diff --git a/frontend/design-system.html b/frontend/design-system.html new file mode 100644 index 0000000..01e35ec --- /dev/null +++ b/frontend/design-system.html @@ -0,0 +1,12 @@ + + +
+ + +v0.1.0 · 쇼케이스
+ {id}
+ {description}
} +| intent ↓ / size → | + {SIZES.map((size) => ( ++ {size} + | + ))} +
|---|---|
| {intent} | + {SIZES.map((size) => ( +
+ |
+ ))}
+
+ {`import { Badge } from '@shared/components/ui/badge';
+import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
+
+// 카탈로그 API와 결합
+
+ {getAlertLevelLabel('CRITICAL', t, lang)}
+
+
+// className override (tailwind-merge가 같은 그룹 충돌 감지)
+
+ 커스텀 둥근 배지
+ `}
+
+
+ {`// ❌ className 직접 작성 (intent prop 무시)
+...
+
+// ❌ !important 사용
+...
+
+// ❌ → Badge 컴포넌트 사용 필수
+위험`}
+
+
+ >
+ );
+}
diff --git a/frontend/src/design-system/sections/ButtonSection.tsx b/frontend/src/design-system/sections/ButtonSection.tsx
new file mode 100644
index 0000000..21233bf
--- /dev/null
+++ b/frontend/src/design-system/sections/ButtonSection.tsx
@@ -0,0 +1,131 @@
+import { TrkSectionHeader, Trk } from '../lib/Trk';
+import { Button } from '@shared/components/ui/button';
+import type { ButtonVariant, ButtonSize } from '@lib/theme/variants';
+import { Plus, Download, Trash2, Search, Save } from 'lucide-react';
+
+const VARIANTS: ButtonVariant[] = ['primary', 'secondary', 'ghost', 'outline', 'destructive'];
+const SIZES: ButtonSize[] = ['sm', 'md', 'lg'];
+
+const VARIANT_USAGE: Record = {
+ primary: '주요 액션 · 기본 CTA (페이지당 1개 권장)',
+ secondary: '보조 액션 · 툴바 버튼 (기본값)',
+ ghost: '고요한 액션 · 리스트 행 내부',
+ outline: '강조 보조 · 필터 활성화 상태',
+ destructive: '삭제 · 비활성화 등 위험 액션',
+};
+
+export function ButtonSection() {
+ return (
+ <>
+
+
+ {/* 매트릭스 */}
+ 15 변형 매트릭스
+
+
+
+
+
+ variant ↓ / size →
+ {SIZES.map((size) => (
+
+ {size}
+
+ ))}
+
+
+
+ {VARIANTS.map((variant) => (
+
+ {variant}
+ {SIZES.map((size) => (
+
+
+
+
+
+ ))}
+
+ ))}
+
+
+
+
+
+ {/* 아이콘 버튼 */}
+ 아이콘 포함 패턴
+
+
+ }>
+ 새 항목 추가
+
+ }>
+ 다운로드
+
+ }>
+ 검색
+
+ }>
+ 저장
+
+ }>
+ 삭제
+
+
+
+
+ {/* 상태 */}
+ 상태
+
+
+
+
+
+
+
+ {/* variant 의미 */}
+ Variant 의미 가이드
+
+ {VARIANTS.map((variant) => (
+
+
+
+ {VARIANT_USAGE[variant]}
+
+
+ ))}
+
+
+ {/* 사용 예시 */}
+ 사용 예시
+
+
+ {`import { Button } from '@shared/components/ui/button';
+import { Plus } from 'lucide-react';
+
+}>
+ 새 보고서
+
+
+
+
+// 금지
+// ❌
+
+ >
+ );
+}
diff --git a/frontend/src/design-system/sections/CardSection.tsx b/frontend/src/design-system/sections/CardSection.tsx
new file mode 100644
index 0000000..c526482
--- /dev/null
+++ b/frontend/src/design-system/sections/CardSection.tsx
@@ -0,0 +1,111 @@
+import { TrkSectionHeader, Trk } from '../lib/Trk';
+import { Card, CardHeader, CardTitle, CardContent } from '@shared/components/ui/card';
+import { Database, Activity, Bell } from 'lucide-react';
+
+export function CardSectionShowcase() {
+ return (
+ <>
+
+
+ {/* 4 variant */}
+ Variant
+
+
+
+
+
+
+
+ 데이터베이스
+
+
+
+
+ PostgreSQL
+ v15.4 운영중
+
+
+ 연결
+ 8 / 20
+
+
+
+
+
+
+
+
+
+
+
+ 시스템 상태
+
+
+
+
+ API
+ 정상
+
+
+ Prediction
+ 5분 주기
+
+
+
+
+
+
+
+
+
+
+
+ 알림
+
+
+
+ 중첩 카드 내부에 사용. 외부 카드보다 한 단계 낮은 depth.
+
+
+
+
+
+
+
+
+ 투명 카드
+
+
+ 배경/보더 없이 구조만 활용 (그룹핑 목적).
+
+
+
+
+
+ {/* 사용 예시 */}
+ 사용 예시
+
+
+ {`import { Card, CardHeader, CardTitle, CardContent } from '@shared/components/ui/card';
+import { Database } from 'lucide-react';
+
+
+
+
+
+ 데이터베이스
+
+
+
+ {/* 콘텐츠 */}
+
+ `}
+
+
+ >
+ );
+}
diff --git a/frontend/src/design-system/sections/CatalogSection.tsx b/frontend/src/design-system/sections/CatalogSection.tsx
new file mode 100644
index 0000000..b055dfc
--- /dev/null
+++ b/frontend/src/design-system/sections/CatalogSection.tsx
@@ -0,0 +1,278 @@
+import { type ReactNode } from 'react';
+import { TrkSectionHeader, Trk } from '../lib/Trk';
+import { Badge } from '@shared/components/ui/badge';
+import type { BadgeIntent } from '@lib/theme/variants';
+
+import { ALERT_LEVELS } from '@shared/constants/alertLevels';
+import { VIOLATION_TYPES } from '@shared/constants/violationTypes';
+import { EVENT_STATUSES } from '@shared/constants/eventStatuses';
+import { ENFORCEMENT_ACTIONS } from '@shared/constants/enforcementActions';
+import { ENFORCEMENT_RESULTS } from '@shared/constants/enforcementResults';
+import { PATROL_STATUSES } from '@shared/constants/patrolStatuses';
+import { ENGINE_SEVERITIES } from '@shared/constants/engineSeverities';
+import { DEVICE_STATUSES } from '@shared/constants/deviceStatuses';
+import {
+ PARENT_RESOLUTION_STATUSES,
+ LABEL_SESSION_STATUSES,
+} from '@shared/constants/parentResolutionStatuses';
+import {
+ MODEL_STATUSES,
+ QUALITY_GATE_STATUSES,
+ EXPERIMENT_STATUSES,
+} from '@shared/constants/modelDeploymentStatuses';
+import { GEAR_GROUP_TYPES } from '@shared/constants/gearGroupTypes';
+import { DARK_VESSEL_PATTERNS } from '@shared/constants/darkVesselPatterns';
+import { USER_ACCOUNT_STATUSES } from '@shared/constants/userAccountStatuses';
+import { LOGIN_RESULTS } from '@shared/constants/loginResultStatuses';
+import { PERMIT_STATUSES, GEAR_JUDGMENTS } from '@shared/constants/permissionStatuses';
+import {
+ VESSEL_SURVEILLANCE_STATUSES,
+ VESSEL_RISK_RINGS,
+} from '@shared/constants/vesselAnalysisStatuses';
+import { CONNECTION_STATUSES } from '@shared/constants/connectionStatuses';
+import { TRAINING_ZONE_TYPES } from '@shared/constants/trainingZoneTypes';
+
+/** 카탈로그 메타 공통 속성 (일부만 있을 수 있음) */
+interface AnyMeta {
+ code: string;
+ intent?: BadgeIntent;
+ fallback?: { ko: string; en: string };
+ classes?: string | { bg?: string; text?: string; border?: string };
+ label?: string;
+}
+
+type AnyCatalog = Record;
+
+function getLabel(meta: AnyMeta): string {
+ return meta.fallback?.ko ?? meta.label ?? meta.code;
+}
+
+/** classes가 문자열인 경우 그대로, 객체인 경우 bg+text 조합 */
+function getFallbackClasses(meta: AnyMeta): string | undefined {
+ if (typeof meta.classes === 'string') return meta.classes;
+ if (typeof meta.classes === 'object' && meta.classes) {
+ return [meta.classes.bg, meta.classes.text, meta.classes.border].filter(Boolean).join(' ');
+ }
+ return undefined;
+}
+
+/** 카탈로그 항목을 Badge 또는 fallback span으로 렌더 */
+function CatalogBadges({ catalog, idPrefix }: { catalog: AnyCatalog; idPrefix: string }) {
+ const entries = Object.values(catalog);
+ return (
+
+ {entries.map((meta) => {
+ const label = getLabel(meta);
+ const trkId = `${idPrefix}-${meta.code}`;
+ const content: ReactNode = meta.intent ? (
+
+ {label}
+
+ ) : (
+
+ {label}
+
+ );
+ return (
+
+ {content}
+
+ );
+ })}
+
+ );
+}
+
+interface CatalogEntry {
+ id: string;
+ title: string;
+ description: string;
+ catalog: AnyCatalog;
+ source?: string;
+}
+
+const CATALOGS: CatalogEntry[] = [
+ {
+ id: 'TRK-CAT-alert-level',
+ title: '위험도 · AlertLevel',
+ description: 'CRITICAL / HIGH / MEDIUM / LOW — 모든 이벤트/알림 배지',
+ catalog: ALERT_LEVELS as unknown as AnyCatalog,
+ source: 'backend code_master EVENT_LEVEL',
+ },
+ {
+ id: 'TRK-CAT-violation-type',
+ title: '위반 유형 · ViolationType',
+ description: '중국불법조업 / 환적의심 / EEZ침범 등',
+ catalog: VIOLATION_TYPES as unknown as AnyCatalog,
+ source: 'backend ViolationType enum',
+ },
+ {
+ id: 'TRK-CAT-event-status',
+ title: '이벤트 상태 · EventStatus',
+ description: 'NEW / ACK / IN_PROGRESS / RESOLVED / FALSE_POSITIVE',
+ catalog: EVENT_STATUSES as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-enforcement-action',
+ title: '단속 조치 · EnforcementAction',
+ description: 'CAPTURE / INSPECT / WARN / DISPERSE / TRACK / EVIDENCE',
+ catalog: ENFORCEMENT_ACTIONS as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-enforcement-result',
+ title: '단속 결과 · EnforcementResult',
+ description: 'PUNISHED / REFERRED / WARNED / RELEASED / FALSE_POSITIVE',
+ catalog: ENFORCEMENT_RESULTS as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-patrol-status',
+ title: '함정 상태 · PatrolStatus',
+ description: '출동 / 순찰 / 복귀 / 정박 / 정비 / 대기',
+ catalog: PATROL_STATUSES as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-engine-severity',
+ title: '엔진 심각도 · EngineSeverity',
+ description: 'AI 모델/분석엔진 오류 심각도',
+ catalog: ENGINE_SEVERITIES as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-device-status',
+ title: '함정 Agent 장치 상태 · DeviceStatus',
+ description: 'ONLINE / OFFLINE / SYNCING / NOT_DEPLOYED',
+ catalog: DEVICE_STATUSES as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-parent-resolution',
+ title: '모선 확정 상태 · ParentResolutionStatus',
+ description: 'PENDING / CONFIRMED / REJECTED / REVIEWING',
+ catalog: PARENT_RESOLUTION_STATUSES as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-label-session',
+ title: '라벨 세션 · LabelSessionStatus',
+ description: '모선 학습 세션 상태',
+ catalog: LABEL_SESSION_STATUSES as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-model-status',
+ title: 'AI 모델 상태 · ModelStatus',
+ description: 'DEV / STAGING / CANARY / PROD / ARCHIVED',
+ catalog: MODEL_STATUSES as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-quality-gate',
+ title: '품질 게이트 · QualityGateStatus',
+ description: '모델 배포 전 품질 검증',
+ catalog: QUALITY_GATE_STATUSES as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-experiment',
+ title: 'ML 실험 · ExperimentStatus',
+ description: 'MLOps 실험 상태',
+ catalog: EXPERIMENT_STATUSES as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-gear-group',
+ title: '어구 그룹 유형 · GearGroupType',
+ description: 'FLEET / GEAR_IN_ZONE / GEAR_OUT_ZONE',
+ catalog: GEAR_GROUP_TYPES as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-dark-vessel',
+ title: '다크베셀 패턴 · DarkVesselPattern',
+ description: 'AIS 끊김/스푸핑 패턴 5종',
+ catalog: DARK_VESSEL_PATTERNS as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-user-account',
+ title: '사용자 계정 상태 · UserAccountStatus',
+ description: 'ACTIVE / LOCKED / INACTIVE / PENDING',
+ catalog: USER_ACCOUNT_STATUSES as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-login-result',
+ title: '로그인 결과 · LoginResult',
+ description: 'SUCCESS / FAILED / LOCKED',
+ catalog: LOGIN_RESULTS as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-permit-status',
+ title: '허가 상태 · PermitStatus',
+ description: '선박 허가 유효/만료/정지',
+ catalog: PERMIT_STATUSES as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-gear-judgment',
+ title: '어구 판정 · GearJudgment',
+ description: '합법 / 의심 / 불법',
+ catalog: GEAR_JUDGMENTS as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-vessel-surveillance',
+ title: '선박 감시 상태 · VesselSurveillanceStatus',
+ description: '관심선박 추적 상태',
+ catalog: VESSEL_SURVEILLANCE_STATUSES as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-vessel-risk-ring',
+ title: '선박 위험 링 · VesselRiskRing',
+ description: '지도 마커 위험도 링',
+ catalog: VESSEL_RISK_RINGS as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-connection',
+ title: '연결 상태 · ConnectionStatus',
+ description: 'OK / WARNING / ERROR',
+ catalog: CONNECTION_STATUSES as unknown as AnyCatalog,
+ },
+ {
+ id: 'TRK-CAT-training-zone',
+ title: '훈련 수역 · TrainingZoneType',
+ description: 'NAVY / AIRFORCE / ARMY / ADD / KCG',
+ catalog: TRAINING_ZONE_TYPES as unknown as AnyCatalog,
+ },
+];
+
+export function CatalogSection() {
+ return (
+ <>
+
+
+
+
+ 페이지에서 배지를 렌더할 때는 반드시 카탈로그 API를 사용한다:
+
+
+ {`import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
+
+
+ {getAlertLevelLabel(event.level, t, lang)}
+ `}
+
+
+
+ {CATALOGS.map((entry) => (
+
+
+
+ {entry.title}
+ {entry.id}
+
+ {entry.description}
+ {entry.source && (
+ 출처: {entry.source}
+ )}
+
+
+
+ ))}
+ >
+ );
+}
diff --git a/frontend/src/design-system/sections/FormSection.tsx b/frontend/src/design-system/sections/FormSection.tsx
new file mode 100644
index 0000000..802d17b
--- /dev/null
+++ b/frontend/src/design-system/sections/FormSection.tsx
@@ -0,0 +1,124 @@
+import { useState } from 'react';
+import { TrkSectionHeader, Trk } from '../lib/Trk';
+import { Input } from '@shared/components/ui/input';
+import { Select } from '@shared/components/ui/select';
+import { Textarea } from '@shared/components/ui/textarea';
+import { Checkbox } from '@shared/components/ui/checkbox';
+import { Radio } from '@shared/components/ui/radio';
+
+export function FormSection() {
+ const [radio, setRadio] = useState('a');
+
+ return (
+ <>
+
+
+ {/* Input 사이즈 */}
+ Input · 사이즈
+
+ {(['sm', 'md', 'lg'] as const).map((size) => (
+
+
+
+
+ ))}
+
+
+ {/* Input 상태 */}
+ Input · 상태
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Select */}
+ Select
+
+ {(['sm', 'md', 'lg'] as const).map((size) => (
+
+
+
+
+ ))}
+
+
+ {/* Textarea */}
+ Textarea
+
+
+
+
+ {/* Checkbox */}
+ Checkbox
+
+
+
+
+
+
+
+
+
+ {/* Radio */}
+ Radio
+
+
+ setRadio('a')} />
+ setRadio('b')} />
+ setRadio('c')} />
+
+
+
+
+ {/* 사용 예시 */}
+ 사용 예시
+
+
+ {`import { Input } from '@shared/components/ui/input';
+import { Select } from '@shared/components/ui/select';
+import { Checkbox } from '@shared/components/ui/checkbox';
+
+ setQ(e.target.value)} />
+
+
+
+ setActive(e.target.checked)} />`}
+
+
+ >
+ );
+}
diff --git a/frontend/src/design-system/sections/GuideSection.tsx b/frontend/src/design-system/sections/GuideSection.tsx
new file mode 100644
index 0000000..53376f7
--- /dev/null
+++ b/frontend/src/design-system/sections/GuideSection.tsx
@@ -0,0 +1,130 @@
+import { TrkSectionHeader, Trk } from '../lib/Trk';
+
+export function GuideSection() {
+ return (
+ <>
+
+
+
+ 언제 fullBleed를 쓰는가?
+
+ -
+ 지도 중심 페이지 — 가장자리까지 지도가 확장되어야 할 때 (LiveMapView,
+ VesselDetail)
+
+ -
+ 3단 패널 레이아웃 — 좌측 리스트 + 중앙 콘텐츠 + 우측 상세 구조로
+ padding이 상위에서 관리되지 않는 경우
+
+ -
+ 높이 100% 요구 — 브라우저 뷰포트 전체를 차지해야 할 때
+
+
+
+ 예외: <PageContainer fullBleed>로 명시.{' '}
+ -m-4, -mx-4 같은 negative margin 해킹 금지.
+
+
+
+
+ 언제 className override를 허용하는가?
+
+ -
+ 레이아웃 보정 — 부모 컨테이너 특성상 width/margin 조정이 필요할 때 (
+
w-48, flex-1 등)
+
+ -
+ 반응형 조정 — sm/md/lg 브레이크포인트별 조정
+
+
+
+ 허용:{' '}
+
+ <Badge intent="info" className="w-full justify-center">
+
+
+
+ 금지:{' '}
+ <Badge className="bg-red-500 text-white">{' '}
+ — intent prop을 대체하려 하지 말 것
+
+
+
+
+ 동적 hex 색상이 필요한 경우
+
+ -
+ DB에서 사용자가 정의한 색상 (예: Role.colorHex) →
style={`{{ background: role.colorHex }}`}{' '}
+ 인라인 허용
+
+ -
+ 차트 팔레트 →
getAlertLevelHex(level) 같은 카탈로그 API에서 hex 조회
+
+ -
+ 지도 마커 deck.gl → RGB 튜플로 변환 필요, 카탈로그 hex 기반
+
+
+
+
+
+ 금지 패턴 체크리스트
+
+ - ❌
!important prefix (!bg-red-500)
+ - ❌
className="bg-X text-Y"로 Badge 스타일을 재정의
+ - ❌
<button className="bg-blue-600 ..."> — Button 컴포넌트 사용 필수
+ - ❌
<input className="bg-surface ..."> — Input 컴포넌트 사용 필수
+ - ❌
p-4 space-y-5 같은 제각각 padding — PageContainer size 사용
+ - ❌
-m-4, -mx-4 negative margin 해킹 — fullBleed 사용
+ - ❌ 페이지에서
const STATUS_COLORS = {`{...}`} 로컬 상수 정의 — shared/constants 카탈로그 사용
+ - ❌
date.toLocaleString('ko-KR', ...) 직접 호출 — formatDateTime 사용
+
+
+
+
+ 새 페이지 작성 템플릿
+
+ {`import { PageContainer, PageHeader, Section } from '@shared/components/layout';
+import { Button } from '@shared/components/ui/button';
+import { Input } from '@shared/components/ui/input';
+import { Badge } from '@shared/components/ui/badge';
+import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
+import { formatDateTime } from '@shared/utils/dateFormat';
+import { Shield, Plus } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+export function MyNewPage() {
+ const { t, i18n } = useTranslation('common');
+ const lang = i18n.language as 'ko' | 'en';
+
+ return (
+
+ }>
+ 추가
+
+ }
+ />
+
+
+
+ {getAlertLevelLabel('HIGH', t, lang)}
+
+ {formatDateTime(row.createdAt)}
+
+
+ );
+}`}
+
+
+ >
+ );
+}
diff --git a/frontend/src/design-system/sections/IntroSection.tsx b/frontend/src/design-system/sections/IntroSection.tsx
new file mode 100644
index 0000000..cde784f
--- /dev/null
+++ b/frontend/src/design-system/sections/IntroSection.tsx
@@ -0,0 +1,68 @@
+import { TrkSectionHeader, Trk } from '../lib/Trk';
+
+export function IntroSection() {
+ return (
+ <>
+
+
+
+ 이 페이지의 목적
+
+ -
+ 모든 스타일의 뼈대 — 페이지 파일은 이 쇼케이스에 정의된 컴포넌트와 토큰만
+ 사용한다. 임의의 `className="bg-red-600"` 같은 직접 스타일은 금지.
+
+ -
+ 미세조정의 단일 지점 — 색상 · 여백 · 텍스트 크기 등 모든 변경은 이 페이지에서
+ 먼저 시각적으로 검증한 후 실제 페이지에 적용한다.
+
+ -
+ ID 기반 참조 — 각 쇼케이스 항목에
TRK-* 추적 ID가 부여되어 있어,
+ 특정 변형을 정확히 가리키며 논의 · 수정이 가능하다.
+
+
+
+
+
+ 사용 방법
+
+ -
+ 상단 "ID 복사 모드" 체크박스를 켜면 쇼케이스 항목 클릭 시 ID가 클립보드에
+ 복사된다.
+
+ -
+ URL 해시
#trk=TRK-BADGE-critical-sm 으로 특정 항목 딥링크 — 스크롤
+ + 하이라이트.
+
+ -
+ 상단 Dark / Light 토글로 두 테마에서 동시에 검증.
+
+ -
+ 좌측 네비게이션으로 섹션 이동. 각 섹션 제목 옆에 섹션의
TRK-SEC-* ID가 노출된다.
+
+
+
+
+
+ 추적 ID 명명 규칙
+
+ {`TRK-<카테고리>-<슬러그>[-<변형>]
+
+예시:
+ TRK-TOKEN-text-heading → 시맨틱 토큰 --text-heading
+ TRK-BADGE-critical-sm → Badge intent=critical size=sm
+ TRK-BUTTON-primary-md → Button variant=primary size=md
+ TRK-LAYOUT-page-container → PageContainer 루트
+ TRK-CAT-alert-level-HIGH → alertLevels 카탈로그의 HIGH 배지
+ TRK-SEC-badge → Badge 섹션 자체
+
+카테고리: SEC, INTRO, TOKEN, TYPO, BADGE, BUTTON, FORM, CARD, LAYOUT, CAT, GUIDE`}
+
+
+ >
+ );
+}
diff --git a/frontend/src/design-system/sections/LayoutSection.tsx b/frontend/src/design-system/sections/LayoutSection.tsx
new file mode 100644
index 0000000..c810e50
--- /dev/null
+++ b/frontend/src/design-system/sections/LayoutSection.tsx
@@ -0,0 +1,177 @@
+import { TrkSectionHeader, Trk } from '../lib/Trk';
+import { PageContainer, PageHeader, Section } from '@shared/components/layout';
+import { Button } from '@shared/components/ui/button';
+import { Input } from '@shared/components/ui/input';
+import { Shield, BarChart3, Plus, Search } from 'lucide-react';
+
+export function LayoutSection() {
+ return (
+ <>
+
+
+ {/* PageContainer */}
+ PageContainer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ fullBleed: 가장자리까지 콘텐츠 (LiveMapView / VesselDetail 패턴)
+
+
+
+
+
+ {/* PageHeader */}
+ PageHeader
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }>
+ 검색
+
+ }>
+ 새 보고서
+
+ >
+ }
+ />
+
+
+
+ {/* Section */}
+ Section (Card 단축)
+
+
+
+
+
+
+ 동해
+ 23건
+
+
+ 서해
+ 12건
+
+
+
+
+
+
+
+
+ 전체 보기
+
+ }
+ >
+ 이벤트 3건
+
+
+
+
+ {/* 전체 조합 예시 */}
+ 전체 조합 예시
+
+
+ {`import { PageContainer, PageHeader, Section } from '@shared/components/layout';
+import { Button } from '@shared/components/ui/button';
+import { Shield, Plus } from 'lucide-react';
+
+export function AccessControlPage() {
+ return (
+
+ }>
+ 역할 추가
+
+ }
+ />
+
+
+
+
+ );
+}`}
+
+
+ >
+ );
+}
diff --git a/frontend/src/design-system/sections/TokenSection.tsx b/frontend/src/design-system/sections/TokenSection.tsx
new file mode 100644
index 0000000..908edda
--- /dev/null
+++ b/frontend/src/design-system/sections/TokenSection.tsx
@@ -0,0 +1,160 @@
+import { TrkSectionHeader, Trk } from '../lib/Trk';
+
+/** 시맨틱 색상 토큰 목록. theme.css :root 정의 기준 */
+const SURFACE_TOKENS = [
+ { id: 'background', label: '--background', desc: '페이지 최하위 배경' },
+ { id: 'card', label: '--card', desc: 'Card 컴포넌트 배경' },
+ { id: 'surface-raised', label: '--surface-raised', desc: '카드 내부 섹션' },
+ { id: 'surface-overlay', label: '--surface-overlay', desc: '모달/오버레이' },
+ { id: 'muted', label: '--muted', desc: '약한 배경 (inactive/disabled)' },
+ { id: 'popover', label: '--popover', desc: '드롭다운/툴팁 배경' },
+];
+
+const TEXT_TOKENS = [
+ { id: 'text-heading', label: '--text-heading', example: '제목 Heading', desc: 'h1~h4, 강조 텍스트' },
+ { id: 'text-label', label: '--text-label', example: '본문 Label', desc: '일반 본문, 라벨' },
+ { id: 'text-hint', label: '--text-hint', example: '보조 Hint', desc: '설명, 힌트, placeholder' },
+ { id: 'text-on-vivid', label: '--text-on-vivid', example: '컬러풀 위 텍스트', desc: '-600/-700 진한 배경 위 (버튼)', bg: '#2563eb' },
+ { id: 'text-on-bright', label: '--text-on-bright', example: '밝은 위 텍스트', desc: '-300/-400 밝은 배경 위 (배지)', bg: '#60a5fa' },
+];
+
+const BRAND_COLORS = [
+ { id: 'primary', label: '--primary', desc: '기본 강조색 (파랑)' },
+ { id: 'destructive', label: '--destructive', desc: '위험/삭제 (빨강)' },
+ { id: 'ring', label: '--ring', desc: 'focus ring' },
+ { id: 'border', label: '--border', desc: '경계선' },
+];
+
+const CHART_COLORS = [
+ { id: 'chart-1', label: '--chart-1' },
+ { id: 'chart-2', label: '--chart-2' },
+ { id: 'chart-3', label: '--chart-3' },
+ { id: 'chart-4', label: '--chart-4' },
+ { id: 'chart-5', label: '--chart-5' },
+];
+
+const RADIUS_SCALE = [
+ { id: 'sm', label: 'radius-sm', cls: 'rounded-sm', px: 'calc(radius - 4px)' },
+ { id: 'md', label: 'radius-md', cls: 'rounded-md', px: 'calc(radius - 2px)' },
+ { id: 'lg', label: 'radius-lg', cls: 'rounded-lg', px: '0.5rem (default)' },
+ { id: 'xl', label: 'radius-xl', cls: 'rounded-xl', px: 'calc(radius + 4px)' },
+ { id: 'full', label: 'rounded-full', cls: 'rounded-full', px: '9999px' },
+];
+
+const SPACING_SCALE = [
+ { id: '1', size: 4 }, { id: '2', size: 8 }, { id: '3', size: 12 },
+ { id: '4', size: 16 }, { id: '5', size: 20 }, { id: '6', size: 24 },
+ { id: '8', size: 32 }, { id: '10', size: 40 },
+];
+
+export function TokenSection() {
+ return (
+ <>
+
+
+ {/* Surface 토큰 */}
+ Surface / 배경
+
+ {SURFACE_TOKENS.map((t) => (
+
+
+ {t.label}
+ {t.desc}
+ TRK-TOKEN-{t.id}
+
+ ))}
+
+
+ {/* 텍스트 토큰 */}
+ Text / 텍스트
+
+ {TEXT_TOKENS.map((t) => (
+
+
+ {t.example}
+
+ {t.label}
+ {t.desc}
+ TRK-TOKEN-{t.id}
+
+ ))}
+
+
+ {/* 브랜드 색 */}
+ Brand / 기능색
+
+ {BRAND_COLORS.map((t) => (
+
+
+ {t.label}
+ {t.desc}
+
+ ))}
+
+
+ {/* Chart 색 */}
+ Chart / 차트 팔레트
+
+
+ {CHART_COLORS.map((t) => (
+
+
+ {t.label}
+
+ ))}
+
+ TRK-TOKEN-chart-palette
+
+
+ {/* Radius 스케일 */}
+ Radius / 모서리 반경
+
+
+ {RADIUS_SCALE.map((r) => (
+
+
+ {r.label}
+ {r.px}
+
+ ))}
+
+
+
+ {/* Spacing 스케일 */}
+ Spacing / 간격 스케일
+
+
+ {SPACING_SCALE.map((s) => (
+
+ p-{s.id}
+
+ {s.size}px
+
+ ))}
+
+
+ >
+ );
+}
diff --git a/frontend/src/design-system/sections/TypographySection.tsx b/frontend/src/design-system/sections/TypographySection.tsx
new file mode 100644
index 0000000..964888c
--- /dev/null
+++ b/frontend/src/design-system/sections/TypographySection.tsx
@@ -0,0 +1,35 @@
+import { TrkSectionHeader, Trk } from '../lib/Trk';
+
+const TYPE_SCALE = [
+ { id: 'h1', tag: 'h1' as const, cls: 'text-2xl font-bold text-heading', example: '주요 타이틀 (H1 · text-2xl)' },
+ { id: 'h2', tag: 'h2' as const, cls: 'text-xl font-bold text-heading', example: '페이지 제목 (H2 · text-xl)' },
+ { id: 'h3', tag: 'h3' as const, cls: 'text-lg font-semibold text-heading', example: '섹션 제목 (H3 · text-lg)' },
+ { id: 'h4', tag: 'h4' as const, cls: 'text-base font-semibold text-heading', example: '카드 제목 (H4 · text-base)' },
+ { id: 'body', tag: 'p' as const, cls: 'text-sm text-label', example: '본문 텍스트 (body · text-sm)' },
+ { id: 'body-sm', tag: 'p' as const, cls: 'text-xs text-label', example: '작은 본문 (body-sm · text-xs)' },
+ { id: 'label', tag: 'span' as const, cls: 'text-[11px] text-label font-medium', example: '라벨 (label · 11px)' },
+ { id: 'hint', tag: 'span' as const, cls: 'text-[10px] text-hint', example: '힌트/캡션 (hint · 10px)' },
+];
+
+export function TypographySection() {
+ return (
+ <>
+
+
+
+ {TYPE_SCALE.map(({ id, tag: Tag, cls, example }) => (
+
+
+ {example}
+ {cls}
+
+
+ ))}
+
+ >
+ );
+}
diff --git a/frontend/src/designSystemMain.tsx b/frontend/src/designSystemMain.tsx
new file mode 100644
index 0000000..3a71b9d
--- /dev/null
+++ b/frontend/src/designSystemMain.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import './styles/index.css';
+import { DesignSystemApp } from './design-system/DesignSystemApp';
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/frontend/src/lib/theme/variants.ts b/frontend/src/lib/theme/variants.ts
index e8756e6..447fd17 100644
--- a/frontend/src/lib/theme/variants.ts
+++ b/frontend/src/lib/theme/variants.ts
@@ -78,3 +78,89 @@ export type CardVariant = 'default' | 'elevated' | 'inner' | 'transparent';
export type BadgeIntent = 'critical' | 'high' | 'warning' | 'info' | 'success' | 'muted' | 'purple' | 'cyan';
export type BadgeSize = 'xs' | 'sm' | 'md' | 'lg';
export type StatusDotStatus = 'online' | 'warning' | 'danger' | 'offline';
+
+/** Button 변형 — 5 variant × 3 size = 15
+ *
+ * 색상 정책:
+ * - primary/destructive: 솔리드 배경 + text-on-vivid (흰색, 테마 무관)
+ * - secondary/ghost/outline: 텍스트 토큰 (text-label / text-heading on hover)
+ */
+export const buttonVariants = cva(
+ 'inline-flex items-center justify-center gap-1.5 whitespace-nowrap font-medium rounded-md transition-colors ' +
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 ' +
+ 'disabled:pointer-events-none disabled:opacity-50',
+ {
+ variants: {
+ variant: {
+ primary: 'bg-blue-600 hover:bg-blue-500 text-on-vivid border border-blue-700',
+ secondary:
+ 'bg-surface-overlay hover:bg-surface-raised text-label hover:text-heading border border-slate-600/40',
+ ghost: 'bg-transparent hover:bg-surface-overlay text-label hover:text-heading border border-transparent',
+ outline:
+ 'bg-transparent hover:bg-blue-500/10 text-blue-400 hover:text-blue-300 border border-blue-500/50',
+ destructive: 'bg-red-600 hover:bg-red-500 text-on-vivid border border-red-700',
+ },
+ size: {
+ sm: 'h-7 px-2.5 text-xs',
+ md: 'h-8 px-3 text-sm',
+ lg: 'h-10 px-4 text-base',
+ },
+ },
+ defaultVariants: {
+ variant: 'secondary',
+ size: 'md',
+ },
+ },
+);
+
+export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'outline' | 'destructive';
+export type ButtonSize = 'sm' | 'md' | 'lg';
+
+/** Input / Select / 유사 폼 요소 공통 변형 */
+export const inputVariants = cva(
+ 'w-full bg-surface-overlay border rounded-md text-label placeholder:text-hint ' +
+ 'focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/30 ' +
+ 'transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
+ {
+ variants: {
+ size: {
+ sm: 'h-7 px-2 text-xs',
+ md: 'h-8 px-3 text-sm',
+ lg: 'h-10 px-3.5 text-base',
+ },
+ state: {
+ default: 'border-slate-600/40',
+ error: 'border-red-500/60 focus:border-red-500 focus:ring-red-500/30',
+ success: 'border-green-500/60 focus:border-green-500 focus:ring-green-500/30',
+ },
+ },
+ defaultVariants: {
+ size: 'md',
+ state: 'default',
+ },
+ },
+);
+
+export type InputSize = 'sm' | 'md' | 'lg';
+export type InputState = 'default' | 'error' | 'success';
+
+/** PageContainer 변형 — 표준 페이지 루트 padding/spacing */
+export const pageContainerVariants = cva('', {
+ variants: {
+ size: {
+ sm: 'p-4 space-y-3',
+ md: 'p-5 space-y-4',
+ lg: 'p-6 space-y-4',
+ },
+ fullBleed: {
+ true: 'p-0 space-y-0',
+ false: '',
+ },
+ },
+ defaultVariants: {
+ size: 'md',
+ fullBleed: false,
+ },
+});
+
+export type PageContainerSize = 'sm' | 'md' | 'lg';
diff --git a/frontend/src/shared/components/layout/PageContainer.tsx b/frontend/src/shared/components/layout/PageContainer.tsx
new file mode 100644
index 0000000..77d6a9b
--- /dev/null
+++ b/frontend/src/shared/components/layout/PageContainer.tsx
@@ -0,0 +1,34 @@
+import { type HTMLAttributes, type ReactNode } from 'react';
+import { pageContainerVariants, type PageContainerSize } from '@lib/theme/variants';
+import { cn } from '@lib/utils/cn';
+
+export interface PageContainerProps extends HTMLAttributes {
+ size?: PageContainerSize;
+ /** 지도/3단 패널 등 풀화면 페이지: padding 0, space-y 0 */
+ fullBleed?: boolean;
+ children: ReactNode;
+}
+
+/**
+ * PageContainer — 모든 feature 페이지의 표준 루트 컨테이너
+ *
+ * 기본값: `p-5 space-y-4` (프로젝트 전역 기준)
+ * - size="sm" → p-4 space-y-3
+ * - size="lg" → p-6 space-y-4 (admin 계열)
+ * - fullBleed → padding 없음 (LiveMapView, VesselDetail 패턴)
+ *
+ * ESLint rule로 feature 페이지 루트는 PageContainer 필수 강제 (예정).
+ */
+export function PageContainer({
+ className,
+ size,
+ fullBleed,
+ children,
+ ...props
+}: PageContainerProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/shared/components/layout/PageHeader.tsx b/frontend/src/shared/components/layout/PageHeader.tsx
new file mode 100644
index 0000000..b1ea48a
--- /dev/null
+++ b/frontend/src/shared/components/layout/PageHeader.tsx
@@ -0,0 +1,63 @@
+import { type ReactNode } from 'react';
+import { type LucideIcon } from 'lucide-react';
+import { cn } from '@lib/utils/cn';
+import { Badge } from '@shared/components/ui/badge';
+
+/**
+ * PageHeader — 페이지 최상단 제목/설명/액션 헤더
+ *
+ * 구성:
+ * [좌] 아이콘 + 제목 + 설명 + (선택) 데모 배지
+ * [우] 액션 슬롯 (검색/필터/버튼 등)
+ *
+ * 사용:
+ *
+ *
+ * 저장}
+ * />
+ */
+export interface PageHeaderProps {
+ title: string;
+ description?: string;
+ icon?: LucideIcon;
+ /** 아이콘 색상 클래스 (text-blue-400 등). 미지정 시 text-muted-foreground */
+ iconColor?: string;
+ /** 데모 데이터 배지 노출 */
+ demo?: boolean;
+ /** 우측 액션 슬롯 */
+ actions?: ReactNode;
+ className?: string;
+}
+
+export function PageHeader({
+ title,
+ description,
+ icon: Icon,
+ iconColor = 'text-muted-foreground',
+ demo = false,
+ actions,
+ className,
+}: PageHeaderProps) {
+ return (
+
+
+
+ {Icon && }
+ {title}
+ {demo && (
+
+ 데모 데이터
+
+ )}
+
+ {description && {description}
}
+
+ {actions && {actions}}
+
+ );
+}
diff --git a/frontend/src/shared/components/layout/Section.tsx b/frontend/src/shared/components/layout/Section.tsx
new file mode 100644
index 0000000..7d4d572
--- /dev/null
+++ b/frontend/src/shared/components/layout/Section.tsx
@@ -0,0 +1,51 @@
+import { type ReactNode } from 'react';
+import { type LucideIcon } from 'lucide-react';
+import { Card, CardHeader, CardTitle, CardContent } from '@shared/components/ui/card';
+import type { CardVariant } from '@lib/theme/variants';
+import { cn } from '@lib/utils/cn';
+
+/**
+ * Section — Card + CardHeader + CardTitle + CardContent 조합을 한 번에
+ *
+ * 사용:
+ *
+ * {...}
+ *
+ */
+export interface SectionProps {
+ title?: string;
+ icon?: LucideIcon;
+ iconColor?: string;
+ variant?: CardVariant;
+ /** 우측 액션 슬롯 (제목 옆 버튼 등) */
+ actions?: ReactNode;
+ children: ReactNode;
+ className?: string;
+ contentClassName?: string;
+}
+
+export function Section({
+ title,
+ icon: Icon,
+ iconColor = 'text-muted-foreground',
+ variant = 'default',
+ actions,
+ children,
+ className,
+ contentClassName,
+}: SectionProps) {
+ return (
+
+ {title && (
+
+
+ {Icon && }
+ {title}
+
+ {actions && {actions}}
+
+ )}
+ {children}
+
+ );
+}
diff --git a/frontend/src/shared/components/layout/index.ts b/frontend/src/shared/components/layout/index.ts
new file mode 100644
index 0000000..7d7160e
--- /dev/null
+++ b/frontend/src/shared/components/layout/index.ts
@@ -0,0 +1,3 @@
+export { PageContainer, type PageContainerProps } from './PageContainer';
+export { PageHeader, type PageHeaderProps } from './PageHeader';
+export { Section, type SectionProps } from './Section';
diff --git a/frontend/src/shared/components/ui/button.tsx b/frontend/src/shared/components/ui/button.tsx
new file mode 100644
index 0000000..5e19b12
--- /dev/null
+++ b/frontend/src/shared/components/ui/button.tsx
@@ -0,0 +1,29 @@
+import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
+import { buttonVariants, type ButtonVariant, type ButtonSize } from '@lib/theme/variants';
+import { cn } from '@lib/utils/cn';
+
+export interface ButtonProps extends ButtonHTMLAttributes {
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+ icon?: ReactNode;
+ trailingIcon?: ReactNode;
+}
+
+/**
+ * Button — 프로젝트 표준 버튼
+ * variant: primary / secondary / ghost / outline / destructive
+ * size: sm / md / lg
+ * className override는 cn()으로 안전하게 허용됨.
+ */
+export const Button = forwardRef(
+ ({ className, variant, size, icon, trailingIcon, children, ...props }, ref) => {
+ return (
+
+ {icon}
+ {children}
+ {trailingIcon}
+
+ );
+ },
+);
+Button.displayName = 'Button';
diff --git a/frontend/src/shared/components/ui/checkbox.tsx b/frontend/src/shared/components/ui/checkbox.tsx
new file mode 100644
index 0000000..e11ebed
--- /dev/null
+++ b/frontend/src/shared/components/ui/checkbox.tsx
@@ -0,0 +1,30 @@
+import { forwardRef, type InputHTMLAttributes } from 'react';
+import { cn } from '@lib/utils/cn';
+
+export interface CheckboxProps extends Omit, 'type'> {
+ label?: string;
+}
+
+/** Checkbox — native input에 라벨/스타일만 씌운 가벼운 래퍼 */
+export const Checkbox = forwardRef(
+ ({ className, label, id, ...props }, ref) => {
+ const inputId = id ?? `cb-${Math.random().toString(36).slice(2, 8)}`;
+ return (
+
+ );
+ },
+);
+Checkbox.displayName = 'Checkbox';
diff --git a/frontend/src/shared/components/ui/input.tsx b/frontend/src/shared/components/ui/input.tsx
new file mode 100644
index 0000000..f5904aa
--- /dev/null
+++ b/frontend/src/shared/components/ui/input.tsx
@@ -0,0 +1,23 @@
+import { forwardRef, type InputHTMLAttributes } from 'react';
+import { inputVariants, type InputSize, type InputState } from '@lib/theme/variants';
+import { cn } from '@lib/utils/cn';
+
+export interface InputProps extends Omit, 'size'> {
+ size?: InputSize;
+ state?: InputState;
+}
+
+/** Input — 프로젝트 표준 입력 필드 */
+export const Input = forwardRef(
+ ({ className, size, state, type = 'text', ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+Input.displayName = 'Input';
diff --git a/frontend/src/shared/components/ui/radio.tsx b/frontend/src/shared/components/ui/radio.tsx
new file mode 100644
index 0000000..26efb9d
--- /dev/null
+++ b/frontend/src/shared/components/ui/radio.tsx
@@ -0,0 +1,29 @@
+import { forwardRef, type InputHTMLAttributes } from 'react';
+import { cn } from '@lib/utils/cn';
+
+export interface RadioProps extends Omit, 'type'> {
+ label?: string;
+}
+
+export const Radio = forwardRef(
+ ({ className, label, id, ...props }, ref) => {
+ const inputId = id ?? `rd-${Math.random().toString(36).slice(2, 8)}`;
+ return (
+
+ );
+ },
+);
+Radio.displayName = 'Radio';
diff --git a/frontend/src/shared/components/ui/select.tsx b/frontend/src/shared/components/ui/select.tsx
new file mode 100644
index 0000000..822897d
--- /dev/null
+++ b/frontend/src/shared/components/ui/select.tsx
@@ -0,0 +1,24 @@
+import { forwardRef, type SelectHTMLAttributes } from 'react';
+import { inputVariants, type InputSize, type InputState } from '@lib/theme/variants';
+import { cn } from '@lib/utils/cn';
+
+export interface SelectProps extends Omit, 'size'> {
+ size?: InputSize;
+ state?: InputState;
+}
+
+/** Select — Input과 동일한 스타일 토큰 공유 */
+export const Select = forwardRef(
+ ({ className, size, state, children, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+Select.displayName = 'Select';
diff --git a/frontend/src/shared/components/ui/textarea.tsx b/frontend/src/shared/components/ui/textarea.tsx
new file mode 100644
index 0000000..5f6c453
--- /dev/null
+++ b/frontend/src/shared/components/ui/textarea.tsx
@@ -0,0 +1,31 @@
+import { forwardRef, type TextareaHTMLAttributes } from 'react';
+import { cn } from '@lib/utils/cn';
+
+export interface TextareaProps extends TextareaHTMLAttributes {
+ state?: 'default' | 'error' | 'success';
+}
+
+export const Textarea = forwardRef(
+ ({ className, state = 'default', ...props }, ref) => {
+ const stateClass = {
+ default: 'border-slate-600/40',
+ error: 'border-red-500/60 focus:border-red-500 focus:ring-red-500/30',
+ success: 'border-green-500/60 focus:border-green-500 focus:ring-green-500/30',
+ }[state];
+ return (
+
+ );
+ },
+);
+Textarea.displayName = 'Textarea';
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index dde1356..ca2179d 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -33,6 +33,7 @@ export default defineConfig({
input: {
main: path.resolve(__dirname, 'index.html'),
systemFlow: path.resolve(__dirname, 'system-flow.html'),
+ designSystem: path.resolve(__dirname, 'design-system.html'),
},
},
},