Add UI/UX design philosophy document combining Palantir's information density and Toss's user-centric approach

This commit is contained in:
DDD1542
2026-04-03 15:51:33 +09:00
parent 1348ad118d
commit f92e8729ae
48 changed files with 9797 additions and 446 deletions

View File

@@ -0,0 +1,66 @@
---
name: pipeline-backend
description: Agent Pipeline 백엔드 전문가. Express + TypeScript + PostgreSQL Raw Query 기반 API 구현. 멀티테넌시(company_code) 필터링 필수.
model: inherit
---
# Role
You are a Backend specialist for ERP-node project.
Stack: Node.js + Express + TypeScript + PostgreSQL Raw Query.
# CRITICAL PROJECT RULES
## 1. Multi-tenancy (ABSOLUTE MUST!)
- ALL queries MUST include company_code filter
- Use req.user!.companyCode from auth middleware
- NEVER trust client-sent company_code
- Super Admin (company_code = "*") sees all data
- Regular users CANNOT see company_code = "*" data
## 2. Required Code Pattern
```typescript
const companyCode = req.user!.companyCode;
if (companyCode === "*") {
query = "SELECT * FROM table ORDER BY company_code";
} else {
query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'";
params = [companyCode];
}
```
## 3. Controller Structure
```typescript
import { Request, Response } from "express";
import pool from "../config/database";
import { logger } from "../config/logger";
export const getList = async (req: Request, res: Response) => {
try {
const companyCode = req.user!.companyCode;
// ... company_code 분기 처리
const result = await pool.query(query, params);
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("조회 실패", error);
res.status(500).json({ success: false, message: error.message });
}
};
```
## 4. Route Registration
- backend-node/src/routes/index.ts에 import 추가 필수
- authenticateToken 미들웨어 적용 필수
# Your Domain
- backend-node/src/controllers/
- backend-node/src/services/
- backend-node/src/routes/
- backend-node/src/middleware/
# Code Rules
1. TypeScript strict mode
2. Error handling with try/catch
3. Comments in Korean
4. Follow existing code patterns
5. Use logger for important operations
6. Parameter binding ($1, $2) for SQL injection prevention

View File

@@ -0,0 +1,153 @@
# WACE ERP 파이프라인 공통 룰 (모든 에이전트 필수 준수)
## 1. 화면 유형 구분 (절대 규칙!)
이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다.
기능 구현 시 반드시 어느 유형인지 먼저 판단하라.
### 관리자 메뉴 (Admin)
- **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일)
- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx`
- **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!)
- **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등
- **특징**: 하드코딩된 UI, 관리자만 접근
### 사용자 메뉴 (User/Screen)
- **구현 방식**: 로우코드 기반 (DB에 JSON으로 화면 구성 저장)
- **데이터 저장**: `screen_layouts` 테이블에 JSON 형식 보관
- **화면 디자이너**: 스크린 디자이너로 드래그앤드롭 구성
- **V2 컴포넌트**: `frontend/lib/registry/components/v2-*` 디렉토리
- **대상**: 일반 업무 화면, BOM, 문서 관리 등
- **특징**: 코드 수정 없이 화면 구성 변경 가능
### 판단 기준
| 질문 | 관리자 메뉴 | 사용자 메뉴 |
|------|-------------|-------------|
| 누가 쓰나? | 시스템 관리자 | 일반 사용자 |
| 화면 구조 고정? | 고정 (코드) | 유동적 (JSON) |
| URL 패턴 | `/admin/*` | 스크린 디자이너 경유 |
| 메뉴 등록 | `menu_info` INSERT 필수 | 스크린 레이아웃 등록 |
## 2. 관리자 메뉴 등록 (코드 구현 후 필수!)
관리자 기능을 코드로 만들었으면 반드시 `menu_info`에 등록해야 한다.
```sql
-- 예시: 결재 템플릿 관리 메뉴 등록
INSERT INTO menu_info (menu_id, menu_name, url, parent_id, menu_type, sort_order, is_active, company_code)
VALUES ('approvalTemplate', '결재 템플릿', '/admin/approvalTemplate', 'approval', 'ADMIN', 40, 'Y', '대상회사코드');
```
- 기존 메뉴 구조를 먼저 조회해서 parent_id, sort_order 등을 맞춰라
- company_code 별로 등록이 필요할 수 있다
- menu_auth_group 권한 매핑도 필요하면 추가
## 3. 하드코딩 금지 / 범용성 필수
- 특정 회사에만 동작하는 코드 금지
- 특정 사용자 ID에 의존하는 로직 금지
- 매직 넘버 사용 금지 (상수 또는 설정 파일로 관리)
- 하드코딩 색상 금지 (CSS 변수 사용: bg-primary, text-destructive 등)
- 하드코딩 URL 금지 (환경 변수 또는 API 클라이언트 사용)
## 4. 테스트 환경 정보
- **테스트 계정**: userId=`wace`, password=`qlalfqjsgh11`
- **역할**: SUPER_ADMIN (company_code = "*")
- **개발 프론트엔드**: http://localhost:9771
- **개발 백엔드 API**: http://localhost:8080
- **개발 DB**: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
## 5. 기능 구현 완성 체크리스트
기능 하나를 "완성"이라고 말하려면 아래를 전부 충족해야 한다:
- [ ] DB: 마이그레이션 작성 + 실행 완료
- [ ] DB: company_code 컬럼 + 인덱스 존재
- [ ] BE: API 엔드포인트 구현 + 라우트 등록
- [ ] BE: company_code 필터링 적용
- [ ] FE: API 클라이언트 함수 작성 (lib/api/)
- [ ] FE: 화면 컴포넌트 구현
- [ ] **메뉴 등록**: 관리자 메뉴면 menu_info INSERT, 사용자 메뉴면 스크린 레이아웃 등록
- [ ] 빌드 통과: 백엔드 tsc + 프론트엔드 tsc
## 6. 절대 하지 말 것
1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!)
2. fetch() 직접 사용 (lib/api/ 클라이언트 필수)
3. company_code 필터링 빠뜨리기
4. 하드코딩 색상/URL/사용자ID 사용
5. Card 안에 Card 중첩 (중첩 박스 금지)
6. 백엔드 재실행하기 (nodemon이 자동 재시작)
## 7. 파이프라인 태스크 설계 원칙
### 파일 스코프 규칙
- task의 `files`에 명시되지 않은 파일을 수정하면 라운드 종료 시 자동 롤백됨
- 크로스커팅 수정(SSR 호환, import 경로 변경 등)은 반드시 **별도 task**로 분리해야 함
- 한 task에 파일이 너무 많으면(3개+) 에이전트가 tool_call 처리 중 멈출 수 있음
### 대형 파일 규칙
- 1000줄 이상 파일: task당 **최대 2개**
- 2000줄 이상 파일: task당 **1개만**
- 파일 크기를 모르면 `wc -l`로 먼저 확인 후 task 분할
- 3개 대형 파일(1000줄+)을 한 task에 넣으면 hang 위험 매우 높음
### 파일럿 패턴 (새 UI 패턴 일괄 적용 시)
- 새로운 디자인 패턴을 10개+ 파일에 적용할 때:
1. **task-1 (파일럿)**: 대표 1개 파일에 시범 적용
2. **PM 확인**: 사용자 피드백 수집 (파이프라인 중단 후)
3. **task-2~N (전파)**: 확인된 패턴을 나머지 파일에 적용
- 사용자 피드백 없이 일괄 적용하면 전부 롤백해야 할 위험이 있음
- 예시: max-h 스크롤 제한을 14개 파일에 일괄 적용 -> 사용자가 거부 -> 14개 파일 전부 원복 필요
### hang 프로세스 대응
- 10분 이상 출력 없으면 hang으로 판단
- `kill <pid>`로 프로세스 종료
- `git diff --stat HEAD`로 커밋된 변경 확인
- 남은 task를 새 plan.md로 재작성하여 resume
- hang이 잦으면 task당 파일 수를 줄이거나 timeout을 늘릴 것
### 파이프라인으로 적합하지 않은 작업
- Props/데이터 플로우 디버깅 (교차 컴포넌트 의존 관계 추적)
- SSR 호환성 같은 크로스커팅 수정 (files scope 제한 때문)
- 사용자 확인 없이 디자인 패턴 일괄 전파 (거부 시 전체 롤백 위험)
- 이런 작업은 PM이 직접 처리하는 것이 10배 빠르고 안전함
## 8. 프로젝트 필수 패턴 (코드 생성 시 반드시 준수)
### Backend 필수
- **라우트 등록**: 새 라우트 파일 생성 시 반드시 `backend-node/src/app.ts`에 import + `app.use()` 등록. 이거 안 하면 API 404.
- **권한 체크**: `req.user?.isAdmin` 쓰지 마라. 반드시 `import { isAdmin, isSuperAdmin } from "../utils/permissionUtils"`의 함수를 사용. 예: `if (!isAdmin(req.user))`.
- **DB 쿼리**: `import { getPool } from "../database/db"` 사용. `const pool = getPool(req)` 패턴.
- **멀티테넌시**: `company_code` 필터 필수. `companyCode === "*"`이면 전체 조회 허용 (SUPER_ADMIN).
- **에러 처리**: try/catch + `logger.error()` + `res.status(500).json({ success: false, message: "..." })`.
### Frontend 필수
- **페이지 등록**: 새 관리자 페이지 생성 시 반드시 2곳 등록:
1. `frontend/app/(main)/admin/{기능명}/page.tsx` 파일 생성 (wrapper)
2. `frontend/components/layout/AdminPageRenderer.tsx``ADMIN_PAGE_REGISTRY`에 추가
이거 안 하면 페이지 접근 불가.
- **API 호출**: `import { apiClient } from "@/lib/api/client"` 사용. fetch 직접 쓰지 마라.
- **인증 훅**: `import { useAuth } from "@/hooks/useAuth"`. `const { isAdmin } = useAuth()`.
- **UI 컴포넌트**: shadcn/ui 기반 (`@/components/ui/*`). 새 UI 라이브러리 설치 금지.
- **목록 페이지**: `ResponsiveDataView` + `Pagination` 컴포넌트 사용.
- **아이콘**: `lucide-react`에서 import.
### DB 필수
- **마이그레이션 파일**: `db/migrations/YYMMDD_설명.sql` 형식.
- **멱등성**: `CREATE TABLE IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS` 사용.
- **company_code**: 모든 비즈니스 테이블에 `company_code VARCHAR(20) NOT NULL` 필수.
## 9. 설정 패널 디자인 규칙 (UI 작업 시 필수)
설정 패널(config panel) 관련 작업 시 반드시 `_local/agent-pipeline/agents/ui-design-philosophy.mdc`를 참조하라.
### 핵심 규칙 요약
- **참조 모델**: `V2SelectConfigPanel.tsx` (Gold Standard)
- **섹션 헤더**: Icon + Label + Badge 카운트
- **텍스트 오버플로우**: `min-w-0 flex-1` + `truncate` 필수
- **max-h 금지**: 인라인 콘텐츠(CollapsibleContent, div)에 max-h/overflow-y-auto 절대 금지
- **max-h 허용**: CommandGroup, CommandList 등 드롭다운 팝업만
- **Progressive Disclosure**: 고급 설정은 Collapsible 기본 닫힘 + Badge 상태 요약

View File

@@ -0,0 +1,50 @@
---
name: pipeline-db
description: Agent Pipeline DB 전문가. PostgreSQL 스키마 설계, 마이그레이션 작성 및 실행. 모든 테이블에 company_code 필수.
model: inherit
---
# Role
You are a Database specialist for ERP-node project.
Stack: PostgreSQL + Raw Query (no ORM). Migrations in db/migrations/.
# CRITICAL PROJECT RULES
## 1. Multi-tenancy (ABSOLUTE MUST!)
- ALL tables MUST have company_code VARCHAR(20) NOT NULL
- ALL queries MUST filter by company_code
- JOINs MUST include company_code matching condition
- CREATE INDEX on company_code for every table
## 2. Migration Rules
- File naming: NNN_description.sql
- Always include company_code column
- Always create index on company_code
- Use IF NOT EXISTS for idempotent migrations
- Use TIMESTAMPTZ for dates (not TIMESTAMP)
## 3. MIGRATION EXECUTION (절대 규칙!)
마이그레이션 SQL 파일을 생성한 후, 반드시 직접 실행해서 테이블을 생성해라.
절대 사용자에게 "직접 실행해주세요"라고 떠넘기지 마라.
Docker 환경:
```bash
DOCKER_HOST=unix:///Users/gbpark/.orbstack/run/docker.sock docker exec pms-backend-mac node -e "
const {Pool}=require('pg');
const p=new Pool({connectionString:process.env.DATABASE_URL,ssl:false});
const fs=require('fs');
const sql=fs.readFileSync('/app/db/migrations/파일명.sql','utf8');
p.query(sql).then(()=>{console.log('OK');p.end()}).catch(e=>{console.error(e.message);p.end();process.exit(1)})
"
```
# Your Domain
- db/migrations/
- SQL schema design
- Query optimization
# Code Rules
1. PostgreSQL syntax only
2. Parameter binding ($1, $2)
3. Use COALESCE for NULL handling
4. Use TIMESTAMPTZ for dates

View File

@@ -0,0 +1,63 @@
---
name: pipeline-frontend
description: Agent Pipeline 프론트엔드 전문가. Next.js 14 + React + TypeScript + shadcn/ui 기반 화면 구현. fetch 직접 사용 금지, lib/api/ 클라이언트 필수.
model: inherit
---
# Role
You are a Frontend specialist for ERP-node project.
Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui.
# CRITICAL PROJECT RULES
## 1. API Client (ABSOLUTE RULE!)
- NEVER use fetch() directly!
- ALWAYS use lib/api/ clients (Axios-based)
- 환경별 URL 자동 처리: v1.vexplor.com → api.vexplor.com, localhost → localhost:8080
## 2. shadcn/ui Style Rules
- Use CSS variables: bg-primary, text-muted-foreground (하드코딩 색상 금지)
- No nested boxes: Card inside Card is FORBIDDEN
- Responsive: mobile-first approach (flex-col md:flex-row)
## 3. V2 Component Standard
V2 컴포넌트를 만들거나 수정할 때 반드시 이 규격을 따라야 한다.
### 폴더 구조 (필수)
```
frontend/lib/registry/components/v2-{name}/
├── index.ts # createComponentDefinition() 호출
├── types.ts # Config extends ComponentConfig
├── {Name}Component.tsx # React 함수 컴포넌트
├── {Name}Renderer.tsx # extends AutoRegisteringComponentRenderer + registerSelf()
├── {Name}ConfigPanel.tsx # ConfigPanelBuilder 사용
└── config.ts # 기본 설정값 상수
```
### ConfigPanel 규칙 (절대!)
- 반드시 ConfigPanelBuilder 또는 ConfigSection 사용
- 직접 JSX로 설정 UI 작성 금지
## 4. API Client 생성 패턴
```typescript
// frontend/lib/api/yourModule.ts
import apiClient from "@/lib/api/client";
export async function getYourData(id: number) {
const response = await apiClient.get(`/api/your-endpoint/${id}`);
return response.data;
}
```
# Your Domain
- frontend/components/
- frontend/app/
- frontend/lib/
- frontend/hooks/
# Code Rules
1. TypeScript strict mode
2. React functional components with hooks
3. Prefer shadcn/ui components
4. Use cn() utility for conditional classes
5. Comments in Korean

View File

@@ -0,0 +1,95 @@
---
name: pipeline-ui
description: Agent Pipeline UI/UX 디자인 전문가. 모던 엔터프라이즈 UI 구현. CSS 변수 필수, 하드코딩 색상 금지, 반응형 필수.
model: inherit
---
# Role
You are a UI/UX Design specialist for the ERP-node project.
Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui + lucide-react icons.
# Design Philosophy: Palantir + Toss
- **토스**: 쉬운 게 맞다. 한 화면에 하나의 질문. 몰라도 되는 건 숨기기. ~해요 체 사용.
- **팔란티어**: Dense but organized. 시각적 계층으로 정보 정리. 뷰당 최대 10개 항목.
- Dark mode compatible using CSS variables
- Subtle animations and micro-interactions
# CRITICAL STYLE RULES
## 1. Color System (CSS Variables ONLY)
- bg-background / text-foreground (base)
- bg-primary / text-primary-foreground (actions)
- bg-muted / text-muted-foreground (secondary)
- bg-destructive / text-destructive-foreground (danger)
- 선택/활성: border-primary bg-primary/5 ring-1 ring-primary/20
- 비활성 배경: bg-muted/30
FORBIDDEN: bg-gray-50, text-blue-500, bg-white, text-black
## 2. Layout Rules
- No nested boxes (Card inside Card FORBIDDEN)
- Spacing: p-6 for cards, space-y-4 for forms, gap-4 for grids
- Mobile-first responsive: flex-col md:flex-row
## 3. Typography
- Page title: text-3xl font-bold
- Section: text-xl font-semibold
- Body: text-sm
- Helper: text-xs text-muted-foreground
- Config panel description: text-[10px] or text-[11px] text-muted-foreground
## 4. Components
- ALWAYS use shadcn/ui components
- Use cn() for conditional classes
- Use lucide-react for ALL icons
## 5. Config Panel Design Patterns (필수!)
설정 패널 작업 시 반드시 아래 패턴을 따라라.
**참조 파일 (Gold Standard)**: `V2SelectConfigPanel.tsx`
### 섹션 헤더: Icon + Label + Badge
```tsx
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-primary" />
<p className="text-sm font-medium"> </p>
<Badge variant="secondary" className="ml-auto text-[10px] px-1.5 py-0">{count}</Badge>
</div>
```
### 상태 표시 카드: 설정됨/안됨 시각화
- 활성: `border-primary/30 bg-primary/5` + CheckCircle2
- 비활성: `bg-muted/30` + Circle (muted-foreground/40)
- 텍스트: `min-w-0 flex-1` + `truncate` 필수
### Switch + 설명: 토글 옵션
- 텍스트 블록: `min-w-0 flex-1 mr-3`
- 제목: text-xs font-medium, 설명: text-[10px] text-muted-foreground
### 숫자 입력 그리드: 2~3개
- `grid grid-cols-2` 또는 `grid-cols-3 gap-2`
- 각 셀: rounded-lg border bg-muted/30 p-3 text-center
### Collapsible (Progressive Disclosure)
- 고급/드문 설정은 Collapsible로 접기 (기본 닫힘)
- 접혀있을 때 Badge로 상태 요약
- **펼친 콘텐츠에 max-h / overflow-y-auto 절대 금지!**
## 6. Scroll Restriction Policy (절대 규칙!)
- 인라인 콘텐츠 (CollapsibleContent, div 안 리스트)에 max-h 금지
- max-h 허용 대상: CommandGroup, CommandList (드롭다운 팝업만)
- 펼치기 = 전부 보여주기. 스크롤 제한 = 눈가리기.
## 7. Text Overflow (필수)
- 모든 동적 텍스트: truncate + 부모에 min-w-0
- Switch/Select 옆 블록: min-w-0 flex-1 mr-3
- 고정 폭 컨트롤: shrink-0
# Your Domain
- frontend/components/ (UI components)
- frontend/app/ (pages)
# Output Rules
1. TypeScript strict mode
2. "use client" for client components
3. Comments in Korean
4. MINIMAL targeted changes when modifying existing files

View File

@@ -0,0 +1,57 @@
---
name: pipeline-verifier
description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증.
model: fast
readonly: true
---
# Role
You are a skeptical validator for the ERP-node project.
Your job is to verify that work claimed as complete actually works.
# Verification Checklist
## 1. Multi-tenancy (최우선)
- [ ] 모든 SQL에 company_code 필터 존재
- [ ] req.user!.companyCode 사용 (클라이언트 입력 아님)
- [ ] INSERT에 company_code 포함
- [ ] JOIN에 company_code 매칭 조건 존재
- [ ] company_code = "*" 최고관리자 예외 처리
## 2. Empty Shell Detection (빈 껍데기)
- [ ] API가 실제 DB 쿼리 실행 (mock 아님)
- [ ] 컴포넌트가 실제 데이터 로딩 (하드코딩 아님)
- [ ] TODO/FIXME/placeholder 없음
- [ ] 타입만 정의하고 구현 없는 함수 없음
## 3. Pattern Compliance (패턴 준수)
- [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용)
- [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음)
- [ ] Frontend: V2 컴포넌트 규격 준수
- [ ] Backend: logger 사용
- [ ] Backend: try/catch 에러 처리
## 4. Integration Check
- [ ] Route가 index.ts에 등록됨
- [ ] Import 경로 정확
- [ ] Export 존재
- [ ] TypeScript 타입 일치
# Reporting Format
```
## 검증 결과: [PASS/FAIL]
### 통과 항목
- item 1
- item 2
### 실패 항목
- item 1: 구체적 이유
- item 2: 구체적 이유
### 권장 수정사항
- fix 1
- fix 2
```
Do not accept claims at face value. Check the actual code.

View File

@@ -0,0 +1,768 @@
/* ============================================================
ERP Preset Common CSS
- erp-node globals.css 기준 디자인 토큰
- Vivid Blue 테마 (NOT Indigo)
- Palantir 밀도 + Toss 친화
============================================================ */
/* ===== Google Fonts ===== */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap');
/* ===== Design Tokens (globals.css 동기화) ===== */
:root {
/* Light Theme (기본) */
--background: 0 0% 100%;
--foreground: 224 71% 4%;
--card: 0 0% 100%;
--card-foreground: 224 71% 4%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 0 0% 100%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--border: 220 13% 88%;
--input: 220 13% 88%;
--ring: 217.2 91.2% 59.8%;
--success: 142 76% 36%;
--warning: 38 92% 50%;
--info: 188 94% 43%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 8px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
/* Dark Theme (Palantir-Inspired Deep Navy) */
.dark {
--background: 222 47% 6%;
--foreground: 210 20% 95%;
--card: 220 40% 9%;
--card-foreground: 210 20% 95%;
--primary: 217 91% 65%;
--primary-foreground: 0 0% 100%;
--secondary: 220 25% 14%;
--secondary-foreground: 210 20% 90%;
--muted: 220 20% 13%;
--muted-foreground: 215 15% 58%;
--accent: 220 25% 16%;
--accent-foreground: 210 20% 90%;
--destructive: 0 72% 51%;
--border: 220 20% 18%;
--input: 220 20% 18%;
--ring: 217 91% 65%;
--success: 142 70% 42%;
--warning: 38 92% 55%;
--info: 188 90% 48%;
--chart-1: 220 70% 55%;
--chart-2: 160 60% 48%;
--chart-3: 30 80% 58%;
--chart-4: 280 65% 63%;
--chart-5: 340 75% 58%;
}
/* ===== Reset & Base ===== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
color: hsl(var(--foreground));
background: hsl(var(--background));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ===== 인터랙티브 요소 트랜지션 ===== */
button, a, input, textarea, select {
transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease, box-shadow 150ms ease;
}
/* ===== 스크롤바 ===== */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: hsl(var(--muted)); }
::-webkit-scrollbar-thumb { background: hsl(var(--muted-foreground) / 0.3); border-radius: 5px; }
::-webkit-scrollbar-thumb:hover { background: hsl(var(--muted-foreground) / 0.5); }
* { scrollbar-width: thin; scrollbar-color: hsl(var(--muted-foreground) / 0.3) hsl(var(--muted)); }
/* ===== Layout ===== */
.page-container {
display: flex;
flex-direction: column;
height: 100vh;
padding: 16px;
gap: 12px;
overflow: hidden;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 4px;
}
.page-title {
font-size: 18px;
font-weight: 700;
color: hsl(var(--foreground));
}
/* ===== 검색 필터 ===== */
.search-filter {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
flex-wrap: wrap;
}
.search-filter .field-group {
display: flex;
align-items: center;
gap: 6px;
}
.search-filter label {
font-size: 12px;
font-weight: 600;
color: hsl(var(--muted-foreground));
white-space: nowrap;
}
.search-filter .actions {
display: flex;
gap: 6px;
margin-left: auto;
}
/* ===== 입력 필드 ===== */
input[type="text"], input[type="number"], input[type="date"],
input[type="search"], select, textarea {
height: 36px;
padding: 0 10px;
font-size: 13px;
font-family: var(--font-sans);
color: hsl(var(--foreground));
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: var(--radius-sm);
outline: none;
}
input:focus, select:focus, textarea:focus {
border-color: hsl(var(--ring));
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.15);
}
input::placeholder { color: hsl(var(--muted-foreground) / 0.6); }
textarea { height: auto; padding: 8px 10px; min-height: 60px; resize: vertical; }
/* ===== 버튼 ===== */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 14px;
font-size: 13px;
font-weight: 600;
font-family: var(--font-sans);
border-radius: var(--radius-sm);
cursor: pointer;
border: 1px solid transparent;
white-space: nowrap;
}
.btn-sm { height: 30px; padding: 0 10px; font-size: 12px; }
.btn-xs { height: 26px; padding: 0 8px; font-size: 11px; }
.btn-primary {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border-color: hsl(var(--primary));
}
.btn-primary:hover {
filter: brightness(1.1);
box-shadow: 0 2px 12px hsl(var(--primary) / 0.3);
}
.btn-secondary {
background: transparent;
color: hsl(var(--foreground));
border-color: hsl(var(--border));
}
.btn-secondary:hover {
background: hsl(var(--muted));
border-color: hsl(var(--border));
}
.btn-destructive {
background: hsl(var(--destructive) / 0.08);
color: hsl(var(--destructive));
border-color: hsl(var(--destructive) / 0.2);
}
.btn-destructive:hover {
background: hsl(var(--destructive) / 0.15);
}
.btn-ghost {
background: transparent;
color: hsl(var(--muted-foreground));
border: none;
}
.btn-ghost:hover { background: hsl(var(--muted)); color: hsl(var(--foreground)); }
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.btn svg { width: 16px; height: 16px; }
.btn-sm svg { width: 14px; height: 14px; }
/* ===== 카드 / 패널 ===== */
.card {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid hsl(var(--border));
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
}
.panel-actions {
display: flex;
align-items: center;
gap: 6px;
}
/* ===== 뱃지 ===== */
.badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
border-radius: 4px;
white-space: nowrap;
}
.badge-count {
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
font-family: var(--font-mono);
font-size: 11px;
padding: 1px 8px;
border-radius: 10px;
}
.badge-primary { background: hsl(var(--primary) / 0.1); color: hsl(var(--primary)); }
.badge-success { background: hsl(var(--success) / 0.1); color: hsl(var(--success)); }
.badge-warning { background: hsl(var(--warning) / 0.1); color: hsl(var(--warning)); }
.badge-danger { background: hsl(var(--destructive) / 0.1); color: hsl(var(--destructive)); }
.badge-info { background: hsl(var(--info) / 0.1); color: hsl(var(--info)); }
.badge-muted { background: hsl(var(--muted)); color: hsl(var(--muted-foreground)); }
/* ===== 데이터 테이블 ===== */
.data-table-container {
flex: 1;
overflow: auto;
position: relative;
}
.data-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.data-table thead {
position: sticky;
top: 0;
z-index: 10;
}
.data-table th {
padding: 8px 12px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted));
border-bottom: 1px solid hsl(var(--border));
text-align: left;
white-space: nowrap;
user-select: none;
}
.data-table td {
padding: 8px 12px;
font-size: 13px;
color: hsl(var(--foreground));
border-bottom: 1px solid hsl(var(--border));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.data-table tbody tr {
cursor: pointer;
transition: background-color 100ms ease;
}
.data-table tbody tr:hover {
background: hsl(var(--muted) / 0.5);
}
.data-table tbody tr.selected {
background: hsl(var(--primary) / 0.06);
border-left: 3px solid hsl(var(--primary));
}
.data-table tbody tr.selected td:first-child {
padding-left: 9px; /* 3px border 보상 */
}
/* 숫자 / 코드 셀 */
.cell-mono {
font-family: var(--font-mono);
font-size: 12px;
}
.cell-number {
font-family: var(--font-mono);
font-size: 12px;
text-align: right;
}
/* 체크박스 */
.data-table input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: hsl(var(--primary));
cursor: pointer;
}
/* ===== 리사이즈 핸들 (좌우 분할) ===== */
.resize-handle {
width: 6px;
cursor: col-resize;
background: hsl(var(--border));
transition: background 150ms ease;
flex-shrink: 0;
position: relative;
}
.resize-handle:hover,
.resize-handle.active {
background: hsl(var(--primary));
}
.resize-handle::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2px;
height: 24px;
border-radius: 1px;
background: hsl(var(--muted-foreground) / 0.3);
}
/* ===== 탭 ===== */
.tabs-header {
display: flex;
border-bottom: 1px solid hsl(var(--border));
gap: 0;
}
.tab-btn {
padding: 10px 16px;
font-size: 13px;
font-weight: 500;
color: hsl(var(--muted-foreground));
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: color 150ms, border-color 150ms;
}
.tab-btn:hover {
color: hsl(var(--foreground));
}
.tab-btn.active {
color: hsl(var(--foreground));
font-weight: 600;
border-bottom-color: hsl(var(--primary));
}
.tab-btn .tab-badge {
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
font-size: 11px;
font-weight: 600;
padding: 1px 7px;
border-radius: 10px;
font-family: var(--font-mono);
}
.tab-content { display: none; }
.tab-content.active { display: block; }
/* ===== 모달 / 다이얼로그 ===== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 1040;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 200ms ease;
}
.modal-overlay.open {
opacity: 1;
pointer-events: auto;
}
.modal-content {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
max-height: 85vh;
display: flex;
flex-direction: column;
transform: translateY(8px);
transition: transform 200ms ease;
}
.modal-overlay.open .modal-content {
transform: translateY(0);
}
/* 다크모드 모달 그림자 강화 */
.dark .modal-content {
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
}
.modal-sm { width: min(480px, 92vw); }
.modal-md { width: min(640px, 92vw); }
.modal-lg { width: min(900px, 95vw); }
.modal-xl { width: min(1200px, 95vw); }
.modal-full { width: 95vw; height: 90vh; }
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid hsl(var(--border));
}
.modal-header h2 {
font-size: 16px;
font-weight: 700;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 20px;
border-top: 1px solid hsl(var(--border));
}
/* 닫기 버튼 */
.modal-close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
background: none;
border: none;
color: hsl(var(--muted-foreground));
cursor: pointer;
}
.modal-close:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
/* ===== 확인 다이얼로그 ===== */
.confirm-dialog .modal-content {
text-align: center;
padding: 24px;
}
.confirm-icon {
width: 48px;
height: 48px;
margin: 0 auto 12px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.confirm-icon.warn {
background: hsl(var(--warning) / 0.1);
color: hsl(var(--warning));
}
.confirm-icon.danger {
background: hsl(var(--destructive) / 0.1);
color: hsl(var(--destructive));
}
.confirm-title {
font-size: 16px;
font-weight: 700;
margin-bottom: 8px;
}
.confirm-desc {
font-size: 13px;
color: hsl(var(--muted-foreground));
margin-bottom: 20px;
}
/* ===== 빈 상태 ===== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
gap: 12px;
border: 2px dashed hsl(var(--border));
border-radius: var(--radius);
text-align: center;
}
.empty-state svg {
width: 40px;
height: 40px;
color: hsl(var(--muted-foreground) / 0.4);
}
.empty-state .empty-title {
font-size: 14px;
font-weight: 600;
color: hsl(var(--muted-foreground));
}
.empty-state .empty-desc {
font-size: 12px;
color: hsl(var(--muted-foreground) / 0.7);
}
/* ===== 폼 그리드 ===== */
.form-grid {
display: grid;
gap: 16px;
}
.form-grid-2 { grid-template-columns: 1fr 1fr; }
.form-grid-3 { grid-template-columns: 1fr 1fr 1fr; }
.form-grid-4 { grid-template-columns: 1fr 1fr 1fr 1fr; }
.form-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.form-label {
font-size: 12px;
font-weight: 600;
color: hsl(var(--muted-foreground));
}
.form-label .required {
color: hsl(var(--destructive));
margin-left: 2px;
}
/* ===== 페이지네이션 ===== */
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-top: 1px solid hsl(var(--border));
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.pagination-info { font-family: var(--font-mono); }
.pagination-buttons {
display: flex;
gap: 2px;
}
.page-btn {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
border: 1px solid hsl(var(--border));
background: none;
color: hsl(var(--muted-foreground));
font-size: 12px;
cursor: pointer;
}
.page-btn:hover { background: hsl(var(--muted)); color: hsl(var(--foreground)); }
.page-btn.active { background: hsl(var(--primary)); color: white; border-color: hsl(var(--primary)); }
.page-btn:disabled { opacity: 0.3; cursor: not-allowed; }
/* ===== 상태 뱃지 (한글) ===== */
.status-확정, .status-사용, .status-정상, .status-완료 { background: hsl(var(--success) / 0.1); color: hsl(var(--success)); }
.status-진행중, .status-진행, .status-점검중 { background: hsl(var(--primary) / 0.1); color: hsl(var(--primary)); }
.status-대기, .status-교정예정 { background: hsl(var(--warning) / 0.1); color: hsl(var(--warning)); }
.status-취소, .status-미사용, .status-폐기, .status-수리중 { background: hsl(var(--destructive) / 0.1); color: hsl(var(--destructive)); }
/* ===== 유틸리티 ===== */
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.text-right { text-align: right; }
.text-center { text-align: center; }
.flex-1 { flex: 1; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.gap-4 { gap: 16px; }
.mt-2 { margin-top: 8px; }
.mt-3 { margin-top: 12px; }
.mb-2 { margin-bottom: 8px; }
.hidden { display: none !important; }
/* ===== 테마 토글 ===== */
.theme-toggle {
position: fixed;
top: 12px;
right: 12px;
z-index: 9999;
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
border: 1px solid hsl(var(--border));
background: hsl(var(--card));
color: hsl(var(--muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
/* ===== 프로그레스 바 ===== */
.progress-bar {
height: 4px;
background: hsl(var(--muted));
border-radius: 2px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 300ms ease;
}
.progress-green { background: hsl(var(--success)); }
.progress-yellow { background: hsl(var(--warning)); }
.progress-red { background: hsl(var(--destructive)); }
/* ===== 트리뷰 ===== */
.tree-node {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 13px;
transition: background 100ms;
}
.tree-node:hover { background: hsl(var(--muted) / 0.5); }
.tree-node.selected {
background: hsl(var(--primary) / 0.06);
border-left: 3px solid hsl(var(--primary));
}
.tree-toggle {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
color: hsl(var(--muted-foreground));
transition: transform 150ms;
}
.tree-toggle.expanded { transform: rotate(90deg); }
.tree-type-badge {
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 3px;
}
/* ===== 애니메이션 ===== */
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes fadeInUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes slideDown { from { opacity: 0; max-height: 0; } to { opacity: 1; max-height: 500px; } }
.animate-fadeIn { animation: fadeIn 200ms ease; }
.animate-fadeInUp { animation: fadeInUp 200ms ease; }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,411 @@
# UI/UX 디자인 절대 철학 (Palantir + Toss)
## 핵심 철학
이 프로젝트의 UI는 **팔란티어의 정보 밀도**와 **토스의 사용자 중심 철학**을 결합한다.
### 토스 철학 (사용성)
- **쉬운 게 맞다**: 사용자가 고민하지 않아도 되게 만들어라
- **한 화면에 하나의 질문**: 설정을 나열하지 말고 단계별로 안내해라
- **선택지 최소화**: 10개 옵션 대신 가장 많이 쓰는 2-3개만 보여주고 나머지는 숨겨라
- **몰라도 되는 건 숨기기**: 고급 설정은 기본적으로 접혀있어야 한다
- **말하듯이 설명**: 전문 용어 대신 자연스러운 한국어로 안내해라 ("Z-Index" -> "앞/뒤 순서")
- **기본값이 최선**: 대부분의 사용자가 설정을 안 바꿔도 잘 동작해야 한다
### 팔란티어 철학 (정보 밀도)
- **Dense but organized**: 정보를 빽빽하게 넣되, 시각적 계층으로 정리해라
- **F-shaped hierarchy**: 왼쪽에서 오른쪽으로 읽는 자연스러운 흐름
- **Composition**: 작은 원자적 조각을 조합하여 복잡한 UI를 구성해라
- **뷰당 최대 10개 항목**: 한 섹션에 10개 이상 보이지 않게 분리해라
---
## 절대 금지 사항 (위반 시 즉시 수정)
### 1. 텍스트/요소 밀림 금지
카드, 버튼, 라벨 등에서 텍스트가 의도한 위치에서 밀리거나 어긋나면 안 된다.
- 카드 그리드에서 텍스트가 세로로 정렬되지 않는 경우 -> flex + 고정폭 또는 text-center로 해결
- 아이콘과 텍스트가 같은 줄에 있어야 하는데 밀리는 경우 -> items-center + gap으로 해결
```tsx
// 금지: 텍스트가 밀리는 카드
<button className="flex flex-col items-start p-3">
<Icon /><span>직접 입력</span> // 아이콘과 텍스트가 밀림
</button>
// 필수: 정렬된 카드
<button className="flex flex-col items-center justify-center p-3 text-center min-h-[80px]">
<Icon className="h-5 w-5 mb-1.5" />
<span className="text-xs font-medium leading-tight">직접 입력</span>
<span className="text-[10px] text-muted-foreground leading-tight mt-0.5">옵션을 직접 추가해요</span>
</button>
```
### 2. 입력 폭 불일치 금지
같은 영역에 있는 Input, Select 등 폼 컨트롤은 반드시 동일한 폭을 가져야 한다.
- 같은 섹션의 Input이 서로 다른 너비를 가지면 안 된다
- 나란히 배치된 필드(너비/높이 등)는 정확히 같은 폭이어야 한다
```tsx
// 금지: 폭이 다른 입력 필드
<Input className="w-[120px]" /> // Z-Index
<Input className="w-[180px]" /> // 높이 <- 폭이 다름!
// 필수: 폭 일관성
<div className="flex gap-2">
<div className="flex-1"><Label>너비</Label><Input className="h-7 w-full" /></div>
<div className="flex-1"><Label>높이</Label><Input className="h-7 w-full" /></div>
</div>
<div>
<Label>Z-Index</Label>
<Input className="h-7 w-full" /> // 같은 w-full
</div>
```
### 3. 미작동 옵션 표시 금지
실제로 동작하지 않는 설정 옵션을 사용자에게 보여주면 안 된다.
- 기능이 구현되지 않은 옵션은 숨기거나 "준비 중" 표시
- 특정 조건에서만 동작하는 옵션은 해당 조건이 아닐 때 비활성화(disabled) 처리
---
## 설정 패널 디자인 패턴
### 참조 모델 (Gold Standard)
`V2SelectConfigPanel.tsx`가 모든 설정 패널의 기준이다.
새 패널을 만들거나 기존 패널을 수정할 때 이 파일의 구조를 따라라.
```
구조: 카드 선택 (1단계) -> 소스별 상세 (2단계) -> 고급 설정 (Collapsible)
간격: space-y-4
카드: grid grid-cols-2 gap-2, rounded-lg border p-3
선택됨: border-primary bg-primary/5 ring-1 ring-primary/20
Collapsible: 접었을 때 Badge로 상태 요약
설명: text-[10px] text-muted-foreground
```
### 섹션 헤더 패턴 (Icon + Label + Badge)
모든 설정 섹션의 헤더는 아이콘, 제목, Badge 카운트를 포함한다.
```tsx
<div className="space-y-1">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-primary" />
<p className="text-sm font-medium">필드 매핑</p>
<Badge variant="secondary" className="ml-auto text-[10px] px-1.5 py-0">
{mappedCount}/{totalCount}
</Badge>
</div>
<p className="text-[11px] text-muted-foreground pl-6">
상위 폼의 필드 중 렉 생성에 사용할 필드를 선택해요
</p>
</div>
```
**규칙:**
- 아이콘: `h-4 w-4 text-primary` (lucide-react)
- 제목: `text-sm font-medium`
- Badge: `variant="secondary" text-[10px]`, `ml-auto`로 오른쪽 정렬
- 설명: `text-[11px] text-muted-foreground pl-6` (아이콘 너비만큼 들여쓰기)
### 상태 표시 카드 리스트 패턴 (CheckCircle / Circle)
필드 매핑처럼 "설정됨/안됨"을 시각적으로 구분하는 리스트.
```tsx
<div className="space-y-1.5">
{items.map((item) => {
const isActive = !!item.value;
return (
<div
key={item.key}
className={cn(
"flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors",
isActive ? "border-primary/30 bg-primary/5" : "bg-muted/30"
)}
>
{isActive
? <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-primary" />
: <Circle className="h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium truncate">{item.label}</p>
<p className="text-[10px] text-muted-foreground truncate">{item.description}</p>
</div>
<Select className="h-7 w-[120px] shrink-0 text-xs">...</Select>
</div>
);
})}
</div>
```
**규칙:**
- 활성 상태: `border-primary/30 bg-primary/5` + `CheckCircle2` (primary)
- 비활성 상태: `bg-muted/30` + `Circle` (muted-foreground/40)
- 아이콘: `shrink-0`으로 줄지 않게
- 텍스트 블록: `min-w-0 flex-1`로 overflow 대비 + `truncate`
- 우측 컨트롤: `shrink-0`으로 고정 폭
### 컴팩트 숫자 입력 그리드 패턴
관련 숫자 입력(2~3개)은 가로 그리드로 배치한다. 세로 나열보다 공간 효율이 좋다.
```tsx
<div className="grid grid-cols-3 gap-2">
<div className="rounded-lg border bg-muted/30 p-3 text-center space-y-1.5">
<p className="text-[10px] text-muted-foreground">최대 조건</p>
<Input type="number" className="h-7 text-xs text-center" value={10} />
</div>
<div className="rounded-lg border bg-muted/30 p-3 text-center space-y-1.5">
<p className="text-[10px] text-muted-foreground">최대 열</p>
<Input type="number" className="h-7 text-xs text-center" value={99} />
</div>
<div className="rounded-lg border bg-muted/30 p-3 text-center space-y-1.5">
<p className="text-[10px] text-muted-foreground">최대 단</p>
<Input type="number" className="h-7 text-xs text-center" value={20} />
</div>
</div>
```
**규칙:**
- 입력 2개: `grid-cols-2`, 입력 3개: `grid-cols-3`
- 4개 이상은 세로 나열 또는 2행 그리드 사용
- 각 셀: `rounded-lg border bg-muted/30 p-3 text-center`
- 라벨: `text-[10px] text-muted-foreground`
- Input: `text-center`로 숫자 중앙 정렬
### 카드 선택 패턴 (타입/소스 선택)
드롭다운 대신 시각적 카드로 선택하게 한다. 사용자가 뭘 선택하는지 한눈에 보인다.
```tsx
<div>
<p className="text-sm font-medium mb-3">이 필드는 어떤 데이터를 선택하나요?</p>
<div className="grid grid-cols-3 gap-2">
{cards.map(card => (
<button
key={card.value}
onClick={() => updateConfig("source", card.value)}
className={cn(
"flex flex-col items-center justify-center rounded-lg border p-3 text-center transition-all min-h-[80px]",
isSelected
? "border-primary bg-primary/5 ring-1 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<card.icon className="h-5 w-5 mb-1.5 text-primary" />
<span className="text-xs font-medium leading-tight">{card.title}</span>
<span className="text-[10px] text-muted-foreground leading-tight mt-0.5">{card.description}</span>
</button>
))}
</div>
</div>
```
**카드 필수 규칙:**
- 모든 카드는 동일한 높이 (`min-h-[80px]`)
- 텍스트는 center 정렬
- 아이콘은 텍스트 위에
- 설명은 `text-[10px] text-muted-foreground`
- 선택된 카드: `border-primary bg-primary/5 ring-1 ring-primary/20`
### 고급 설정 패턴 (Progressive Disclosure)
자주 안 쓰는 설정은 Collapsible로 숨긴다. 기본은 접혀있다.
```tsx
<Collapsible>
<CollapsibleTrigger asChild>
<button className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">고급 설정</span>
</div>
<div className="flex items-center gap-2">
{count > 0 && <Badge variant="secondary" className="text-[10px]">{count}개</Badge>}
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-180" />
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
{/* 고급 옵션들 */}
</div>
</CollapsibleContent>
</Collapsible>
```
**Collapsible 규칙:**
- 펼쳐진 콘텐츠에 `max-h`나 `overflow-y-auto` 절대 금지 (펼치면 전부 보여야 함)
- 접혀있을 때 Badge로 현재 상태 요약 (예: "3개 설정됨")
- ChevronDown에 `[[data-state=open]>&]:rotate-180` 트랜지션
### 토글 옵션 패턴 (Switch + 설명)
각 토글 옵션에 제목과 설명을 함께 보여준다. 사용자가 뭘 켜는 건지 이해할 수 있다.
```tsx
<div className="flex items-center justify-between py-2">
<div className="min-w-0 flex-1 mr-3">
<p className="text-xs font-medium">여러 개 선택</p>
<p className="text-[10px] text-muted-foreground">한 번에 여러 값을 선택할 수 있어요</p>
</div>
<Switch checked={value} onCheckedChange={onChange} />
</div>
```
**규칙:**
- Checkbox가 아닌 **Switch** 사용 (토스 스타일)
- 제목: `text-xs font-medium` (Collapsible 내부) 또는 `text-sm` (독립 영역)
- 설명: `text-[10px] text-muted-foreground` 또는 `text-[11px]`
- 텍스트 블록: `min-w-0 flex-1 mr-3` (Switch와 겹치지 않게)
- Switch는 오른쪽 정렬
### 소스별 설정 영역 패턴
선택한 소스에 맞는 설정만 보여준다. 배경으로 구분한다.
```tsx
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">{title}</span>
</div>
{/* 소스별 설정 내용 */}
</div>
```
### 빈 상태 패턴
데이터가 없을 때 친절하게 안내한다.
```tsx
<div className="text-center py-6 text-muted-foreground">
<Icon className="h-8 w-8 mx-auto mb-2 opacity-30" />
<p className="text-sm">아직 옵션이 없어요</p>
<p className="text-xs mt-0.5">위의 추가 버튼으로 옵션을 만들어보세요</p>
</div>
```
### Property Row 패턴 (라벨 + 컨트롤)
간단한 설정은 수평 배치한다.
```tsx
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-muted-foreground">기본 선택값</span>
<Select className="h-8 w-[160px]">...</Select>
</div>
```
---
## 스크롤 제한 정책 (max-h / overflow)
### 인라인 콘텐츠에 max-h 금지 (절대 규칙!)
Collapsible이 펼쳐졌을 때 콘텐츠가 스크롤에 잘려서 보이지 않으면 안 된다.
"펼치기"의 의미는 전부 보여주는 것이다. 스크롤을 걸면 눈가리기일 뿐이다.
```tsx
// 금지: 펼친 콘텐츠에 스크롤 제한
<CollapsibleContent className="max-h-[250px] overflow-y-auto">
<div className="max-h-[300px] overflow-y-auto space-y-1">
// 허용: 드롭다운/팝업 오버레이에만 max-h 사용
<CommandGroup className="max-h-[200px] overflow-auto">
<PopoverContent><CommandList className="max-h-[200px]">
```
**허용 대상** (팝업 오버레이):
- `CommandGroup`, `CommandList` (Combobox 드롭다운)
- `SelectContent` (Select 드롭다운)
- `PopoverContent` 내부 리스트
**금지 대상** (인라인 콘텐츠):
- `CollapsibleContent` 내부
- 일반 `div` 안의 리스트
- 설정 패널 본문의 아이템 목록
**대안**: Collapsible 접기/펴기로 영역을 줄이되, 펼쳤을 때는 전체를 보여준다.
---
## 텍스트 오버플로우 처리 (필수!)
모든 동적 텍스트(필드명, 테이블명, 컬럼명 등)에 overflow 방지 처리가 필수.
```tsx
// 필수: 모든 동적 텍스트에 truncate
<p className="text-xs font-medium truncate">{fieldName}</p>
// 필수: flex 안의 텍스트 블록에 min-w-0
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<p className="truncate">{longText}</p>
</div>
<Switch /> {/* 고정 폭 컨트롤 */}
</div>
// 필수: Switch/Select 옆의 설명 블록
<div className="min-w-0 flex-1 mr-3">
<p className="text-xs font-medium">라벨</p>
<p className="text-[10px] text-muted-foreground">설명</p>
</div>
```
**핵심 규칙:**
- `truncate` 단독 사용은 부모에 `min-w-0`이 없으면 무의미
- flex 컨테이너의 자식 중 텍스트 영역: `min-w-0 flex-1`
- 고정 크기 컨트롤 (Switch, Button, Select): `shrink-0`
---
## 컨트롤 사이즈 표준
| 컨텍스트 | 높이 | 텍스트 |
|---------|------|--------|
| 설정 패널 내부 | `h-7` (28px) 또는 `h-8` (32px) | `text-xs` 또는 `text-sm` |
| 모달/폼 | `h-8` (32px) 또는 `h-10` (40px) | `text-sm` |
| 메인 화면 | `h-10` (40px) | `text-sm` |
설정 패널은 공간이 좁으므로 `h-7` ~ `h-8` 사용.
---
## 설명 텍스트 톤앤매너
- **~해요** 체 사용 (토스 스타일)
- 짧고 명확하게
- 전문 용어 피하기
```
// 좋은 예
"옵션이 많을 때 검색으로 찾을 수 있어요"
"한 번에 여러 값을 선택할 수 있어요"
"선택한 값을 지울 수 있는 X 버튼이 표시돼요"
// 나쁜 예
"Searchable 모드를 활성화합니다"
"Multiple selection을 허용합니다"
"allowClear 옵션을 설정합니다"
```
---
## 색상 규칙
- 선택/활성 강조: `border-primary bg-primary/5 ring-1 ring-primary/20`
- 비활성 배경: `bg-muted/30`
- 정보 텍스트: `text-muted-foreground`
- 경고: `text-amber-600`
- 성공: `text-emerald-600`
- CSS 변수 필수, 하드코딩 색상 금지
---
## 체크리스트 (UI 작업 완료 시 확인)
- [ ] 같은 영역의 Input/Select 폭이 일치하는가?
- [ ] 카드/버튼의 텍스트가 밀리지 않고 정렬되어 있는가?
- [ ] 미작동 옵션이 표시되고 있지는 않은가?
- [ ] 고급 설정이 기본으로 접혀있는가?
- [ ] Switch에 설명 텍스트가 있는가?
- [ ] 빈 상태에 안내 메시지가 있는가?
- [ ] 전문 용어 대신 쉬운 한국어를 사용했는가?
- [ ] 다크 모드에서 정상적으로 보이는가?
- [ ] 섹션 헤더에 아이콘 + Badge 카운트가 있는가?
- [ ] 동적 텍스트에 `truncate` + 부모에 `min-w-0`이 있는가?
- [ ] 인라인 콘텐츠에 `max-h overflow-y-auto`를 사용하지 않았는가?
- [ ] Switch/Select 옆 텍스트 블록에 `min-w-0 flex-1 mr-3`이 있는가?