feat(frontend): 디자인 시스템 쇼케이스 페이지 + 신규 공통 컴포넌트

쇼케이스 (/design-system.html):
- 별도 Vite entry (System Flow 패턴 재사용, 메인 SPA 분리)
- 10개 섹션: Intro / Token / Typography / Badge / Button / Form /
  Card / Layout / Catalog (19+) / Guide
- 추적 ID 체계 (TRK-CATEGORY-SLUG):
  - hover 시 툴팁 + "ID 복사 모드"에서 클릭 시 클립보드 복사
  - URL hash 딥링크 (#trk=TRK-BADGE-critical-sm) 스크롤+하이라이트
  - 산출문서/논의에서 특정 변형 정확히 참조 가능
- Dark/Light 테마 토글로 양쪽 시각 검증

신규 공통 컴포넌트:
- Button (@shared/components/ui/button.tsx)
  - 5 variant × 3 size = 15 변형
  - primary/secondary/ghost/outline/destructive × sm/md/lg
- Input / Select / Textarea / Checkbox / Radio
  - Input · Select 공통 inputVariants 공유 (sm/md/lg × default/error/success)
- PageContainer / PageHeader / Section (shared/components/layout/)
  - PageContainer: size sm/md/lg + fullBleed (지도/풀화면 예외)
  - PageHeader: title + description + icon + demo 배지 + actions 슬롯
  - Section: Card + CardHeader + CardTitle + CardContent 단축

variants.ts 확장:
- buttonVariants / inputVariants / pageContainerVariants CVA 정의
- Button/Input/Select는 variants.ts에서 import하여 fast-refresh 경고 회피

빌드 검증 완료:
- TypeScript 타입 체크 통과
- ESLint 통과 (경고 0)
- vite build: designSystem-*.js 54KB (메인 SPA와 분리)

이 쇼케이스가 확정된 후 실제 40+ 페이지 마이그레이션 진행 예정.
This commit is contained in:
htlee 2026-04-08 11:09:36 +09:00
부모 a07b7d9ba5
커밋 e0b51efc54
28개의 변경된 파일2187개의 추가작업 그리고 0개의 파일을 삭제

파일 보기

@ -0,0 +1,12 @@
<!doctype html>
<html lang="ko" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KCG AI Monitoring — Design System</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/designSystemMain.tsx"></script>
</body>
</html>

파일 보기

@ -0,0 +1,138 @@
/* 디자인 쇼케이스 전용 스타일 */
.ds-shell {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--background, #0b1220);
color: var(--foreground, #e2e8f0);
}
.ds-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid rgb(51 65 85 / 0.5);
flex-shrink: 0;
background: var(--surface-overlay, rgb(15 23 42 / 0.6));
backdrop-filter: blur(8px);
}
.ds-body {
display: flex;
flex: 1;
min-height: 0;
}
.ds-nav {
width: 220px;
flex-shrink: 0;
padding: 1rem 0.75rem;
border-right: 1px solid rgb(51 65 85 / 0.5);
overflow-y: auto;
position: sticky;
top: 0;
}
.ds-main {
flex: 1;
overflow-y: auto;
padding: 2rem 2.5rem 6rem;
scroll-behavior: smooth;
}
.ds-main section {
margin-bottom: 4rem;
}
/* 추적 ID 시스템 */
.trk-item {
position: relative;
transition: outline-color 0.2s;
}
.trk-copyable {
cursor: pointer;
}
.trk-copyable:hover {
outline: 1px dashed rgb(59 130 246 / 0.5);
outline-offset: 4px;
}
.trk-active {
outline: 2px solid rgb(59 130 246);
outline-offset: 4px;
animation: trk-pulse 1.2s ease-out;
}
.trk-item[data-copied='true'] {
outline: 2px solid rgb(34 197 94) !important;
outline-offset: 4px;
}
.trk-item[data-copied='true']::after {
content: '복사됨 ✓';
position: absolute;
top: -1.5rem;
left: 0;
font-size: 0.625rem;
color: rgb(34 197 94);
background: rgb(34 197 94 / 0.15);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
pointer-events: none;
z-index: 10;
}
@keyframes trk-pulse {
0% {
outline-color: rgb(59 130 246);
}
50% {
outline-color: rgb(59 130 246 / 0.3);
}
100% {
outline-color: rgb(59 130 246);
}
}
/* 쇼케이스 그리드 */
.ds-grid {
display: grid;
gap: 0.75rem;
}
.ds-grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ds-grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.ds-grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.ds-grid-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.ds-sample {
padding: 1rem;
background: var(--surface-raised, rgb(30 41 59 / 0.4));
border: 1px solid rgb(51 65 85 / 0.4);
border-radius: 0.5rem;
}
.ds-sample-label {
font-size: 0.625rem;
color: var(--text-hint, rgb(148 163 184));
font-family: ui-monospace, monospace;
margin-top: 0.5rem;
word-break: break-all;
}
.ds-code {
display: block;
padding: 0.75rem 1rem;
background: rgb(15 23 42 / 0.7);
border: 1px solid rgb(51 65 85 / 0.4);
border-radius: 0.375rem;
font-family: ui-monospace, monospace;
font-size: 0.75rem;
color: rgb(203 213 225);
white-space: pre;
overflow-x: auto;
}

파일 보기

@ -0,0 +1,174 @@
import { useEffect, useState } from 'react';
import { TrkProvider, useTrk } from './lib/TrkContext';
import { IntroSection } from './sections/IntroSection';
import { TokenSection } from './sections/TokenSection';
import { TypographySection } from './sections/TypographySection';
import { BadgeSection } from './sections/BadgeSection';
import { ButtonSection } from './sections/ButtonSection';
import { FormSection } from './sections/FormSection';
import { CardSectionShowcase } from './sections/CardSection';
import { LayoutSection } from './sections/LayoutSection';
import { CatalogSection } from './sections/CatalogSection';
import { GuideSection } from './sections/GuideSection';
import './DesignSystemApp.css';
interface NavItem {
id: string;
label: string;
anchor: string;
}
const NAV_ITEMS: NavItem[] = [
{ id: 'intro', label: '1. 소개', anchor: 'TRK-SEC-intro' },
{ id: 'token', label: '2. 테마 · 토큰', anchor: 'TRK-SEC-token' },
{ id: 'typography', label: '3. 타이포그래피', anchor: 'TRK-SEC-typography' },
{ id: 'badge', label: '4. Badge', anchor: 'TRK-SEC-badge' },
{ id: 'button', label: '5. Button', anchor: 'TRK-SEC-button' },
{ id: 'form', label: '6. Form', anchor: 'TRK-SEC-form' },
{ id: 'card', label: '7. Card / Section', anchor: 'TRK-SEC-card' },
{ id: 'layout', label: '8. Layout', anchor: 'TRK-SEC-layout' },
{ id: 'catalog', label: '9. 분류 카탈로그', anchor: 'TRK-SEC-catalog' },
{ id: 'guide', label: '10. 예외 / 가이드', anchor: 'TRK-SEC-guide' },
];
function DesignSystemShell() {
const { copyMode, setCopyMode } = useTrk();
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
const [activeNav, setActiveNav] = useState<string>('intro');
// 테마 토글
useEffect(() => {
const root = document.documentElement;
root.classList.remove('dark', 'light');
root.classList.add(theme);
}, [theme]);
// 스크롤 감지로 현재 네비 하이라이트
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const id = entry.target.getAttribute('data-section-id');
if (id) setActiveNav(id);
}
}
},
{ rootMargin: '-40% 0px -55% 0px', threshold: 0 },
);
const sections = document.querySelectorAll('[data-section-id]');
sections.forEach((s) => observer.observe(s));
return () => observer.disconnect();
}, []);
const scrollTo = (anchor: string) => {
const el = document.querySelector(`[data-trk="${anchor}"]`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
<div className="ds-shell">
{/* 고정 헤더 */}
<header className="ds-header">
<div className="flex items-center gap-3">
<h1 className="text-base font-bold text-heading">KCG Design System</h1>
<code className="text-[10px] text-hint font-mono">v0.1.0 · </code>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-label">
<input
type="checkbox"
checked={copyMode}
onChange={(e) => setCopyMode(e.target.checked)}
className="accent-blue-500"
/>
ID
</label>
<button
type="button"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="px-3 py-1 rounded-md border border-slate-600/40 text-xs text-label hover:bg-slate-700/30"
>
{theme === 'dark' ? '☾ Dark' : '☀ Light'}
</button>
</div>
</header>
<div className="ds-body">
{/* 좌측 네비 */}
<nav className="ds-nav">
<div className="text-[10px] text-hint uppercase mb-2 tracking-wider">Sections</div>
<ul className="space-y-0.5">
{NAV_ITEMS.map((item) => (
<li key={item.id}>
<button
type="button"
onClick={() => scrollTo(item.anchor)}
className={`w-full text-left px-2 py-1.5 rounded text-xs transition-colors ${
activeNav === item.id
? 'bg-blue-500/15 text-blue-400 border-l-2 border-blue-500'
: 'text-label hover:bg-slate-700/20'
}`}
>
{item.label}
</button>
</li>
))}
</ul>
<div className="mt-6 pt-4 border-t border-slate-700/40 text-[10px] text-hint space-y-1">
<div>
<strong className="text-label"> ID </strong>
</div>
<code className="block font-mono">TRK-&lt;&gt;-&lt;&gt;</code>
<div className="mt-2">
: <code className="font-mono">#trk=ID</code>
</div>
</div>
</nav>
{/* 우측 컨텐츠 */}
<main className="ds-main">
<section data-section-id="intro">
<IntroSection />
</section>
<section data-section-id="token">
<TokenSection />
</section>
<section data-section-id="typography">
<TypographySection />
</section>
<section data-section-id="badge">
<BadgeSection />
</section>
<section data-section-id="button">
<ButtonSection />
</section>
<section data-section-id="form">
<FormSection />
</section>
<section data-section-id="card">
<CardSectionShowcase />
</section>
<section data-section-id="layout">
<LayoutSection />
</section>
<section data-section-id="catalog">
<CatalogSection />
</section>
<section data-section-id="guide">
<GuideSection />
</section>
</main>
</div>
</div>
);
}
export function DesignSystemApp() {
return (
<TrkProvider>
<DesignSystemShell />
</TrkProvider>
);
}

파일 보기

@ -0,0 +1,71 @@
import { type ReactNode, type CSSProperties, type MouseEvent } from 'react';
import { useTrk } from './TrkContext';
/**
* ID
* ID를 :
* 1. ID
* 2. "ID 복사 모드"
* 3. URL hash (#trk=TRK-BADGE-critical-sm)
*/
interface TrkProps {
id: string;
children: ReactNode;
className?: string;
style?: CSSProperties;
/** 인라인 요소로 렌더할지 여부 (기본: block) */
inline?: boolean;
}
export function Trk({ id, children, className = '', style, inline = false }: TrkProps) {
const { copyMode, activeId } = useTrk();
const isActive = activeId === id;
const handleClick = async (e: MouseEvent) => {
if (!copyMode) return;
e.preventDefault();
e.stopPropagation();
try {
await navigator.clipboard.writeText(id);
const el = e.currentTarget as HTMLElement;
el.dataset.copied = 'true';
setTimeout(() => delete el.dataset.copied, 800);
} catch {
// clipboard API 미지원 시 무시
}
};
const Wrapper = inline ? 'span' : 'div';
return (
<Wrapper
data-trk={id}
className={`trk-item ${isActive ? 'trk-active' : ''} ${copyMode ? 'trk-copyable' : ''} ${className}`}
style={style}
title={id}
onClick={handleClick}
>
{children}
</Wrapper>
);
}
export function TrkSectionHeader({
id,
title,
description,
}: {
id: string;
title: string;
description?: string;
}) {
return (
<div data-trk={id} className="mb-4">
<div className="flex items-baseline gap-2">
<h2 className="text-xl font-bold text-heading">{title}</h2>
<code className="text-[10px] text-hint font-mono">{id}</code>
</div>
{description && <p className="text-xs text-hint mt-1">{description}</p>}
</div>
);
}

파일 보기

@ -0,0 +1,50 @@
import { useContext, createContext, useState, useEffect, type ReactNode } from 'react';
interface TrkContextValue {
copyMode: boolean;
setCopyMode: (v: boolean) => void;
activeId: string | null;
setActiveId: (id: string | null) => void;
}
// eslint-disable-next-line react-refresh/only-export-components
export const TrkContext = createContext<TrkContextValue | null>(null);
export function TrkProvider({ children }: { children: ReactNode }) {
const [copyMode, setCopyMode] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
// 딥링크 처리: #trk=TRK-BADGE-critical-sm
useEffect(() => {
const applyHash = () => {
const hash = window.location.hash;
const match = hash.match(/#trk=([\w-]+)/);
if (match) {
const id = match[1];
setActiveId(id);
setTimeout(() => {
const el = document.querySelector(`[data-trk="${id}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 50);
}
};
applyHash();
window.addEventListener('hashchange', applyHash);
return () => window.removeEventListener('hashchange', applyHash);
}, []);
return (
<TrkContext.Provider value={{ copyMode, setCopyMode, activeId, setActiveId }}>
{children}
</TrkContext.Provider>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function useTrk() {
const ctx = useContext(TrkContext);
if (!ctx) throw new Error('useTrk must be used within TrkProvider');
return ctx;
}

파일 보기

@ -0,0 +1,114 @@
import { TrkSectionHeader, Trk } from '../lib/Trk';
import { Badge } from '@shared/components/ui/badge';
import type { BadgeIntent, BadgeSize } from '@lib/theme/variants';
const INTENTS: BadgeIntent[] = ['critical', 'high', 'warning', 'info', 'success', 'muted', 'purple', 'cyan'];
const SIZES: BadgeSize[] = ['xs', 'sm', 'md', 'lg'];
const INTENT_USAGE: Record<BadgeIntent, string> = {
critical: '심각 · 긴급 · 위험 (빨강 계열)',
high: '높음 · 경고 (주황 계열)',
warning: '주의 · 보류 (노랑 계열)',
info: '일반 · 정보 (파랑 계열, 기본값)',
success: '성공 · 완료 · 정상 (초록 계열)',
muted: '비활성 · 중립 · 기타 (회색 계열)',
purple: '분석 · AI · 특수 (보라 계열)',
cyan: '모니터링 · 스트림 (청록 계열)',
};
export function BadgeSection() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-badge"
title="Badge"
description="8 intent × 4 size = 32 변형. CVA + cn()로 className override 허용, !important 없음."
/>
{/* 32 변형 그리드 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">32 </h3>
<Trk id="TRK-BADGE-matrix" className="ds-sample">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="text-left text-[10px] text-hint font-mono pb-2 pr-3">intent / size </th>
{SIZES.map((size) => (
<th key={size} className="text-left text-[10px] text-hint font-mono pb-2 px-2">
{size}
</th>
))}
</tr>
</thead>
<tbody>
{INTENTS.map((intent) => (
<tr key={intent}>
<td className="text-[11px] text-label font-mono pr-3 py-1.5">{intent}</td>
{SIZES.map((size) => (
<td key={size} className="px-2 py-1.5">
<Trk id={`TRK-BADGE-${intent}-${size}`} inline>
<Badge intent={intent} size={size}>
{intent.toUpperCase()}
</Badge>
</Trk>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Trk>
{/* intent별 의미 가이드 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Intent </h3>
<div className="ds-grid ds-grid-2">
{INTENTS.map((intent) => (
<Trk key={intent} id={`TRK-BADGE-usage-${intent}`} className="ds-sample">
<div className="flex items-center gap-3">
<Badge intent={intent} size="md">
{intent.toUpperCase()}
</Badge>
<span className="text-xs text-label">{INTENT_USAGE[intent]}</span>
</div>
</Trk>
))}
</div>
{/* 사용 예시 코드 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-BADGE-usage-code" className="ds-sample">
<code className="ds-code">
{`import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
// 카탈로그 API와 결합
<Badge intent={getAlertLevelIntent('CRITICAL')} size="sm">
{getAlertLevelLabel('CRITICAL', t, lang)}
</Badge>
// className override (tailwind-merge가 같은 그룹 충돌 감지)
<Badge intent="info" size="md" className="rounded-full px-3">
</Badge>`}
</code>
</Trk>
{/* 금지 패턴 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-BADGE-antipattern" className="ds-sample border-red-500/30">
<div className="text-xs text-red-400 mb-2"> </div>
<code className="ds-code">
{`// ❌ className 직접 작성 (intent prop 무시)
<Badge className="bg-red-400 text-white text-[11px]">...</Badge>
// ❌ !important 사용
<Badge className="!bg-red-400 !text-slate-900">...</Badge>
// ❌ <div className="bg-red-400 ..."> → Badge 컴포넌트 사용 필수
<div className="inline-flex bg-red-400 text-white rounded px-2"></div>`}
</code>
</Trk>
</>
);
}

파일 보기

@ -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<ButtonVariant, string> = {
primary: '주요 액션 · 기본 CTA (페이지당 1개 권장)',
secondary: '보조 액션 · 툴바 버튼 (기본값)',
ghost: '고요한 액션 · 리스트 행 내부',
outline: '강조 보조 · 필터 활성화 상태',
destructive: '삭제 · 비활성화 등 위험 액션',
};
export function ButtonSection() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-button"
title="Button"
description="5 variant × 3 size = 15 변형. CVA 기반, 직접 className 작성 금지."
/>
{/* 매트릭스 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">15 </h3>
<Trk id="TRK-BUTTON-matrix" className="ds-sample">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="text-left text-[10px] text-hint font-mono pb-2 pr-3">variant / size </th>
{SIZES.map((size) => (
<th key={size} className="text-left text-[10px] text-hint font-mono pb-2 px-3">
{size}
</th>
))}
</tr>
</thead>
<tbody>
{VARIANTS.map((variant) => (
<tr key={variant}>
<td className="text-[11px] text-label font-mono pr-3 py-2">{variant}</td>
{SIZES.map((size) => (
<td key={size} className="px-3 py-2">
<Trk id={`TRK-BUTTON-${variant}-${size}`} inline>
<Button variant={variant} size={size}>
{variant}
</Button>
</Trk>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Trk>
{/* 아이콘 버튼 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-BUTTON-with-icon" className="ds-sample">
<div className="flex flex-wrap items-center gap-3">
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
</Button>
<Button variant="secondary" icon={<Download className="w-4 h-4" />}>
</Button>
<Button variant="outline" icon={<Search className="w-4 h-4" />}>
</Button>
<Button variant="ghost" icon={<Save className="w-4 h-4" />}>
</Button>
<Button variant="destructive" icon={<Trash2 className="w-4 h-4" />}>
</Button>
</div>
</Trk>
{/* 상태 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"></h3>
<Trk id="TRK-BUTTON-states" className="ds-sample">
<div className="flex flex-wrap items-center gap-3">
<Button variant="primary">Normal</Button>
<Button variant="primary" disabled>
Disabled
</Button>
</div>
</Trk>
{/* variant 의미 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Variant </h3>
<div className="ds-grid ds-grid-2">
{VARIANTS.map((variant) => (
<Trk key={variant} id={`TRK-BUTTON-usage-${variant}`} className="ds-sample">
<div className="flex items-center gap-3">
<Button variant={variant} size="sm">
{variant}
</Button>
<span className="text-xs text-label flex-1">{VARIANT_USAGE[variant]}</span>
</div>
</Trk>
))}
</div>
{/* 사용 예시 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-BUTTON-usage-code" className="ds-sample">
<code className="ds-code">
{`import { Button } from '@shared/components/ui/button';
import { Plus } from 'lucide-react';
<Button variant="primary" size="md" icon={<Plus className="w-4 h-4" />}>
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
</Button>
// 금지
// ❌ <button className="bg-blue-600 text-white px-4 py-2 rounded-lg">
// ❌ <Button className="bg-red-500"> → variant="destructive" 사용`}
</code>
</Trk>
</>
);
}

파일 보기

@ -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 (
<>
<TrkSectionHeader
id="TRK-SEC-card"
title="Card · Section"
description="4 variant (default/elevated/inner/transparent). CardHeader+CardTitle+CardContent 조합."
/>
{/* 4 variant */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">Variant</h3>
<div className="ds-grid ds-grid-2">
<Trk id="TRK-CARD-default" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block">variant=default</label>
<Card variant="default">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-1.5">
<Database className="w-3.5 h-3.5 text-blue-400" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5">
<div className="flex justify-between text-[11px]">
<span className="text-hint">PostgreSQL</span>
<span className="text-label">v15.4 </span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-hint"></span>
<span className="text-label">8 / 20</span>
</div>
</CardContent>
</Card>
</Trk>
<Trk id="TRK-CARD-elevated" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block">variant=elevated ()</label>
<Card variant="elevated">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-1.5">
<Activity className="w-3.5 h-3.5 text-green-400" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5">
<div className="flex justify-between text-[11px]">
<span className="text-hint">API</span>
<span className="text-green-400"></span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-hint">Prediction</span>
<span className="text-green-400">5 </span>
</div>
</CardContent>
</Card>
</Trk>
<Trk id="TRK-CARD-inner" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block">variant=inner</label>
<Card variant="inner">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-1.5">
<Bell className="w-3.5 h-3.5 text-yellow-400" />
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-[11px] text-label"> . depth.</p>
</CardContent>
</Card>
</Trk>
<Trk id="TRK-CARD-transparent" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block">variant=transparent</label>
<Card variant="transparent">
<CardHeader className="pb-2">
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<p className="text-[11px] text-label">/ ( ).</p>
</CardContent>
</Card>
</Trk>
</div>
{/* 사용 예시 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-CARD-usage-code" className="ds-sample">
<code className="ds-code">
{`import { Card, CardHeader, CardTitle, CardContent } from '@shared/components/ui/card';
import { Database } from 'lucide-react';
<Card variant="elevated">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-1.5">
<Database className="w-3.5 h-3.5 text-blue-400" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{/* 콘텐츠 */}
</CardContent>
</Card>`}
</code>
</Trk>
</>
);
}

파일 보기

@ -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<string, AnyMeta>;
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 (
<div className="flex flex-wrap gap-2">
{entries.map((meta) => {
const label = getLabel(meta);
const trkId = `${idPrefix}-${meta.code}`;
const content: ReactNode = meta.intent ? (
<Badge intent={meta.intent} size="sm">
{label}
</Badge>
) : (
<span
className={`inline-flex items-center px-2 py-0.5 rounded-md border text-[12px] font-semibold ${getFallbackClasses(meta) ?? 'bg-slate-500/20 text-slate-300 border-slate-500/30'}`}
>
{label}
</span>
);
return (
<Trk key={meta.code} id={trkId} inline>
{content}
</Trk>
);
})}
</div>
);
}
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 (
<>
<TrkSectionHeader
id="TRK-SEC-catalog"
title="분류 카탈로그 (19+)"
description="백엔드 enum/code_master 기반 SSOT. 모든 위험도·상태·유형 배지의 단일 정의."
/>
<Trk id="TRK-CAT-intro" className="ds-sample mb-4">
<p className="text-xs text-label leading-relaxed">
API를 :
</p>
<code className="ds-code mt-2">
{`import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
<Badge intent={getAlertLevelIntent(event.level)} size="sm">
{getAlertLevelLabel(event.level, t, lang)}
</Badge>`}
</code>
</Trk>
{CATALOGS.map((entry) => (
<Trk key={entry.id} id={entry.id} className="ds-sample mb-3">
<div className="mb-2">
<div className="flex items-baseline gap-2">
<h3 className="text-sm font-semibold text-heading">{entry.title}</h3>
<code className="text-[10px] text-hint font-mono">{entry.id}</code>
</div>
<p className="text-[11px] text-hint">{entry.description}</p>
{entry.source && (
<p className="text-[10px] text-hint italic mt-0.5">: {entry.source}</p>
)}
</div>
<CatalogBadges catalog={entry.catalog} idPrefix={entry.id} />
</Trk>
))}
</>
);
}

파일 보기

@ -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 (
<>
<TrkSectionHeader
id="TRK-SEC-form"
title="Form 요소"
description="Input · Select · Textarea · Checkbox · Radio. 공통 스타일 토큰 공유."
/>
{/* Input 사이즈 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">Input · </h3>
<div className="ds-grid ds-grid-3">
{(['sm', 'md', 'lg'] as const).map((size) => (
<Trk key={size} id={`TRK-FORM-input-${size}`} className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">size={size}</label>
<Input size={size} placeholder={`Input size=${size}`} />
</Trk>
))}
</div>
{/* Input 상태 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Input · </h3>
<div className="ds-grid ds-grid-3">
<Trk id="TRK-FORM-input-default" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">state=default</label>
<Input placeholder="기본 상태" />
</Trk>
<Trk id="TRK-FORM-input-error" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">state=error</label>
<Input state="error" defaultValue="잘못된 값" />
</Trk>
<Trk id="TRK-FORM-input-success" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">state=success</label>
<Input state="success" defaultValue="유효한 값" />
</Trk>
<Trk id="TRK-FORM-input-disabled" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">disabled</label>
<Input disabled defaultValue="비활성" />
</Trk>
<Trk id="TRK-FORM-input-readonly" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">readOnly</label>
<Input readOnly defaultValue="읽기 전용" />
</Trk>
<Trk id="TRK-FORM-input-type-number" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">type=number</label>
<Input type="number" defaultValue={42} />
</Trk>
</div>
{/* Select */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Select</h3>
<div className="ds-grid ds-grid-3">
{(['sm', 'md', 'lg'] as const).map((size) => (
<Trk key={size} id={`TRK-FORM-select-${size}`} className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">size={size}</label>
<Select size={size} defaultValue="">
<option value=""> </option>
<option value="CRITICAL">CRITICAL</option>
<option value="HIGH">HIGH</option>
<option value="MEDIUM">MEDIUM</option>
</Select>
</Trk>
))}
</div>
{/* Textarea */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Textarea</h3>
<Trk id="TRK-FORM-textarea" className="ds-sample">
<Textarea placeholder="여러 줄 입력..." rows={3} />
</Trk>
{/* Checkbox */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Checkbox</h3>
<Trk id="TRK-FORM-checkbox" className="ds-sample">
<div className="flex flex-wrap gap-4">
<Checkbox label="옵션 1" defaultChecked />
<Checkbox label="옵션 2" />
<Checkbox label="비활성" disabled />
<Checkbox label="체크됨 비활성" disabled defaultChecked />
</div>
</Trk>
{/* Radio */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Radio</h3>
<Trk id="TRK-FORM-radio" className="ds-sample">
<div className="flex flex-wrap gap-4">
<Radio name="grp" label="선택 A" checked={radio === 'a'} onChange={() => setRadio('a')} />
<Radio name="grp" label="선택 B" checked={radio === 'b'} onChange={() => setRadio('b')} />
<Radio name="grp" label="선택 C" checked={radio === 'c'} onChange={() => setRadio('c')} />
<Radio name="grp" label="비활성" disabled />
</div>
</Trk>
{/* 사용 예시 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-FORM-usage-code" className="ds-sample">
<code className="ds-code">
{`import { Input } from '@shared/components/ui/input';
import { Select } from '@shared/components/ui/select';
import { Checkbox } from '@shared/components/ui/checkbox';
<Input size="md" placeholder="검색어 입력" value={q} onChange={(e) => setQ(e.target.value)} />
<Select size="sm" value={level} onChange={(e) => setLevel(e.target.value)}>
<option value=""> </option>
<option value="CRITICAL">CRITICAL</option>
</Select>
<Checkbox label="활성화" checked={active} onChange={(e) => setActive(e.target.checked)} />`}
</code>
</Trk>
</>
);
}

파일 보기

@ -0,0 +1,130 @@
import { TrkSectionHeader, Trk } from '../lib/Trk';
export function GuideSection() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-guide"
title="예외 처리 · 가이드"
description="공통 패턴에서 벗어날 때의 명시적 규칙"
/>
<Trk id="TRK-GUIDE-fullbleed" className="ds-sample">
<h3 className="text-sm font-semibold text-heading mb-2"> fullBleed를 ?</h3>
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
<li>
<strong className="text-heading"> </strong> (LiveMapView,
VesselDetail)
</li>
<li>
<strong className="text-heading">3 </strong> + +
padding이
</li>
<li>
<strong className="text-heading"> 100% </strong>
</li>
</ul>
<p className="text-xs text-hint mt-2">
: <code className="font-mono">&lt;PageContainer fullBleed&gt;</code> .{' '}
<code className="font-mono">-m-4</code>, <code className="font-mono">-mx-4</code> negative margin .
</p>
</Trk>
<Trk id="TRK-GUIDE-classname-override" className="ds-sample mt-3">
<h3 className="text-sm font-semibold text-heading mb-2"> className override를 ?</h3>
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
<li>
<strong className="text-heading"> </strong> width/margin (
<code className="font-mono">w-48</code>, <code className="font-mono">flex-1</code> )
</li>
<li>
<strong className="text-heading"> </strong> sm/md/lg
</li>
</ul>
<p className="text-xs text-label mt-2">
<strong className="text-green-400">:</strong>{' '}
<code className="font-mono">
&lt;Badge intent="info" className="w-full justify-center"&gt;
</code>
</p>
<p className="text-xs text-label">
<strong className="text-red-400">:</strong>{' '}
<code className="font-mono">&lt;Badge className="bg-red-500 text-white"&gt;</code>{' '}
<span className="text-hint">intent prop을 </span>
</p>
</Trk>
<Trk id="TRK-GUIDE-dynamic-color" className="ds-sample mt-3">
<h3 className="text-sm font-semibold text-heading mb-2"> hex </h3>
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
<li>
DB에서 (: Role.colorHex) <code className="font-mono">style={`{{ background: role.colorHex }}`}</code>{' '}
</li>
<li>
<code className="font-mono">getAlertLevelHex(level)</code> API에서 hex
</li>
<li>
deck.gl RGB , hex
</li>
</ul>
</Trk>
<Trk id="TRK-GUIDE-anti-patterns" className="ds-sample mt-3 border border-red-500/30">
<h3 className="text-sm font-semibold text-red-400 mb-2"> </h3>
<ul className="text-xs text-label space-y-1.5 leading-relaxed">
<li> <code className="font-mono">!important</code> prefix (<code className="font-mono">!bg-red-500</code>)</li>
<li> <code className="font-mono">className="bg-X text-Y"</code> Badge </li>
<li> <code className="font-mono">&lt;button className="bg-blue-600 ..."&gt;</code> Button </li>
<li> <code className="font-mono">&lt;input className="bg-surface ..."&gt;</code> Input </li>
<li> <code className="font-mono">p-4 space-y-5</code> padding PageContainer size </li>
<li> <code className="font-mono">-m-4</code>, <code className="font-mono">-mx-4</code> negative margin fullBleed </li>
<li> <code className="font-mono">const STATUS_COLORS = {`{...}`}</code> shared/constants </li>
<li> <code className="font-mono">date.toLocaleString('ko-KR', ...)</code> <code className="font-mono">formatDateTime</code> </li>
</ul>
</Trk>
<Trk id="TRK-GUIDE-new-page" className="ds-sample mt-3">
<h3 className="text-sm font-semibold text-heading mb-2"> 릿</h3>
<code className="ds-code">
{`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 (
<PageContainer>
<PageHeader
icon={Shield}
iconColor="text-blue-400"
title="새 페이지"
description="페이지 설명"
actions={
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
</Button>
}
/>
<Section title="데이터 목록">
<Badge intent={getAlertLevelIntent('HIGH')} size="sm">
{getAlertLevelLabel('HIGH', t, lang)}
</Badge>
<span className="text-xs text-hint">{formatDateTime(row.createdAt)}</span>
</Section>
</PageContainer>
);
}`}
</code>
</Trk>
</>
);
}

파일 보기

@ -0,0 +1,68 @@
import { TrkSectionHeader, Trk } from '../lib/Trk';
export function IntroSection() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-intro"
title="KCG Design System"
description="kcg-ai-monitoring 프론트엔드의 시각 언어 · 컴포넌트 · 레이아웃 규칙 단일 참조 페이지"
/>
<Trk id="TRK-INTRO-overview" className="ds-sample">
<h3 className="text-sm font-semibold text-heading mb-2"> </h3>
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
<li>
<strong className="text-heading"> </strong>
. `className="bg-red-600"` .
</li>
<li>
<strong className="text-heading"> </strong> · ·
.
</li>
<li>
<strong className="text-heading">ID </strong> <code>TRK-*</code> ID가 ,
· .
</li>
</ul>
</Trk>
<Trk id="TRK-INTRO-howto" className="ds-sample mt-3">
<h3 className="text-sm font-semibold text-heading mb-2"> </h3>
<ol className="text-xs text-label space-y-1.5 list-decimal list-inside leading-relaxed">
<li>
<strong className="text-heading">"ID 복사 모드"</strong> ID가
.
</li>
<li>
URL <code className="font-mono text-blue-400">#trk=TRK-BADGE-critical-sm</code>
+ .
</li>
<li>
<strong className="text-heading">Dark / Light</strong> .
</li>
<li>
. <code>TRK-SEC-*</code> ID가 .
</li>
</ol>
</Trk>
<Trk id="TRK-INTRO-naming" className="ds-sample mt-3">
<h3 className="text-sm font-semibold text-heading mb-2"> ID </h3>
<code className="ds-code">
{`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`}
</code>
</Trk>
</>
);
}

파일 보기

@ -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 (
<>
<TrkSectionHeader
id="TRK-SEC-layout"
title="Layout 컴포넌트"
description="PageContainer · PageHeader · Section. 40+ 페이지 레이아웃 표준."
/>
{/* PageContainer */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">PageContainer</h3>
<div className="ds-grid ds-grid-3">
<Trk id="TRK-LAYOUT-page-container-sm" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">size=sm (p-4 space-y-3)</label>
<div className="border border-dashed border-slate-600/40 rounded">
<PageContainer size="sm">
<div className="h-4 bg-blue-500/30 rounded" />
<div className="h-4 bg-blue-500/30 rounded" />
</PageContainer>
</div>
</Trk>
<Trk id="TRK-LAYOUT-page-container-md" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">size=md · (p-5 space-y-4)</label>
<div className="border border-dashed border-slate-600/40 rounded">
<PageContainer size="md">
<div className="h-4 bg-blue-500/30 rounded" />
<div className="h-4 bg-blue-500/30 rounded" />
</PageContainer>
</div>
</Trk>
<Trk id="TRK-LAYOUT-page-container-lg" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">size=lg (p-6 space-y-4)</label>
<div className="border border-dashed border-slate-600/40 rounded">
<PageContainer size="lg">
<div className="h-4 bg-blue-500/30 rounded" />
<div className="h-4 bg-blue-500/30 rounded" />
</PageContainer>
</div>
</Trk>
</div>
<Trk id="TRK-LAYOUT-page-container-fullbleed" className="ds-sample mt-3">
<label className="text-[10px] text-hint font-mono mb-1 block">
fullBleed / (padding 0, space-y 0)
</label>
<div className="border border-dashed border-slate-600/40 rounded h-16 relative">
<PageContainer fullBleed className="h-full">
<div className="h-full bg-blue-500/30 rounded flex items-center justify-center text-xs text-heading">
fullBleed: 가장자리까지 (LiveMapView / VesselDetail )
</div>
</PageContainer>
</div>
</Trk>
{/* PageHeader */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">PageHeader</h3>
<div className="space-y-3">
<Trk id="TRK-LAYOUT-page-header-simple" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block"> (title + description)</label>
<PageHeader title="대시보드" description="실시간 종합 상황판" />
</Trk>
<Trk id="TRK-LAYOUT-page-header-icon" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block"> </label>
<PageHeader
icon={Shield}
iconColor="text-blue-400"
title="권한 관리"
description="사용자/역할/권한 설정"
/>
</Trk>
<Trk id="TRK-LAYOUT-page-header-demo" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block"> </label>
<PageHeader
icon={BarChart3}
iconColor="text-purple-400"
title="AI 모델 관리"
description="ML 모델 배포 상태"
demo
/>
</Trk>
<Trk id="TRK-LAYOUT-page-header-actions" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block"> </label>
<PageHeader
title="보고서 관리"
description="단속 증거 보고서 목록"
actions={
<>
<Input size="sm" placeholder="검색..." className="w-40" />
<Button variant="secondary" size="sm" icon={<Search className="w-3.5 h-3.5" />}>
</Button>
<Button variant="primary" size="sm" icon={<Plus className="w-3.5 h-3.5" />}>
</Button>
</>
}
/>
</Trk>
</div>
{/* Section */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Section (Card )</h3>
<div className="ds-grid ds-grid-2">
<Trk id="TRK-LAYOUT-section-basic" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block"> Section</label>
<Section title="해역별 위험도" icon={BarChart3} iconColor="text-orange-400">
<div className="space-y-1.5">
<div className="flex justify-between text-[11px]">
<span className="text-hint"></span>
<span className="text-label">23</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-hint"></span>
<span className="text-label">12</span>
</div>
</div>
</Section>
</Trk>
<Trk id="TRK-LAYOUT-section-actions" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block"> </label>
<Section
title="최근 이벤트"
icon={BarChart3}
actions={
<Button variant="ghost" size="sm">
</Button>
}
>
<div className="text-[11px] text-hint"> 3</div>
</Section>
</Trk>
</div>
{/* 전체 조합 예시 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-LAYOUT-full-example" className="ds-sample">
<code className="ds-code">
{`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 (
<PageContainer>
<PageHeader
icon={Shield}
iconColor="text-blue-400"
title="권한 관리"
description="사용자별 역할 및 권한 매핑"
actions={
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
</Button>
}
/>
<Section title="사용자 목록">
<DataTable ... />
</Section>
</PageContainer>
);
}`}
</code>
</Trk>
</>
);
}

파일 보기

@ -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 (
<>
<TrkSectionHeader
id="TRK-SEC-token"
title="테마 · 시맨틱 토큰"
description="모든 색상/여백은 CSS 변수로 관리. 다크/라이트 모드는 값만 바뀌고 이름은 동일."
/>
{/* Surface 토큰 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-4">Surface / </h3>
<div className="ds-grid ds-grid-3">
{SURFACE_TOKENS.map((t) => (
<Trk key={t.id} id={`TRK-TOKEN-${t.id}`} className="ds-sample">
<div
className={`h-16 rounded-md border border-slate-700/40 mb-2`}
style={{ background: `var(--${t.id})` }}
/>
<div className="text-xs text-heading font-semibold">{t.label}</div>
<div className="text-[10px] text-hint">{t.desc}</div>
<div className="ds-sample-label">TRK-TOKEN-{t.id}</div>
</Trk>
))}
</div>
{/* 텍스트 토큰 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Text / </h3>
<div className="ds-grid ds-grid-3">
{TEXT_TOKENS.map((t) => (
<Trk key={t.id} id={`TRK-TOKEN-${t.id}`} className="ds-sample">
<div
className="h-16 rounded-md flex items-center justify-center text-sm font-semibold mb-2"
style={{
background: t.bg ?? 'var(--surface-overlay)',
color: `var(--${t.id})`,
}}
>
{t.example}
</div>
<div className="text-xs text-heading font-semibold">{t.label}</div>
<div className="text-[10px] text-hint">{t.desc}</div>
<div className="ds-sample-label">TRK-TOKEN-{t.id}</div>
</Trk>
))}
</div>
{/* 브랜드 색 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Brand / </h3>
<div className="ds-grid ds-grid-4">
{BRAND_COLORS.map((t) => (
<Trk key={t.id} id={`TRK-TOKEN-${t.id}`} className="ds-sample">
<div
className="h-12 rounded-md border border-slate-700/40 mb-2"
style={{ background: `var(--${t.id})` }}
/>
<div className="text-xs text-heading font-semibold">{t.label}</div>
<div className="text-[10px] text-hint">{t.desc}</div>
</Trk>
))}
</div>
{/* Chart 색 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Chart / </h3>
<Trk id="TRK-TOKEN-chart-palette" className="ds-sample">
<div className="flex gap-2">
{CHART_COLORS.map((t) => (
<div key={t.id} className="flex-1 text-center">
<div
className="h-12 rounded-md border border-slate-700/40 mb-1"
style={{ background: `var(--${t.id})` }}
/>
<div className="text-[10px] text-hint font-mono">{t.label}</div>
</div>
))}
</div>
<div className="ds-sample-label">TRK-TOKEN-chart-palette</div>
</Trk>
{/* Radius 스케일 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Radius / </h3>
<Trk id="TRK-TOKEN-radius-scale" className="ds-sample">
<div className="flex gap-4 items-end">
{RADIUS_SCALE.map((r) => (
<div key={r.id} className="flex-1 text-center">
<div className={`h-16 bg-blue-500 ${r.cls} mb-1`} />
<div className="text-[11px] text-heading">{r.label}</div>
<div className="text-[9px] text-hint font-mono">{r.px}</div>
</div>
))}
</div>
</Trk>
{/* Spacing 스케일 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Spacing / </h3>
<Trk id="TRK-TOKEN-spacing-scale" className="ds-sample">
<div className="space-y-1.5">
{SPACING_SCALE.map((s) => (
<div key={s.id} className="flex items-center gap-3">
<code className="text-[10px] text-hint font-mono w-12">p-{s.id}</code>
<div
className="h-4 bg-blue-500 rounded-sm"
style={{ width: `${s.size}px` }}
/>
<span className="text-[10px] text-hint font-mono">{s.size}px</span>
</div>
))}
</div>
</Trk>
</>
);
}

파일 보기

@ -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 (
<>
<TrkSectionHeader
id="TRK-SEC-typography"
title="타이포그래피"
description="8단계 텍스트 스케일 + 시맨틱 색상 토큰 조합"
/>
<div className="space-y-2">
{TYPE_SCALE.map(({ id, tag: Tag, cls, example }) => (
<Trk key={id} id={`TRK-TYPO-${id}`} className="ds-sample">
<div className="flex items-center justify-between gap-4">
<Tag className={cls}>{example}</Tag>
<code className="text-[10px] text-hint font-mono whitespace-nowrap">{cls}</code>
</div>
</Trk>
))}
</div>
</>
);
}

파일 보기

@ -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(
<StrictMode>
<DesignSystemApp />
</StrictMode>,
);

파일 보기

@ -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';

파일 보기

@ -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<HTMLDivElement> {
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 (
<div className={cn(pageContainerVariants({ size, fullBleed }), className)} {...props}>
{children}
</div>
);
}

파일 보기

@ -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 //
*
* :
* [] + + + ()
* [] (// )
*
* :
* <PageHeader title="대시보드" description="실시간 종합 상황판" />
*
* <PageHeader
* icon={Shield}
* title="권한 관리"
* description="사용자/역할/권한 설정"
* demo
* actions={<Button variant="primary"></Button>}
* />
*/
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 (
<div className={cn('flex items-start justify-between gap-4', className)}>
<div className="min-w-0 flex-1">
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
{Icon && <Icon className={cn('w-5 h-5', iconColor)} />}
{title}
{demo && (
<Badge intent="warning" size="xs" className="font-normal">
</Badge>
)}
</h2>
{description && <p className="text-[11px] text-hint mt-0.5">{description}</p>}
</div>
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
</div>
);
}

파일 보기

@ -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
*
* :
* <Section title="해역별 위험도" icon={BarChart3} iconColor="text-orange-400">
* {...}
* </Section>
*/
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 (
<Card variant={variant} className={className}>
{title && (
<CardHeader className="pb-2 flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-xs text-label flex items-center gap-1.5">
{Icon && <Icon className={cn('w-3.5 h-3.5', iconColor)} />}
{title}
</CardTitle>
{actions && <div className="flex items-center gap-1.5">{actions}</div>}
</CardHeader>
)}
<CardContent className={cn(title ? 'pt-0' : 'pt-4', contentClassName)}>{children}</CardContent>
</Card>
);
}

파일 보기

@ -0,0 +1,3 @@
export { PageContainer, type PageContainerProps } from './PageContainer';
export { PageHeader, type PageHeaderProps } from './PageHeader';
export { Section, type SectionProps } from './Section';

파일 보기

@ -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<HTMLButtonElement> {
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<HTMLButtonElement, ButtonProps>(
({ className, variant, size, icon, trailingIcon, children, ...props }, ref) => {
return (
<button ref={ref} className={cn(buttonVariants({ variant, size }), className)} {...props}>
{icon}
{children}
{trailingIcon}
</button>
);
},
);
Button.displayName = 'Button';

파일 보기

@ -0,0 +1,30 @@
import { forwardRef, type InputHTMLAttributes } from 'react';
import { cn } from '@lib/utils/cn';
export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
label?: string;
}
/** Checkbox — native input에 라벨/스타일만 씌운 가벼운 래퍼 */
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
({ className, label, id, ...props }, ref) => {
const inputId = id ?? `cb-${Math.random().toString(36).slice(2, 8)}`;
return (
<label htmlFor={inputId} className="inline-flex items-center gap-1.5 cursor-pointer select-none">
<input
ref={ref}
id={inputId}
type="checkbox"
className={cn(
'w-3.5 h-3.5 rounded border-slate-600/60 bg-surface-overlay',
'accent-blue-500 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
{label && <span className="text-xs text-label">{label}</span>}
</label>
);
},
);
Checkbox.displayName = 'Checkbox';

파일 보기

@ -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<InputHTMLAttributes<HTMLInputElement>, 'size'> {
size?: InputSize;
state?: InputState;
}
/** Input — 프로젝트 표준 입력 필드 */
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, size, state, type = 'text', ...props }, ref) => {
return (
<input
ref={ref}
type={type}
className={cn(inputVariants({ size, state }), className)}
{...props}
/>
);
},
);
Input.displayName = 'Input';

파일 보기

@ -0,0 +1,29 @@
import { forwardRef, type InputHTMLAttributes } from 'react';
import { cn } from '@lib/utils/cn';
export interface RadioProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
label?: string;
}
export const Radio = forwardRef<HTMLInputElement, RadioProps>(
({ className, label, id, ...props }, ref) => {
const inputId = id ?? `rd-${Math.random().toString(36).slice(2, 8)}`;
return (
<label htmlFor={inputId} className="inline-flex items-center gap-1.5 cursor-pointer select-none">
<input
ref={ref}
id={inputId}
type="radio"
className={cn(
'w-3.5 h-3.5 border-slate-600/60 bg-surface-overlay',
'accent-blue-500 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
{label && <span className="text-xs text-label">{label}</span>}
</label>
);
},
);
Radio.displayName = 'Radio';

파일 보기

@ -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<SelectHTMLAttributes<HTMLSelectElement>, 'size'> {
size?: InputSize;
state?: InputState;
}
/** Select — Input과 동일한 스타일 토큰 공유 */
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className, size, state, children, ...props }, ref) => {
return (
<select
ref={ref}
className={cn(inputVariants({ size, state }), 'cursor-pointer', className)}
{...props}
>
{children}
</select>
);
},
);
Select.displayName = 'Select';

파일 보기

@ -0,0 +1,31 @@
import { forwardRef, type TextareaHTMLAttributes } from 'react';
import { cn } from '@lib/utils/cn';
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
state?: 'default' | 'error' | 'success';
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ 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
ref={ref}
className={cn(
'w-full bg-surface-overlay border rounded-md px-3 py-2 text-sm text-label placeholder:text-hint',
'focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/30',
'transition-colors resize-y min-h-[4rem]',
'disabled:opacity-50 disabled:cursor-not-allowed',
stateClass,
className,
)}
{...props}
/>
);
},
);
Textarea.displayName = 'Textarea';

파일 보기

@ -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'),
},
},
},