diff --git a/docs/design-system.md b/docs/design-system.md index 3e73f35e..152703a3 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -1,292 +1,416 @@ # 디자인 시스템 -> StackUp 프론트엔드의 시각·행동 규약. Figma 토큰과 1:1로 동기화된다. -> 구현 위치: `frontend/src/app/styles/`, `frontend/src/shared/ui/` +> StackUp 프론트엔드 시각·행동 규약. **Tailwind CSS v4 기반 단일 출처(SSOT).** +> 구현 — [`frontend/src/app/styles/`](../frontend/src/app/styles/) (`tokens.css`, `global.css`, `index.css`) +> 데모 페이지 — [`frontend/src/App.tsx`](../frontend/src/App.tsx) (`npm run dev` → `http://localhost:5173`) +> 컴포넌트 — [`frontend/src/shared/ui/`](../frontend/src/shared/ui/) (도메인 비종속), [`frontend/src/features/*/ui/`](../frontend/src/features/) (도메인 종속) --- ## 1. 디자인 원칙 -1. **신뢰감 우선** — 면접 도구이므로 가벼운 톤 지양. 진중하고 차분한 시각 언어. -2. **집중 환경 보호** — 면접 화면은 노이즈 최소화. 핵심 UI만 노출, 부가 정보는 hover/click으로 점진적 공개. -3. **상태 가시성** — 분석 진행, 세션 진행 등 비동기 상태는 항상 명시적으로 보여준다. -4. **접근성 (WCAG 2.1 AA)** — 키보드 only 조작 가능, 명도 대비 4.5:1 이상. -5. **모바일 웹뷰 대응** — 데스크탑 우선이지만 mobile breakpoint에서도 깨지지 않게. +1. **신뢰감 우선** — 면접 도구이므로 가벼운 톤 지양. 진중·차분한 시각 언어. +2. **집중 환경 보호** — 면접 화면은 노이즈 최소화. 핵심 UI만 노출, 부가 정보는 hover/click 으로 점진적 공개. +3. **상태 가시성** — 분석 진행, 세션 상태, SSE 이벤트 등 비동기 상태는 항상 명시적으로 표현. +4. **모노크로매틱 + 의미 컬러** — Sage 단일 베이스로 톤 일관성을 유지하고, Status / Domain 만 muted jewel tone 으로 식별성 부여. +5. **접근성 (WCAG 2.1 AA)** — 키보드 only 조작, 명도 대비 4.5:1 이상, focus ring 명확. +6. **모바일 웹뷰 대응** — 데스크탑 우선이지만 mobile breakpoint(`< lg`)에서도 깨지지 않게. --- -## 2. 디자인 토큰 - -### 2.1 컬러 - -#### Brand -| 토큰 | Light | Dark | 용도 | -|------|-------|------|------| -| `--color-brand-primary` | `#2563EB` | `#3B82F6` | CTA, 활성 상태 | -| `--color-brand-primary-hover` | `#1D4ED8` | `#60A5FA` | hover | -| `--color-brand-primary-pressed` | `#1E40AF` | `#2563EB` | active | -| `--color-brand-secondary` | `#10B981` | `#34D399` | 성공·완료 | - -#### Semantic -| 토큰 | Light | Dark | 용도 | -|------|-------|------|------| -| `--color-success` | `#10B981` | `#34D399` | 성공 메시지, ANALYZED 뱃지 | -| `--color-warning` | `#F59E0B` | `#FBBF24` | 경고, PROCESSING | -| `--color-danger` | `#EF4444` | `#F87171` | 에러, FAILED, 삭제 | -| `--color-info` | `#3B82F6` | `#60A5FA` | 정보 토스트 | - -#### Neutral -| 토큰 | 16진 | 용도 | -|------|------|------| -| `--color-bg` | `#FFFFFF` / `#0B0F19` | 페이지 배경 | -| `--color-surface` | `#F9FAFB` / `#111827` | 카드, 모달 | -| `--color-surface-raised` | `#FFFFFF` / `#1F2937` | 모달, 팝오버 | -| `--color-border` | `#E5E7EB` / `#374151` | 보더 | -| `--color-text-primary` | `#111827` / `#F9FAFB` | 본문 | -| `--color-text-secondary` | `#6B7280` / `#9CA3AF` | 부가 정보 | -| `--color-text-disabled` | `#D1D5DB` / `#4B5563` | 비활성 | - -#### 도메인 컬러 (직군 / 면접 유형) -| 토큰 | 색상 | -|------|------| -| `--color-job-frontend` | `#22D3EE` (cyan) | -| `--color-job-backend` | `#A78BFA` (violet) | -| `--color-job-infra` | `#FB7185` (rose) | -| `--color-job-dba` | `#FACC15` (amber) | -| `--color-type-personality` | `#34D399` | -| `--color-type-technical` | `#60A5FA` | -| `--color-type-live-coding` | `#F472B6` | -| `--color-type-integrated` | `#A78BFA` | - -### 2.2 타이포그래피 - -폰트 스택: -```css ---font-sans: 'Pretendard Variable', 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, sans-serif; ---font-mono: 'JetBrains Mono', 'D2Coding', 'Menlo', monospace; -``` +## 2. 디자인 토큰 — 단일 출처 -| 토큰 | 크기 / 라인높이 / 굵기 | 용도 | -|------|------------------------|------| -| `--type-display` | 48 / 56 / 700 | 랜딩 히어로 | -| `--type-h1` | 32 / 40 / 700 | 페이지 제목 | -| `--type-h2` | 24 / 32 / 700 | 섹션 제목 | -| `--type-h3` | 20 / 28 / 600 | 카드 제목 | -| `--type-h4` | 18 / 26 / 600 | 서브 제목 | -| `--type-body-lg` | 16 / 24 / 400 | 본문 (기본) | -| `--type-body-md` | 14 / 20 / 400 | 보조 본문 | -| `--type-body-sm` | 12 / 18 / 400 | 캡션, 메타 | -| `--type-code-md` | 14 / 20 / 400 (mono) | 코드 | +모든 토큰은 [`frontend/src/app/styles/tokens.css`](../frontend/src/app/styles/tokens.css) 의 `@theme` 블록에만 정의. 컴포넌트는 토큰만 참조하고 **하드코딩 금지** (`bg-[#626e5c]` ✗ → `bg-primary` ✓). -### 2.3 간격 (Spacing) — 4px grid +### 2.1 컬러 — Base -| 토큰 | 값 | -|------|----| -| `--space-1` | 4px | -| `--space-2` | 8px | -| `--space-3` | 12px | -| `--space-4` | 16px | -| `--space-5` | 20px | -| `--space-6` | 24px | -| `--space-8` | 32px | -| `--space-10` | 40px | -| `--space-12` | 48px | -| `--space-16` | 64px | - -### 2.4 라디우스 +| 토큰 | Hex | Tailwind | 용도 | +|---|---|---|---| +| `--color-white` | `#ffffff` | `bg-white`, `text-white` | 순백 | +| `--color-black` | `#000000` | `bg-black` | 순흑 | +| `--color-background` | `#e9e8e7` | `bg-background` (= `bg-bg`) | 앱 기본 배경 | + +### 2.2 컬러 — Sage Scale (메인 테마, 11 단계) + +| 토큰 | Hex | 권장 용도 | +|---|---|---| +| `sage-50` | `#e8e7e1` | 가장 밝은 컴포넌트 배경 (= `surface`) | +| `sage-100` | `#d4cfcb` | 분리선 / 보더 (= `border`) | +| `sage-200` | `#c9ccc8` | 비활성 텍스트 / 보조 배경 (= `border-strong`, `fg-disabled`) | +| `sage-300` | `#b4bdaf` | 보조 텍스트 (= `fg-subtle`) | +| `sage-400` | `#a0a89d` | 보조 텍스트 강조 (= `fg-muted`) | +| `sage-500` | `#626e5c` | **Primary**, 활성 / 포커스 | +| `sage-600` | `#3e4739` | Primary hover | +| `sage-700` | `#2b3625` | Primary pressed / 강조 컴포넌트 | +| `sage-800` | `#1f271b` | 주요 헤딩 (= `fg-strong`) | +| `sage-900` | `#181e15` | 본문 (대안) | +| `sage-950` | `#141a11` | 가장 어두운 텍스트 (= `fg`, 기본) | -| 토큰 | 값 | 용도 | -|------|----|------| -| `--radius-sm` | 4px | 뱃지 | -| `--radius-md` | 8px | 버튼, 인풋 | -| `--radius-lg` | 12px | 카드 | -| `--radius-xl` | 16px | 모달 | -| `--radius-pill` | 9999px | 칩, 아바타 | +Tailwind 사용: `bg-sage-{n}`, `text-sage-{n}`, `border-sage-{n}`. -### 2.5 그림자 (Elevation) +### 2.3 컬러 — Semantic Aliases -| 토큰 | 값 | -|------|----| -| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.06)` | -| `--shadow-md` | `0 4px 6px -1px rgba(0,0,0,0.10), 0 2px 4px -2px rgba(0,0,0,0.06)` | -| `--shadow-lg` | `0 10px 15px -3px rgba(0,0,0,0.10), 0 4px 6px -4px rgba(0,0,0,0.05)` | -| `--shadow-focus-ring` | `0 0 0 3px rgba(37,99,235,0.45)` | +`sage-*` 직접 참조보다 의미 별칭 사용을 권장 (다크 모드 도입 시 자동 대응). + +| 토큰 | 매핑 | Tailwind | +|---|---|---| +| `--color-bg` | `background` | `bg-bg` | +| `--color-surface` | `sage-50` | `bg-surface` | +| `--color-surface-raised` | `white` | `bg-surface-raised` | +| `--color-border` | `sage-100` | `border-border` | +| `--color-border-strong` | `sage-200` | `border-border-strong` | +| `--color-fg` | `sage-950` | `text-fg` | +| `--color-fg-strong` | `sage-800` | `text-fg-strong` | +| `--color-fg-muted` | `sage-400` | `text-fg-muted` | +| `--color-fg-subtle` | `sage-300` | `text-fg-subtle` | +| `--color-fg-disabled` | `sage-200` | `text-fg-disabled` | +| `--color-fg-on-primary` | `white` | `text-fg-on-primary` | +| `--color-primary` | `sage-500` | `bg-primary`, `text-primary` | +| `--color-primary-hover` | `sage-600` | `bg-primary-hover` | +| `--color-primary-pressed` | `sage-700` | `bg-primary-pressed` | + +### 2.4 컬러 — Status + +상태 시각화. Sage 톤과 조화되는 muted jewel tones. + +| 카테고리 | -50 (연한 배경) | -500 (기본) | -700 (강조) | 단축 | +|---|---|---|---|---| +| Success | `#e8efe1` | `#5b7c47` | `#3f5731` | `bg-success`, `text-success` | +| Warning | `#f4e8d4` | `#b88840` | `#8a6529` | `bg-warning`, `text-warning` | +| Danger | `#f4e0d8` | `#a8503c` | `#803a2a` | `bg-danger`, `text-danger` | +| Info | `#dde4ea` | `#4d6878` | `#36475a` | `bg-info`, `text-info` | + +**조합 규칙**: +- 솔리드 뱃지: `bg-success text-white` +- 연한 뱃지: `bg-success-50 text-success-700` +- 인라인 텍스트 강조: `text-success-700` + +### 2.5 컬러 — Domain (Jobs / Interview Types) + +StackUp 도메인 한정 컬러. `features/*/ui` 의 도메인 뱃지에서만 사용 (`shared/ui` 에서 직접 참조 금지). + +#### 직군 -### 2.6 모션 +| 직군 | Hex | Tailwind | +|---|---|---| +| Frontend | `#5e8a98` (teal) | `bg-job-frontend` | +| Backend | `#7d6c93` (plum) | `bg-job-backend` | +| Infra | `#b06c70` (rose) | `bg-job-infra` | +| DBA | `#b89c5e` (gold) | `bg-job-dba` | + +#### 면접 유형 + +| 유형 | Hex | Tailwind | +|---|---|---| +| Personality | `#6f9978` (mint) | `bg-type-personality` | +| Technical | `#6c8294` (slate) | `bg-type-technical` | +| Live Coding | `#a87385` (mauve) | `bg-type-live-coding` | +| Integrated | `#8a7896` (violet)| `bg-type-integrated` | + +### 2.6 타이포그래피 — 폰트 패밀리 (3-Tier) + +| 토큰 | 폰트 | 용도 | Tailwind | +|---|---|---|---| +| `--font-heading` / `--font-display` | Fira Sans Extra Condensed | H1~H3 (Uppercase, 임팩트) | `font-heading`, `font-display` | +| `--font-subheading` | Geist | H4~H6, Caption (모던) | `font-subheading` | +| `--font-sans` / `--font-body` | Inter | 본문, 버튼 (가독성) | `font-sans`, `font-body` | +| `--font-mono` | Geist Mono | 코드 / 숫자 모노 | `font-mono` | + +폰트 로딩은 [`frontend/index.html`](../frontend/index.html) 의 `` (Google Fonts). + +### 2.7 타이포그래피 — 텍스트 스케일 + +각 토큰은 `font-size + line-height + letter-spacing + font-weight` 페어. 반응형은 `*-mobile` 변형. + +| Tailwind | Size / LH | Weight | 폰트 카테고리 | 용도 | +|---|---|---|---|---| +| `text-display` (`text-display-mobile`) | 100/1.0 (48/1.05) | 700 | Heading | 랜딩 / 페이지 타이틀 | +| `text-h2` (`text-h2-mobile`) | 56/1.05 (42/1.1) | 700 | Heading | 섹션 제목 | +| `text-h3` | 38/1.1 | 700 | Heading | 카드 / 블록 제목 | +| `text-h4` | 32/1.2 | 700 | Subheading | 큰 서브 헤딩 | +| `text-h5` | 24/1.3 | 700 | Subheading | 중간 서브 헤딩 | +| `text-h6` | 20/1.4 | 700 | Subheading | 작은 서브 헤딩 | +| `text-rich` | 20/1.5 | 500 | Subheading | 큰 본문 / 강조 | +| `text-body` | 16/1.2 | 500 | Sans | 기본 본문 | +| `text-button` | 14/1.2 | 600 | Sans | 버튼 / 작은 본문 | +| `text-caption` | 12/1.4 | 400 | Subheading | 캡션 / 메타 | + +> **반응형 헤딩 사용법**: `

` + +> **계층 가이드**: 한 페이지에 `text-display` 1개, `text-h2`/`text-h3` 는 섹션 단위. 카드 안 헤더는 `text-h5`/`text-h6` 우선. + +### 2.8 간격 (Spacing — 4px Grid) + +Tailwind v4 기본 `--spacing: 0.25rem` (= 4px) 사용. `p-4` = `16px`. + +| 토큰 | 값 | +|---|---| +| `space-1` | 4px | +| `space-2` | 8px | +| `space-3` | 12px | +| `space-4` | 16px | +| `space-5` | 20px | +| `space-6` | 24px | +| `space-8` | 32px | +| `space-10` | 40px | +| `space-12` | 48px | +| `space-16` | 64px | + +권장 — 위 10개 값 우선. 임의 값(`p-[13px]`)은 디자인 의도가 분명할 때만. + +### 2.9 라디우스 + +| 토큰 | 값 | Tailwind | 용도 | +|---|---|---|---| +| `--radius-sm` | 4px | `rounded-sm` | 뱃지 | +| `--radius-md` | 8px | `rounded-md` | 버튼, 인풋 | +| `--radius-lg` | 12px | `rounded-lg` | 카드 | +| `--radius-xl` | 16px | `rounded-xl` | 모달 | +| `--radius-2xl` | 24px | `rounded-2xl` | 큰 모달 / Hero | +| `--radius-pill` | 9999px | `rounded-pill` | 칩, 아바타, 토글 | + +### 2.10 그림자 / Elevation (Sage-tint) + +| 토큰 | Tailwind | 값 | +|---|---|---| +| `--shadow-sm` | `shadow-sm` | `0 1px 2px rgba(31,39,27,.06)` | +| `--shadow-md` | `shadow-md` | `0 4px 6px -1px rgba(31,39,27,.10), 0 2px 4px -2px rgba(31,39,27,.06)` | +| `--shadow-lg` | `shadow-lg` | `0 10px 15px -3px rgba(31,39,27,.10), 0 4px 6px -4px rgba(31,39,27,.05)` | +| `--shadow-focus-ring` | `shadow-focus-ring` | `0 0 0 3px rgba(98,110,92,.45)` | + +### 2.11 모션 + +| 토큰 | 값 | Tailwind | 용도 | +|---|---|---|---| +| `--duration-fast` | 120ms | `duration-fast` | hover, focus | +| `--duration-normal` | 200ms | `duration-normal` | 기본 transition | +| `--duration-slow` | 320ms | `duration-slow` | 모달 enter / exit | +| `--ease-standard` | `cubic-bezier(0.4,0,0.2,1)` | `ease-standard` | 기본 | +| `--ease-decelerate` | `cubic-bezier(0,0,0.2,1)` | `ease-decelerate` | enter | +| `--ease-accelerate` | `cubic-bezier(0.4,0,1,1)` | `ease-accelerate` | exit | + +> `prefers-reduced-motion: reduce` 사용자에게는 모든 transition / animation 0.01ms 강제 (`global.css` 에서 처리). + +### 2.12 Z-Index + +레이어 충돌 방지용 정수 스케일. Tailwind 자동 utility 미생성 (`--z-*` 는 namespace 가 아님) → CSS var 직접 사용. | 토큰 | 값 | 용도 | -|------|----|------| -| `--duration-fast` | 120ms | hover, focus | -| `--duration-normal` | 200ms | 기본 transition | -| `--duration-slow` | 320ms | 모달 enter/exit | -| `--easing-standard` | `cubic-bezier(0.4, 0, 0.2, 1)` | 기본 | -| `--easing-decelerate` | `cubic-bezier(0, 0, 0.2, 1)` | enter | -| `--easing-accelerate` | `cubic-bezier(0.4, 0, 1, 1)` | exit | +|---|---|---| +| `--z-base` | 0 | 기본 | +| `--z-raised` | 10 | 카드 hover, 떠오름 | +| `--z-dropdown` | 1000 | Select, Menu | +| `--z-sticky` | 1100 | Sticky TopNav | +| `--z-modal-backdrop` | 1200 | 모달 dim | +| `--z-modal` | 1300 | 모달 본체 | +| `--z-popover` | 1400 | Popover | +| `--z-tooltip` | 1500 | Tooltip | +| `--z-toast` | 1600 | Toast (가장 위) | + +```tsx +
...
+// or +
...
+``` + +### 2.13 컨테이너 / Breakpoint + +| 토큰 | 값 | Tailwind | 용도 | +|---|---|---|---| +| `--container-readable` | 65ch | `max-w-readable` | 본문 / 프로즈 카드 | +| `--container-content` | 1280px | `max-w-content` | TopNav 안쪽 메인 영역 | +| `--container-app` | 1440px | `max-w-app` | 앱 전체 최대 폭 | -> `prefers-reduced-motion` 사용자에게는 모든 transition을 0ms로 강제. +**Breakpoints** — Tailwind v4 default 사용: +- `sm: 640px`, `md: 768px`, `lg: 1024px`, `xl: 1280px`, `2xl: 1536px`. -### 2.7 Breakpoint +### 2.14 아이콘 -| 토큰 | min-width | 용도 | -|------|-----------|------| -| `--bp-sm` | 640px | 모바일 가로 | -| `--bp-md` | 768px | 태블릿 | -| `--bp-lg` | 1024px | 데스크탑 | -| `--bp-xl` | 1280px | 대형 모니터 | +- 라이브러리 — **Lucide Icons** (`lucide-react` 도입 예정, 트리쉐이킹 지원). +- 크기 — `16 / 20 / 24 px` (line-height 와 정렬). +- 색상 — `currentColor` (텍스트 색 상속). Tailwind: `text-fg`, `text-fg-muted` 적용. +- 의미 있는 아이콘은 `aria-label` 필수, 장식용은 `aria-hidden="true"`. --- ## 3. 컴포넌트 인벤토리 -> 위치: `frontend/src/shared/ui/{ComponentName}/`. 각 컴포넌트는 `index.ts`로 export. +위치: [`frontend/src/shared/ui/{Name}/`](../frontend/src/shared/ui/) (도메인 비종속) 또는 [`frontend/src/features/*/ui/`](../frontend/src/features/) (도메인 종속). 각 컴포넌트는 `index.ts` 로 public API. ### Foundation -- `Button` — variant: `primary | secondary | ghost | danger`, size: `sm | md | lg`, state: `loading | disabled` -- `IconButton` — 아이콘 전용, aria-label 필수 -- `Link` — 인라인/블록, 외부 링크는 자동으로 `target="_blank" rel="noopener"` -- `Input` — text/email/url, prefix·suffix slot, 에러 메시지 슬롯 -- `Textarea` — auto-grow 옵션 -- `Select` — 단일 선택 (네이티브 select 래핑) -- `Combobox` — 검색·자동완성 (Repository 등록 등) -- `Checkbox`, `Radio`, `Switch` -- `FileUploader` — Drag & Drop, 진행률, 파일 검증 메시지 +- `Button` — variant: `primary | secondary | ghost | danger` · size: `sm | md | lg` · state: `loading | disabled`. +- `IconButton` — 아이콘 전용, `aria-label` 필수. +- `Link` — 인라인 / 블록, 외부 링크는 자동 `target="_blank" rel="noopener"`. +- `Input`, `Textarea`, `Select`, `Combobox` (검색·자동완성). +- `Checkbox`, `Radio`, `Switch`. +- `FileUploader` — Drag & Drop, 진행률, 파일 검증 메시지. ### Display -- `Badge` — 상태 표시 (READY/IN_PROGRESS/COMPLETED/FAILED 색상 매핑) -- `Tag` / `Chip` — 기술 스택 등 다중 라벨 -- `Avatar` — GitHub avatar -- `Progress` — 선형 / 원형 -- `Skeleton` — 로딩 placeholder -- `EmptyState` — 빈 목록 안내 (아이콘 + 설명 + CTA) -- `Card` — `header / body / footer` slot +- `Badge` — 상태 매핑 (§4 참조). +- `Tag` / `Chip` — 기술 스택 등 다중 라벨. +- `Avatar` — GitHub avatar. +- `Progress` — 선형 / 원형. +- `Skeleton` — 로딩 placeholder (4-state §5). +- `EmptyState` — 빈 목록 안내, 아이콘 + 설명 + CTA (4-state §5). +- `Card` — `header / body / footer` slot. ### Feedback -- `Toast` — 4종 (success/info/warning/error), 우상단 stack, 4초 자동 dismiss -- `Modal` — `sm / md / lg / fullscreen`, focus trap 필수 -- `Drawer` — 우측 슬라이드, 세션 설정 등 -- `Popover`, `Tooltip` — 키보드 접근 가능 -- `ConfirmDialog` — 파괴적 액션 (삭제, 회원 탈퇴) 전용 -- `Alert` — 페이지 내 inline 경고 +- `Toast` — 4종 (success / info / warning / error), 우상단 stack, 4초 자동 dismiss, `z-toast`. +- `Modal` — `sm / md / lg / fullscreen`, focus trap 필수, `z-modal` + `z-modal-backdrop`. +- `Drawer` — 우측 슬라이드, 세션 설정 등. +- `Popover`, `Tooltip` — 키보드 접근 가능. +- `ConfirmDialog` — 파괴적 액션(삭제, 회원 탈퇴) 전용. +- `Alert` — 페이지 inline 경고. ### Navigation -- `TopNav` — 로고, 글로벌 액션, Avatar 드롭다운 -- `SideNav` — Workspace 좌측 메뉴 -- `Tabs` — `Underline | Pills` -- `Breadcrumb` -- `Pagination` +- `TopNav` — 로고, 글로벌 액션, Avatar 드롭다운, 64px height, sticky, `z-sticky`. +- `SideNav` — Workspace 좌측 메뉴, 240px width. +- `Tabs` — `Underline | Pills`. +- `Breadcrumb`, `Pagination`. + +### Domain-aware (`features/*/ui` 에 위치) +- `JobCategoryBadge` — 직군별 컬러 (§2.5). +- `InterviewTypeBadge` — 면접 유형별 컬러 (§2.5). +- `SessionStatusBadge` — 상태 매핑 (§4). +- `AnalysisStateIndicator` — `QUEUED / PROCESSING / COMPLETED / FAILED` 4단계 progress. + +> **shared vs features 구분** — 도메인 의존이 있으면 `features`, 디자인 토큰만 참조하면 `shared`. + +--- + +## 4. 상태 ↔ 컬러 매핑 -### Domain-aware (`shared/ui` 가 아닌 `features/*/ui`로 위치) -- `JobCategoryBadge` — 직군별 컬러 -- `InterviewTypeBadge` — 면접 유형별 컬러 -- `SessionStatusBadge` -- `AnalysisStateIndicator` — QUEUED/PROCESSING/COMPLETED/FAILED 4단계 progress +| 도메인 상태 | 시각 컬러 | 토큰 | 컴포넌트 예 | +|---|---|---|---| +| `READY` / `PENDING` / `QUEUED` | neutral | `text-fg-muted` (sage-400) | 회색 Badge | +| `IN_PROGRESS` / `PROCESSING` / `ANALYZING` | warning | `bg-warning-50 text-warning-700` | 노란 Badge + spinner | +| `COMPLETED` / `ANALYZED` / `ACTIVE` | success | `bg-success-50 text-success-700` | 초록 Badge | +| `INTERRUPTED` | warning | `bg-warning-50 text-warning-700` | 노란 Badge (느낌표 아이콘) | +| `FAILED` | danger | `bg-danger-50 text-danger-700` | 빨간 Badge + 재시도 | +| `CANCELLED` / `ARCHIVED` | disabled | `text-fg-disabled` | 회색 strikethrough | -> **shared vs features 구분**: 도메인 의존성이 있으면 features로. 디자인 토큰만 사용하면 shared로. +**구현 컨벤션** — `frontend/src/shared/ui/StatusBadge` 단일 컴포넌트가 모든 매핑을 캡슐화. 새 상태 추가 시 이 컴포넌트만 수정. --- -## 4. 상태별 색상 매핑 (Status Color Map) +## 5. 4-State UI 패턴 (비동기) + +서버 데이터의 4가지 상태별 UI 분기. [`frontend/src/shared/lib/AsyncBoundary`](../frontend/src/shared/lib/AsyncBoundary/) 로 일원화. -| 상태 | 색상 | 컴포넌트 예시 | -|------|------|----------------| -| `READY` / `PENDING` / `QUEUED` | neutral / `text-secondary` | 회색 Badge | -| `IN_PROGRESS` / `PROCESSING` / `ANALYZING` | warning | 노란 Badge + spinner | -| `COMPLETED` / `ANALYZED` / `ACTIVE` | success | 초록 Badge | -| `INTERRUPTED` | warning | 노란 Badge (느낌표) | -| `FAILED` | danger | 빨간 Badge + 재시도 버튼 | -| `CANCELLED` / `ARCHIVED` | text-disabled | 회색 strikethrough | +| 상태 | UI | 컴포넌트 | +|---|---|---| +| **Loading (Pending)** | shape-mimicking placeholder | `Skeleton` | +| **Empty** | 아이콘 + 설명 + CTA | `EmptyState` | +| **Error (Rejected)** | 메시지 + 재시도 버튼 | `Alert` (또는 `rejectedFallback`) | +| **Success** | 정상 컨텐츠 | (각 페이지 자체) | + +```tsx +} + rejectedFallback={({ error, reset }) => ( + + )} +> + + +``` -**구현 컨벤션**: `frontend/src/shared/ui/StatusBadge` 단일 컴포넌트가 모든 매핑을 캡슐화. 새 상태 추가 시 이 컴포넌트만 수정. +상세: [`frontend/src/shared/CLAUDE.md`](../frontend/src/shared/CLAUDE.md), [`docs/ui-patterns.md`](./ui-patterns.md). --- -## 5. 페이지 레이아웃 그리드 +## 6. 페이지 레이아웃 ``` ┌────────────────────────────────────────────────────┐ -│ TopNav (height: 64px, sticky) │ +│ TopNav (height: 64px, sticky, z-sticky) │ ├──────────┬─────────────────────────────────────────┤ │ │ │ -│ SideNav │ Main Content │ -│ (240px) │ - max-width: 1280px │ -│ │ - padding: 24px (mobile: 16px) │ +│ SideNav │ Main Content │ +│ (240px) │ - max-w-content (1280px) │ +│ │ - px-6 lg:px-12, py-12 │ │ │ │ └──────────┴─────────────────────────────────────────┘ ``` 특수 페이지: -- **Login**: SideNav 없음, 중앙 정렬 카드 -- **Interview**: SideNav 숨김(focus mode), 좌측 면접관, 우측 답변 영역, 하단 컨트롤 -- **Feedback Report**: TopNav만, 인쇄 친화적 레이아웃 (`@media print`) +- **Login** — SideNav 없음, 중앙 정렬 카드. +- **Interview** — focus mode (TopNav 숨김 옵션), 좌측 면접관, 우측 답변 영역, 하단 컨트롤. +- **Feedback Report** — TopNav 만, 인쇄 친화 (`@media print`). --- -## 6. 다크 모드 +## 7. 다크 모드 (Phase 2 예정) -- `` 로 토글 -- `prefers-color-scheme` 자동 감지 + 사용자 명시 선택 우선 -- 토큰만 갱신 (컴포넌트 코드 수정 불필요) -- 면접 진행 중에는 다크 모드 강제 (눈 피로 감소) — Phase 2에서 검토 +- `` 토글. +- `prefers-color-scheme` 자동 감지 + 사용자 명시 선택 우선. +- **토큰만 갱신** (컴포넌트 코드 수정 X) — `[data-theme='dark'] { --color-bg: ...; ... }`. +- 면접 진행 중 다크 모드 강제 옵션 검토 (눈 피로 감소). + +다크 토큰 정의는 Phase 2 진입 시 [`frontend/src/app/styles/tokens.css`](../frontend/src/app/styles/tokens.css) 에 추가. --- -## 7. 아이콘 +## 8. 토큰 → Tailwind 매핑 빠른 참조 + +| CSS Variable | Tailwind Utility | +|---|---| +| `--color-sage-{50..950}` | `bg-sage-{n}`, `text-sage-{n}`, `border-sage-{n}` | +| `--color-primary` / `--color-fg` / `--color-bg` | `bg-primary` / `text-fg` / `bg-bg` | +| `--color-success` / `--color-success-50` / `--color-success-700` | `bg-success` / `bg-success-50` / `text-success-700` | +| `--color-job-frontend` | `bg-job-frontend`, `text-job-frontend` | +| `--font-heading` / `--font-sans` / `--font-mono` | `font-heading` / `font-sans` / `font-mono` | +| `--text-display` (+ paired LH / letter / weight) | `text-display` | +| `--radius-md` / `--radius-pill` | `rounded-md` / `rounded-pill` | +| `--shadow-md` / `--shadow-focus-ring` | `shadow-md` / `shadow-focus-ring` | +| `--duration-normal` / `--ease-standard` | `duration-normal` / `ease-standard` | +| `--container-content` / `--container-readable` | `max-w-content` / `max-w-readable` | +| `--z-modal` / `--z-toast` | `z-[var(--z-modal)]` (namespace 미생성) | -- 라이브러리: **Lucide Icons** (트리쉐이킹 지원) -- 크기: 16/20/24px (line height와 정렬) -- 색상: `currentColor` (텍스트 색상 상속) -- 의미 있는 아이콘은 `aria-label` 필수, 장식용은 `aria-hidden="true"` +**원칙** — 컴포넌트는 utility class 만 사용. `bg-[#626e5c]` 같은 직접 hex 사용 금지. --- -## 8. 토큰 → 코드 매핑 - -`frontend/src/app/styles/tokens.css`: -```css -:root { - /* 위 표의 모든 토큰을 CSS 변수로 정의 */ - --color-brand-primary: #2563EB; - --space-4: 16px; - /* ... */ -} - -[data-theme='dark'] { - --color-brand-primary: #3B82F6; - /* ... 다크모드 오버라이드 */ -} -``` +## 9. 접근성 체크리스트 (모든 컴포넌트) -컴포넌트는 토큰만 참조: -```tsx -// 좋음 - - - -
- -
- -
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- - ) +export default function App() { + return } - -export default App diff --git a/frontend/src/app/styles/global.css b/frontend/src/app/styles/global.css new file mode 100644 index 00000000..8d56d56d --- /dev/null +++ b/frontend/src/app/styles/global.css @@ -0,0 +1,60 @@ +/** + * Global base styles. + * - html/body 기본 + * - 접근성 기본 (focus-visible, prefers-reduced-motion) + * + * Google Fonts (Fira Sans Extra Condensed, Geist, Geist Mono, Inter) 는 + * `index.html` 의 `` 로 로드 (성능상 CSS @import url 보다 권장). + */ + +@layer base { + html { + font-family: var(--font-sans); + color: var(--color-fg); + background-color: var(--color-bg); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + font-synthesis: none; + } + + body { + margin: 0; + min-height: 100svh; + } + + h1, h2, h3 { + font-family: var(--font-heading); + color: var(--color-fg-strong); + text-transform: uppercase; + margin: 0; + } + + h4, h5, h6 { + font-family: var(--font-subheading); + color: var(--color-fg-strong); + margin: 0; + } + + p { + margin: 0; + } + + code, kbd, pre, samp { + font-family: var(--font-mono); + } + + :focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + } + + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + } +} diff --git a/frontend/src/app/styles/index.css b/frontend/src/app/styles/index.css new file mode 100644 index 00000000..1ae42601 --- /dev/null +++ b/frontend/src/app/styles/index.css @@ -0,0 +1,3 @@ +@import 'tailwindcss'; +@import './tokens.css'; +@import './global.css'; diff --git a/frontend/src/app/styles/index.ts b/frontend/src/app/styles/index.ts index e69de29b..6a9a4b13 100644 --- a/frontend/src/app/styles/index.ts +++ b/frontend/src/app/styles/index.ts @@ -0,0 +1 @@ +import './index.css'; diff --git a/frontend/src/app/styles/tokens.css b/frontend/src/app/styles/tokens.css new file mode 100644 index 00000000..549b97b5 --- /dev/null +++ b/frontend/src/app/styles/tokens.css @@ -0,0 +1,250 @@ +/** + * StackUp Design Tokens — Tailwind CSS v4 `@theme` + * + * 토큰 추가/변경 시 `docs/design-system.md` 도 함께 갱신. + * 컴포넌트는 토큰만 참조하고 색상·간격·타이포그래피 하드코딩 금지. + */ + +@theme { + /* ========================================================= + * 1. Color — Base + * ========================================================= */ + --color-white: #ffffff; + --color-black: #000000; + --color-background: #e9e8e7; + + /* ========================================================= + * 2. Color — Sage / Neutral Scale (Main Theme) + * bg-sage-500, text-sage-800, border-sage-200 ... + * ========================================================= */ + --color-sage-50: #e8e7e1; + --color-sage-100: #d4cfcb; + --color-sage-200: #c9ccc8; + --color-sage-300: #b4bdaf; + --color-sage-400: #a0a89d; + --color-sage-500: #626e5c; + --color-sage-600: #3e4739; + --color-sage-700: #2b3625; + --color-sage-800: #1f271b; + --color-sage-900: #181e15; + --color-sage-950: #141a11; + + /* ========================================================= + * 3. Color — Semantic Aliases + * bg-primary, text-fg, text-fg-muted, border-border ... + * ========================================================= */ + --color-bg: var(--color-background); + --color-surface: var(--color-sage-50); + --color-surface-raised: var(--color-white); + --color-border: var(--color-sage-100); + --color-border-strong: var(--color-sage-200); + + --color-fg: var(--color-sage-950); + --color-fg-strong: var(--color-sage-800); + --color-fg-muted: var(--color-sage-400); + --color-fg-subtle: var(--color-sage-300); + --color-fg-disabled: var(--color-sage-200); + --color-fg-on-primary: var(--color-white); + + --color-primary: var(--color-sage-500); + --color-primary-hover: var(--color-sage-600); + --color-primary-pressed: var(--color-sage-700); + + /* ========================================================= + * 4. Color — Status (Success / Warning / Danger / Info) + * Sage 톤과 조화되는 muted jewel tones. + * 스케일: -50 (연한 배경), -500 (기본/표면), -700 (강조/텍스트) + * 축약 별칭: bg-success / text-warning / border-danger ... + * ========================================================= */ + --color-success-50: #e8efe1; + --color-success-500: #5b7c47; + --color-success-700: #3f5731; + --color-success: var(--color-success-500); + + --color-warning-50: #f4e8d4; + --color-warning-500: #b88840; + --color-warning-700: #8a6529; + --color-warning: var(--color-warning-500); + + --color-danger-50: #f4e0d8; + --color-danger-500: #a8503c; + --color-danger-700: #803a2a; + --color-danger: var(--color-danger-500); + + --color-info-50: #dde4ea; + --color-info-500: #4d6878; + --color-info-700: #36475a; + --color-info: var(--color-info-500); + + /* ========================================================= + * 5. Color — Domain (Jobs / Interview Types) + * StackUp 도메인 한정. features/[domain]/ui 의 도메인 뱃지에서만 사용. + * Sage 모노크로매틱과 조화되도록 muted jewel tone 으로 통일. + * ========================================================= */ + --color-job-frontend: #5e8a98; /* teal */ + --color-job-backend: #7d6c93; /* plum */ + --color-job-infra: #b06c70; /* rose */ + --color-job-dba: #b89c5e; /* gold */ + + --color-type-personality: #6f9978; /* mint */ + --color-type-technical: #6c8294; /* slate */ + --color-type-live-coding: #a87385; /* mauve */ + --color-type-integrated: #8a7896; /* violet */ + + /* ========================================================= + * 6. Typography — Font Families + * font-heading, font-subheading, font-sans, font-mono + * ========================================================= */ + --font-heading: + 'Fira Sans Extra Condensed', 'Pretendard Variable', system-ui, sans-serif; + --font-display: + 'Fira Sans Extra Condensed', 'Pretendard Variable', system-ui, sans-serif; + --font-subheading: + 'Geist', 'Pretendard Variable', system-ui, sans-serif; + --font-sans: + 'Inter', 'Pretendard Variable', system-ui, sans-serif; + --font-body: + 'Inter', 'Pretendard Variable', system-ui, sans-serif; + --font-mono: + 'Geist Mono', ui-monospace, 'JetBrains Mono', Consolas, monospace; + + /* ========================================================= + * 7. Typography — Text Scale + * text-display / text-h2..h6 / text-rich / text-body / text-button / text-caption + * 각 토큰은 size + line-height + letter-spacing + font-weight 페어. + * + * 반응형: 모바일 (< lg) 에서는 *-mobile 변형을 사용. + * ex)

+ * ========================================================= */ + + /* Display (H1) — 100px 데스크탑 / 48px 모바일 */ + --text-display: 100px; + --text-display--line-height: 1; + --text-display--letter-spacing: -1px; + --text-display--font-weight: 700; + + --text-display-mobile: 48px; + --text-display-mobile--line-height: 1.05; + --text-display-mobile--letter-spacing: -1px; + --text-display-mobile--font-weight: 700; + + /* H2 (Section) */ + --text-h2: 56px; + --text-h2--line-height: 1.05; + --text-h2--letter-spacing: -0.2px; + --text-h2--font-weight: 700; + + --text-h2-mobile: 42px; + --text-h2-mobile--line-height: 1.1; + --text-h2-mobile--letter-spacing: -0.2px; + --text-h2-mobile--font-weight: 700; + + /* H3 (Card / Block) */ + --text-h3: 38px; + --text-h3--line-height: 1.1; + --text-h3--letter-spacing: -0.2px; + --text-h3--font-weight: 700; + + /* H4 */ + --text-h4: 32px; + --text-h4--line-height: 1.2; + --text-h4--letter-spacing: -0.8px; + --text-h4--font-weight: 700; + + /* H5 */ + --text-h5: 24px; + --text-h5--line-height: 1.3; + --text-h5--letter-spacing: -0.2px; + --text-h5--font-weight: 700; + + /* H6 */ + --text-h6: 20px; + --text-h6--line-height: 1.4; + --text-h6--letter-spacing: -0.1px; + --text-h6--font-weight: 700; + + /* Rich text (큰 본문 / 강조) */ + --text-rich: 20px; + --text-rich--line-height: 1.5; + --text-rich--font-weight: 500; + + /* Body 1 (기본 본문) */ + --text-body: 16px; + --text-body--line-height: 1.2; + --text-body--letter-spacing: -0.01em; + --text-body--font-weight: 500; + + /* Body 2 / Button (작은 본문 / 버튼) */ + --text-button: 14px; + --text-button--line-height: 1.2; + --text-button--font-weight: 600; + + /* Caption */ + --text-caption: 12px; + --text-caption--line-height: 1.4; + --text-caption--font-weight: 400; + + /* ========================================================= + * 8. Radius + * ========================================================= */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-2xl: 24px; + --radius-pill: 9999px; + + /* ========================================================= + * 9. Shadow / Elevation (sage-tint) + * shadow-sm, shadow-md, shadow-lg, shadow-focus-ring + * ========================================================= */ + --shadow-sm: 0 1px 2px rgba(31, 39, 27, 0.06); + --shadow-md: + 0 4px 6px -1px rgba(31, 39, 27, 0.10), + 0 2px 4px -2px rgba(31, 39, 27, 0.06); + --shadow-lg: + 0 10px 15px -3px rgba(31, 39, 27, 0.10), + 0 4px 6px -4px rgba(31, 39, 27, 0.05); + --shadow-focus-ring: 0 0 0 3px rgba(98, 110, 92, 0.45); + + /* ========================================================= + * 10. Motion + * duration-fast/normal/slow, ease-standard/decelerate/accelerate + * ========================================================= */ + --ease-standard: cubic-bezier(0.4, 0, 0.2, 1); + --ease-decelerate: cubic-bezier(0, 0, 0.2, 1); + --ease-accelerate: cubic-bezier(0.4, 0, 1, 1); + + --duration-fast: 120ms; + --duration-normal: 200ms; + --duration-slow: 320ms; + + /* ========================================================= + * 11. Z-Index Scale + * `--z-*` 는 Tailwind 자동 namespace 가 아니므로 utility 미생성. + * 사용 — style={{ zIndex: 'var(--z-modal)' }} 또는 className="z-[var(--z-modal)]" + * ========================================================= */ + --z-base: 0; + --z-raised: 10; + --z-dropdown: 1000; + --z-sticky: 1100; + --z-modal-backdrop: 1200; + --z-modal: 1300; + --z-popover: 1400; + --z-tooltip: 1500; + --z-toast: 1600; + + /* ========================================================= + * 12. Container max-widths (Tailwind v4 max-w-*) + * max-w-readable, max-w-content, max-w-app + * ========================================================= */ + --container-readable: 65ch; /* 본문 / 프로즈 카드 */ + --container-content: 1280px; /* TopNav 안쪽 메인 영역 */ + --container-app: 1440px; /* 앱 전체 최대 폭 */ + + /* ========================================================= + * 13. Breakpoints + * Tailwind v4 default 사용 — sm 640 / md 768 / lg 1024 / xl 1280 / 2xl 1536. + * 재정의 필요 시 --breakpoint-* 로 오버라이드. + * ========================================================= */ +} diff --git a/frontend/src/index.css b/frontend/src/index.css deleted file mode 100644 index 5fb33130..00000000 --- a/frontend/src/index.css +++ /dev/null @@ -1,111 +0,0 @@ -:root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } -} - -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; - box-sizing: border-box; -} - -body { - margin: 0; -} - -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); -} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202a..037249fa 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import './index.css' +import '@/app/styles' import App from './App.tsx' createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/pages/DesignSystem/DesignSystemDemo.tsx b/frontend/src/pages/DesignSystem/DesignSystemDemo.tsx new file mode 100644 index 00000000..ea90c99f --- /dev/null +++ b/frontend/src/pages/DesignSystem/DesignSystemDemo.tsx @@ -0,0 +1,122 @@ +import { useState } from 'react' +import { NAV, type SectionId } from './nav' +import { Code } from './primitives' +import { + ColorsSection, + ContainerSection, + MotionSection, + OverviewSection, + RadiusSection, + ShadowSection, + SpacingSection, + TypographySection, + ZIndexSection, +} from './sections' + +function Sidebar({ + active, + onSelect, +}: { + active: SectionId + onSelect: (id: SectionId) => void +}) { + return ( + + ) +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ) +} + +function Hero() { + return ( +
+
+ frontend / app / styles / tokens.css +
+

+ Design System +

+

+ StackUp 프론트엔드의 시각·행동 규약. tokens.css 의{' '} + @theme 블록을 SSOT 로 한 Tailwind v4 토큰 시스템. +

+ +
+ + + + +
+
+ ) +} + +export default function DesignSystemDemo() { + const [active, setActive] = useState('overview') + + return ( +
+
+ +
+
+ + + + + + + + + + +
+
+
+
+ ) +} diff --git a/frontend/src/pages/DesignSystem/index.ts b/frontend/src/pages/DesignSystem/index.ts new file mode 100644 index 00000000..d7fa05b3 --- /dev/null +++ b/frontend/src/pages/DesignSystem/index.ts @@ -0,0 +1 @@ +export { default } from './DesignSystemDemo' diff --git a/frontend/src/pages/DesignSystem/lib.ts b/frontend/src/pages/DesignSystem/lib.ts new file mode 100644 index 00000000..868c8698 --- /dev/null +++ b/frontend/src/pages/DesignSystem/lib.ts @@ -0,0 +1,13 @@ +export function luminance(hex: string): number { + const h = hex.replace('#', '') + const r = parseInt(h.substring(0, 2), 16) / 255 + const g = parseInt(h.substring(2, 4), 16) / 255 + const b = parseInt(h.substring(4, 6), 16) / 255 + const lin = (v: number) => + v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4) + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b) +} + +export function readableFg(hex: string): string { + return luminance(hex) > 0.45 ? '#1f271b' : '#ffffff' +} diff --git a/frontend/src/pages/DesignSystem/nav.ts b/frontend/src/pages/DesignSystem/nav.ts new file mode 100644 index 00000000..228cc570 --- /dev/null +++ b/frontend/src/pages/DesignSystem/nav.ts @@ -0,0 +1,22 @@ +export type SectionId = + | 'overview' + | 'colors' + | 'typography' + | 'radius' + | 'shadow' + | 'spacing' + | 'motion' + | 'z-index' + | 'container' + +export const NAV: { id: SectionId; label: string }[] = [ + { id: 'overview', label: 'Overview' }, + { id: 'colors', label: 'Colors' }, + { id: 'typography', label: 'Typography' }, + { id: 'radius', label: 'Radius' }, + { id: 'shadow', label: 'Shadow' }, + { id: 'spacing', label: 'Spacing' }, + { id: 'motion', label: 'Motion' }, + { id: 'z-index', label: 'Z-Index' }, + { id: 'container', label: 'Container' }, +] diff --git a/frontend/src/pages/DesignSystem/primitives.tsx b/frontend/src/pages/DesignSystem/primitives.tsx new file mode 100644 index 00000000..d5e3008a --- /dev/null +++ b/frontend/src/pages/DesignSystem/primitives.tsx @@ -0,0 +1,116 @@ +import { type ReactNode } from 'react' +import type { SectionId } from './nav' +import { readableFg } from './lib' + +/** + * 데모 페이지 전용 UI 프리미티브. + * - Section / Sub : 섹션 / 하위 섹션 헤딩 + * - Swatch : 컬러 스와치 카드 + * - Code : 인라인 코드 / 토큰 배지 + * - TableHead : 공통 테이블 헤더 + * + * 모두 디자인 시스템 토큰만 사용 — 하드코딩된 색상 없음. + */ + +export function Section({ + id, + label, + title, + description, + children, +}: { + id: SectionId + label: string + title: string + description?: string + children: ReactNode +}) { + return ( +
+
+
+ {label} +
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+ {children} +
+ ) +} + +export function Sub({ + title, + action, + children, +}: { + title: string + action?: ReactNode + children: ReactNode +}) { + return ( +
+
+

+ {title} +

+ {action} +
+ {children} +
+ ) +} + +export function Swatch({ + token, + hex, + sub, +}: { + token: string + hex: string + sub?: string +}) { + const fg = readableFg(hex) + return ( +
+
+ {hex.toUpperCase()} +
+
+
{token}
+ {sub && ( +
{sub}
+ )} +
+
+ ) +} + +export function Code({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +export function TableHead({ cols }: { cols: string[] }) { + return ( + + + {cols.map((c) => ( + + {c} + + ))} + + + ) +} diff --git a/frontend/src/pages/DesignSystem/sections/Colors.tsx b/frontend/src/pages/DesignSystem/sections/Colors.tsx new file mode 100644 index 00000000..d9399766 --- /dev/null +++ b/frontend/src/pages/DesignSystem/sections/Colors.tsx @@ -0,0 +1,260 @@ +import { Code, Section, Sub, Swatch, TableHead } from '../primitives' +import { readableFg } from '../lib' + +const SAGE_SCALE = [ + { token: 'sage-50', hex: '#e8e7e1' }, + { token: 'sage-100', hex: '#d4cfcb' }, + { token: 'sage-200', hex: '#c9ccc8' }, + { token: 'sage-300', hex: '#b4bdaf' }, + { token: 'sage-400', hex: '#a0a89d' }, + { token: 'sage-500', hex: '#626e5c' }, + { token: 'sage-600', hex: '#3e4739' }, + { token: 'sage-700', hex: '#2b3625' }, + { token: 'sage-800', hex: '#1f271b' }, + { token: 'sage-900', hex: '#181e15' }, + { token: 'sage-950', hex: '#141a11' }, +] + +const SEMANTIC_ROWS = [ + { token: 'bg', alias: 'background', hex: '#e9e8e7', usage: '앱 기본 배경' }, + { token: 'surface', alias: 'sage-50', hex: '#e8e7e1', usage: '컴포넌트 배경' }, + { token: 'surface-raised', alias: 'white', hex: '#ffffff', usage: '카드 · 모달' }, + { token: 'border', alias: 'sage-100', hex: '#d4cfcb', usage: '기본 보더' }, + { token: 'border-strong', alias: 'sage-200', hex: '#c9ccc8', usage: '강조 보더' }, + { token: 'fg', alias: 'sage-950', hex: '#141a11', usage: '기본 텍스트' }, + { token: 'fg-strong', alias: 'sage-800', hex: '#1f271b', usage: '헤딩' }, + { token: 'fg-muted', alias: 'sage-400', hex: '#a0a89d', usage: '보조 텍스트' }, + { token: 'fg-subtle', alias: 'sage-300', hex: '#b4bdaf', usage: '흐린 텍스트' }, + { token: 'fg-disabled', alias: 'sage-200', hex: '#c9ccc8', usage: '비활성' }, + { token: 'primary', alias: 'sage-500', hex: '#626e5c', usage: 'Primary 액션' }, + { token: 'primary-hover', alias: 'sage-600', hex: '#3e4739', usage: 'hover' }, + { token: 'primary-pressed', alias: 'sage-700', hex: '#2b3625', usage: 'active' }, +] + +const STATUS_COLORS = [ + { + key: 'success', + name: 'Success', + example: 'COMPLETED · ANALYZED', + shades: [ + { label: '-50', hex: '#e8efe1' }, + { label: '-500', hex: '#5b7c47' }, + { label: '-700', hex: '#3f5731' }, + ], + bgLight: '#e8efe1', + textLight: '#3f5731', + bgSolid: '#5b7c47', + }, + { + key: 'warning', + name: 'Warning', + example: 'IN_PROGRESS · ANALYZING', + shades: [ + { label: '-50', hex: '#f4e8d4' }, + { label: '-500', hex: '#b88840' }, + { label: '-700', hex: '#8a6529' }, + ], + bgLight: '#f4e8d4', + textLight: '#8a6529', + bgSolid: '#b88840', + }, + { + key: 'danger', + name: 'Danger', + example: 'FAILED · 삭제', + shades: [ + { label: '-50', hex: '#f4e0d8' }, + { label: '-500', hex: '#a8503c' }, + { label: '-700', hex: '#803a2a' }, + ], + bgLight: '#f4e0d8', + textLight: '#803a2a', + bgSolid: '#a8503c', + }, + { + key: 'info', + name: 'Info', + example: '정보 토스트 · 안내', + shades: [ + { label: '-50', hex: '#dde4ea' }, + { label: '-500', hex: '#4d6878' }, + { label: '-700', hex: '#36475a' }, + ], + bgLight: '#dde4ea', + textLight: '#36475a', + bgSolid: '#4d6878', + }, +] + +const DOMAIN_JOBS = [ + { token: 'job-frontend', hex: '#5e8a98', name: 'Frontend', tone: 'teal' }, + { token: 'job-backend', hex: '#7d6c93', name: 'Backend', tone: 'plum' }, + { token: 'job-infra', hex: '#b06c70', name: 'Infra', tone: 'rose' }, + { token: 'job-dba', hex: '#b89c5e', name: 'DBA', tone: 'gold' }, +] + +const DOMAIN_TYPES = [ + { token: 'type-personality', hex: '#6f9978', name: 'Personality', tone: 'mint' }, + { token: 'type-technical', hex: '#6c8294', name: 'Technical', tone: 'slate' }, + { token: 'type-live-coding', hex: '#a87385', name: 'Live Coding', tone: 'mauve' }, + { token: 'type-integrated', hex: '#8a7896', name: 'Integrated', tone: 'violet' }, +] + +function DomainCard({ + token, + hex, + name, + tone, +}: { + token: string + hex: string + name: string + tone: string +}) { + return ( +
+
+ {hex.toUpperCase()} +
+
+
+ {name} +
+
+ {token} · {tone} +
+
+
+ ) +} + +export function ColorsSection() { + return ( +
+ +
+ {SAGE_SCALE.map((c) => ( + + ))} +
+
+ + +
+ + + + {SEMANTIC_ROWS.map((row) => ( + + + + + + + ))} + +
+ {row.token} + + {row.alias} + +
+ + + {row.hex} + +
+
{row.usage}
+
+
+ + +
+ {STATUS_COLORS.map((s) => ( +
+
+ + {s.name} + + + {s.example} + +
+
+
+ {s.shades.map((sh) => ( + + ))} +
+
+ + Light Badge + + + Solid Badge + +
+
+ {`bg-${s.key}-50 text-${s.key}-700`} + {`bg-${s.key} text-white`} +
+
+
+ ))} +
+
+ + +
+
+
+ 직군 (Jobs) +
+
+ {DOMAIN_JOBS.map((d) => ( + + ))} +
+
+
+
+ 면접 유형 (Interview Types) +
+
+ {DOMAIN_TYPES.map((d) => ( + + ))} +
+
+
+
+
+ ) +} diff --git a/frontend/src/pages/DesignSystem/sections/Container.tsx b/frontend/src/pages/DesignSystem/sections/Container.tsx new file mode 100644 index 00000000..cbdd9aa1 --- /dev/null +++ b/frontend/src/pages/DesignSystem/sections/Container.tsx @@ -0,0 +1,89 @@ +import { Code, Section, Sub } from '../primitives' + +const CONTAINERS = [ + { + token: '--container-readable', + utility: 'max-w-readable', + value: '65ch', + pct: 45, + usage: '본문 · 프로즈 카드', + }, + { + token: '--container-content', + utility: 'max-w-content', + value: '1280px', + pct: 89, + usage: 'TopNav 안 메인 영역', + }, + { + token: '--container-app', + utility: 'max-w-app', + value: '1440px', + pct: 100, + usage: '앱 전체 최대 폭', + }, +] + +const BREAKPOINTS = [ + { label: 'sm', value: '640px' }, + { label: 'md', value: '768px' }, + { label: 'lg', value: '1024px' }, + { label: 'xl', value: '1280px' }, + { label: '2xl', value: '1536px' }, +] + +export function ContainerSection() { + return ( +
+ +
+ {CONTAINERS.map((c) => ( +
+
+
+ {c.utility} + + {c.value} + +
+ {c.usage} +
+
+
+
+
+ ))} +
+ + + +
+ {BREAKPOINTS.map((bp) => ( +
+
+ {bp.label} +
+
+ {bp.value} +
+
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/pages/DesignSystem/sections/Motion.tsx b/frontend/src/pages/DesignSystem/sections/Motion.tsx new file mode 100644 index 00000000..8d2cf8f3 --- /dev/null +++ b/frontend/src/pages/DesignSystem/sections/Motion.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react' +import { Code, Section } from '../primitives' + +const DURATIONS = [ + { token: 'duration-fast', value: '120ms', usage: 'hover · focus' }, + { token: 'duration-normal', value: '200ms', usage: '기본 transition' }, + { token: 'duration-slow', value: '320ms', usage: '모달 enter / exit' }, +] + +const EASINGS = [ + { token: 'ease-standard', curve: 'cubic-bezier(0.4, 0, 0.2, 1)', usage: '기본' }, + { token: 'ease-decelerate', curve: 'cubic-bezier(0, 0, 0.2, 1)', usage: 'enter' }, + { token: 'ease-accelerate', curve: 'cubic-bezier(0.4, 0, 1, 1)', usage: 'exit' }, +] + +function MotionDemo() { + const [hover, setHover] = useState(false) + return ( +
setHover(true)} + onMouseLeave={() => setHover(false)} + > +
+
+ hover → 200ms · ease-standard +
+
+ ) +} + +export function MotionSection() { + return ( +
+
+
+
+ Duration +
+
+ {DURATIONS.map((d) => ( +
+ {d.token} +
+ + {d.value} + + + {d.usage} + +
+
+ ))} +
+
+
+
+ Easing +
+
+ {EASINGS.map((e) => ( +
+ {e.token} +
+
+ {e.curve} +
+
{e.usage}
+
+
+ ))} +
+
+
+ +
+ ) +} diff --git a/frontend/src/pages/DesignSystem/sections/Overview.tsx b/frontend/src/pages/DesignSystem/sections/Overview.tsx new file mode 100644 index 00000000..e7564323 --- /dev/null +++ b/frontend/src/pages/DesignSystem/sections/Overview.tsx @@ -0,0 +1,38 @@ +import { Section } from '../primitives' + +const ROWS: [string, string][] = [ + ['Sage Scale', 'sage-50 … sage-950 (11단계)'], + ['Semantic', '14개 별칭 — fg / bg / surface / border / primary'], + ['Status', 'success · warning · danger · info × 3 shades'], + ['Domain', '직군 4 + 면접 유형 4 (muted jewel tone)'], + ['Typography', 'Fira / Geist / Inter 3-Tier · 10단계 텍스트 스케일'], + ['Radius', 'sm → 2xl + pill 6단계'], + ['Shadow', 'sm · md · lg · focus-ring (sage-tint)'], + ['Spacing', '4px grid — Tailwind 기본 scale'], + ['Motion', '120 / 200 / 320ms + 3 easing'], + ['Z-Index', '9단계 (0 → 1600)'], + ['Container', 'readable 65ch · content 1280px · app 1440px'], +] + +export function OverviewSection() { + return ( +
+
+ {ROWS.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+
+ ) +} diff --git a/frontend/src/pages/DesignSystem/sections/Radius.tsx b/frontend/src/pages/DesignSystem/sections/Radius.tsx new file mode 100644 index 00000000..efe78e94 --- /dev/null +++ b/frontend/src/pages/DesignSystem/sections/Radius.tsx @@ -0,0 +1,35 @@ +import { Code, Section } from '../primitives' + +const RADII = [ + { token: 'sm', value: '4px', cls: 'rounded-sm' }, + { token: 'md', value: '8px', cls: 'rounded-md' }, + { token: 'lg', value: '12px', cls: 'rounded-lg' }, + { token: 'xl', value: '16px', cls: 'rounded-xl' }, + { token: '2xl', value: '24px', cls: 'rounded-2xl' }, + { token: 'pill', value: '9999px', cls: 'rounded-pill' }, +] + +export function RadiusSection() { + return ( +
+
+ {RADII.map((r) => ( +
+
+
+ {r.cls} +
+
{r.value}
+
+ ))} +
+
+ ) +} diff --git a/frontend/src/pages/DesignSystem/sections/Shadow.tsx b/frontend/src/pages/DesignSystem/sections/Shadow.tsx new file mode 100644 index 00000000..af5c25d6 --- /dev/null +++ b/frontend/src/pages/DesignSystem/sections/Shadow.tsx @@ -0,0 +1,34 @@ +import { Code, Section } from '../primitives' + +const SHADOWS = [ + { cls: 'shadow-sm', label: 'shadow-sm', desc: '카드 기본' }, + { cls: 'shadow-md', label: 'shadow-md', desc: '드롭다운' }, + { cls: 'shadow-lg', label: 'shadow-lg', desc: '모달' }, + { cls: 'shadow-focus-ring', label: 'shadow-focus-ring', desc: 'Focus ring' }, +] + +export function ShadowSection() { + return ( +
+
+ {SHADOWS.map((s) => ( +
+
+
+ {s.label} +
{s.desc}
+
+
+ ))} +
+
+ ) +} diff --git a/frontend/src/pages/DesignSystem/sections/Spacing.tsx b/frontend/src/pages/DesignSystem/sections/Spacing.tsx new file mode 100644 index 00000000..6141e807 --- /dev/null +++ b/frontend/src/pages/DesignSystem/sections/Spacing.tsx @@ -0,0 +1,31 @@ +import { Section } from '../primitives' + +const SCALE = [1, 2, 3, 4, 5, 6, 8, 10, 12, 16] + +export function SpacingSection() { + return ( +
+
+ {SCALE.map((n) => ( +
+
+ space-{n} +
+
+
+ {n * 4}px +
+
+ ))} +
+
+ ) +} diff --git a/frontend/src/pages/DesignSystem/sections/Typography.tsx b/frontend/src/pages/DesignSystem/sections/Typography.tsx new file mode 100644 index 00000000..a922a348 --- /dev/null +++ b/frontend/src/pages/DesignSystem/sections/Typography.tsx @@ -0,0 +1,102 @@ +import { Code, Section, Sub } from '../primitives' + +const SAMPLE = 'StackUp · 빠른 면접, 깊은 피드백' + +const FONT_FAMILIES = [ + { + token: '--font-heading / display', + utility: 'font-heading', + name: 'Fira Sans Extra Condensed', + usage: 'H1 · H2 · H3 (Uppercase)', + cls: 'font-heading text-h4 uppercase font-bold', + }, + { + token: '--font-subheading', + utility: 'font-subheading', + name: 'Geist', + usage: 'H4 · H5 · H6 · Caption', + cls: 'font-subheading text-h4 font-bold', + }, + { + token: '--font-sans / body', + utility: 'font-sans', + name: 'Inter', + usage: '본문 · 버튼', + cls: 'font-sans text-h4', + }, + { + token: '--font-mono', + utility: 'font-mono', + name: 'Geist Mono', + usage: '코드 · 숫자', + cls: 'font-mono text-h5', + }, +] + +const TEXT_SCALE = [ + { token: 'text-display', cls: 'font-display text-display-mobile lg:text-display uppercase', size: '100 / 48', weight: 700, usage: '랜딩 타이틀' }, + { token: 'text-h2', cls: 'font-heading text-h2-mobile lg:text-h2 uppercase', size: '56 / 42', weight: 700, usage: '섹션 제목' }, + { token: 'text-h3', cls: 'font-heading text-h3 uppercase', size: '38', weight: 700, usage: '카드 제목' }, + { token: 'text-h4', cls: 'font-subheading text-h4', size: '32', weight: 700, usage: '서브헤딩 L' }, + { token: 'text-h5', cls: 'font-subheading text-h5', size: '24', weight: 700, usage: '서브헤딩 M' }, + { token: 'text-h6', cls: 'font-subheading text-h6', size: '20', weight: 700, usage: '서브헤딩 S' }, + { token: 'text-rich', cls: 'font-subheading text-rich', size: '20', weight: 500, usage: '큰 본문 / 강조' }, + { token: 'text-body', cls: 'font-sans text-body', size: '16', weight: 500, usage: '기본 본문' }, + { token: 'text-button', cls: 'font-sans text-button', size: '14', weight: 600, usage: '버튼 · 작은 본문' }, + { token: 'text-caption', cls: 'font-subheading text-caption', size: '12', weight: 400, usage: '캡션 · 메타' }, +] + +export function TypographySection() { + return ( +
+ +
+ {FONT_FAMILIES.map((f) => ( +
+
+ {f.name} +
+
+
+ {f.utility} +
+
{f.usage}
+
+
+ ))} +
+
+ + +
+ {TEXT_SCALE.map((t) => ( +
+
{SAMPLE}
+
+ {t.token} + + {t.size}px + + + w{t.weight} + + {t.usage} +
+
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/pages/DesignSystem/sections/ZIndex.tsx b/frontend/src/pages/DesignSystem/sections/ZIndex.tsx new file mode 100644 index 00000000..5b2218fc --- /dev/null +++ b/frontend/src/pages/DesignSystem/sections/ZIndex.tsx @@ -0,0 +1,74 @@ +import { Section, TableHead } from '../primitives' + +const Z_INDEX = [ + { token: '--z-toast', value: 1600, usage: 'Toast' }, + { token: '--z-tooltip', value: 1500, usage: 'Tooltip' }, + { token: '--z-popover', value: 1400, usage: 'Popover' }, + { token: '--z-modal', value: 1300, usage: '모달 본체' }, + { token: '--z-modal-backdrop', value: 1200, usage: '모달 dim' }, + { token: '--z-sticky', value: 1100, usage: 'Sticky TopNav' }, + { token: '--z-dropdown', value: 1000, usage: 'Select · Menu' }, + { token: '--z-raised', value: 10, usage: '카드 hover' }, + { token: '--z-base', value: 0, usage: '기본 레이어' }, +] + +export function ZIndexSection() { + return ( +
+
+
+ + + + {Z_INDEX.map((z) => ( + + + + + + ))} + +
+ {z.token} + + {z.value} + {z.usage}
+
+ +
+
+ Stacking Order +
+
+ {[...Z_INDEX].reverse().map((z, i) => { + const intensity = 0.07 + i * 0.085 + return ( +
0.5 ? '#ffffff' : '#1f271b', + }} + > + + {z.value} + + {z.usage} +
+ ) + })} +
+
+
+
+ ) +} diff --git a/frontend/src/pages/DesignSystem/sections/index.ts b/frontend/src/pages/DesignSystem/sections/index.ts new file mode 100644 index 00000000..779b4e36 --- /dev/null +++ b/frontend/src/pages/DesignSystem/sections/index.ts @@ -0,0 +1,9 @@ +export { OverviewSection } from './Overview' +export { ColorsSection } from './Colors' +export { TypographySection } from './Typography' +export { RadiusSection } from './Radius' +export { ShadowSection } from './Shadow' +export { SpacingSection } from './Spacing' +export { MotionSection } from './Motion' +export { ZIndexSection } from './ZIndex' +export { ContainerSection } from './Container' diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index af516fcc..b2a52c6b 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -22,7 +22,21 @@ "noUnusedParameters": true, "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noUncheckedSideEffectImports": true, + + /* Path mapping */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@/shared/*": ["src/shared/*"], + "@/entities/*": ["src/domain/*"], + "@/features/*": ["src/features/*"], + "@/pages/*": ["src/pages/*"], + "@/app/*": ["src/app/*"], + }, + + + }, "include": ["src"] } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 8b0f57b9..bb1d7b51 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,14 @@ import { defineConfig } from 'vite' +import path from 'node:path' import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [react(), tailwindcss()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, })