atomsystem 0.4.3 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,15 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useState, useRef, useEffect } from 'react';
4
- import { Send, Bot, User, Sparkles, Paperclip, MoreVertical, CheckCheck } from 'lucide-react';
4
+ import { Card, Button, Input, Badge } from '@/components/ui';
5
+ import {
6
+ Send,
7
+ Bot,
8
+ User,
9
+ Paperclip,
10
+ Check,
11
+ Plus,
12
+ } from 'lucide-react';
5
13
 
6
14
  interface ChatMessage {
7
15
  id: string;
@@ -11,10 +19,10 @@ interface ChatMessage {
11
19
  }
12
20
 
13
21
  const QUICK_PROMPTS = [
14
- '🎨 primary 브랜드 색상 변경 방법',
15
- '📐 8pt 그리드 스페이싱 토큰 조회',
16
- '⚡ 다크모드 자동 전환 구현 가이드',
17
- '🚀 0KB 런타임 CSS 내보내기'
22
+ '디자인 토큰 어떻게 설정하나요?',
23
+ '4pt 그리드 스페이싱 규칙',
24
+ '다크모드 컬러 전환',
25
+ '0KB 런타임 CSS 빌드',
18
26
  ];
19
27
 
20
28
  export function InteractiveChatBlock() {
@@ -22,62 +30,59 @@ export function InteractiveChatBlock() {
22
30
  {
23
31
  id: 'm1',
24
32
  sender: 'assistant',
25
- text: '안녕하세요! AtomDesignToken 대화형 어시스턴트입니다. 무엇이든 물어보세요. 디자인 시스템 구축과 토큰 확장을 신속히 도와드립니다.',
26
- timestamp: '오전 10:24',
33
+ text: '안녕하세요. 디자인 토큰 AI 어시스턴트입니다. 색상 시스템, 스페이싱 규격, 레이아웃에 관해 궁금한 점을 질문해보세요.',
34
+ timestamp: '10:00 AM',
27
35
  },
28
36
  {
29
37
  id: 'm2',
30
38
  sender: 'user',
31
- text: '디자인 토큰을 Tailwind v4 프로젝트에 어떻게 연결하나요?',
32
- timestamp: '오전 10:25',
39
+ text: '브랜드 primary 색상 토큰을 어떻게 바꾸나요?',
40
+ timestamp: '10:01 AM',
33
41
  },
34
42
  {
35
43
  id: 'm3',
36
44
  sender: 'assistant',
37
- text: '@import "tailwindcss"; 설정 후 css/atom-ui.css를 최상단에 import하면 모든 --atom-* CSS 변수가 @theme 규칙과 자동 동기화되어 즉시 클래스로 활용할 수 있습니다!',
38
- timestamp: '오전 10:25',
45
+ text: '우측 상단의 테마 에디터를 열거나, :root의 --atom-primary CSS 변수를 원하는 색상 코드로 지정하면 즉시 모든 컴포넌트에 반영됩니다.',
46
+ timestamp: '10:01 AM',
39
47
  },
40
48
  ]);
41
49
 
42
- const [inputValue, setInputValue] = useState('');
50
+ const [inputVal, setInputVal] = useState('');
43
51
  const [isTyping, setIsTyping] = useState(false);
44
- const messagesEndRef = useRef<HTMLDivElement>(null);
45
-
46
- const scrollToBottom = () => {
47
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
48
- };
52
+ const scrollRef = useRef<HTMLDivElement>(null);
49
53
 
50
54
  useEffect(() => {
51
- scrollToBottom();
55
+ if (scrollRef.current) {
56
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
57
+ }
52
58
  }, [messages, isTyping]);
53
59
 
54
- const handleSendMessage = (textToSend?: string) => {
55
- const text = textToSend || inputValue;
60
+ const handleSend = (textToSend?: string) => {
61
+ const text = textToSend || inputVal;
56
62
  if (!text.trim()) return;
57
63
 
58
64
  const now = new Date();
59
- const timeStr = `${now.getHours() < 12 ? '오전' : '오후'} ${now.getHours() % 12 || 12}:${String(now.getMinutes()).padStart(2, '0')}`;
65
+ const timeStr = `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}`;
60
66
 
61
- const newUserMsg: ChatMessage = {
62
- id: `usr-${Date.now()}`,
67
+ const newMsg: ChatMessage = {
68
+ id: `user-${Date.now()}`,
63
69
  sender: 'user',
64
- text: text.trim(),
70
+ text: text,
65
71
  timestamp: timeStr,
66
72
  };
67
73
 
68
- setMessages((prev) => [...prev, newUserMsg]);
69
- if (!textToSend) setInputValue('');
74
+ setMessages((prev) => [...prev, newMsg]);
75
+ if (!textToSend) setInputVal('');
70
76
  setIsTyping(true);
71
77
 
72
- // AI 자동 답변 시뮬레이션
73
78
  setTimeout(() => {
74
- let replyText = '문의해주신 내용에 대한 표준 가이드라인이 준비되었습니다. AtomDesignToken의 W3C DTCG 토큰 명세를 통해 모든 프레임워크에서 100% 일관된 UI를 유지할 수 있습니다.';
75
- if (text.includes('색상') || text.includes('primary')) {
76
- replyText = '브랜드 기본 색상은 `--atom-primary: #3182f6;` 토큰으로 정의되어 있으며, Theme Editor에서 슬라이더나 프리셋을 통해 실시간으로 변경하고 즉시 내보낼 수 있습니다.';
79
+ let replyText = `"${text}"에 대한 디자인 토큰 가이드입니다. AtomDesignToken의 W3C DTCG 표준 규격에 따라 모든 변수는 CSS 변수(--atom-*)로 안전하게 컴파일됩니다.`;
80
+ if (text.includes('primary') || text.includes('색상')) {
81
+ replyText = 'Primary 브랜드 토큰은 css/atom-ui.css의 --atom-primary 변수로 선언되어 있으며, Tailwind v4 및 런타임 없는 순수 CSS로 상속됩니다.';
77
82
  } else if (text.includes('그리드') || text.includes('스페이싱')) {
78
- replyText = '스페이싱 토큰은 4px 기반으로 `--spacing-1: 4px;`, `--spacing-2: 8px;`, `--spacing-4: 16px;` 순으로 균일하게 모듈화되어 있습니다.';
79
- } else if (text.includes('다크모드')) {
80
- replyText = '최상위 html 태그의 `data-theme="dark"` 속성 또는 `.dark` 클래스를 토글하면 모든 시맨틱 색상 토큰이 부드러운 트랜지션과 함께 즉시 반전됩니다.';
83
+ replyText = '스페이싱 토큰은 4px 배수 규격(--atom-space-1: 4px, --atom-space-2: 8px, --atom-space-4: 16px)으로 엄격히 관리됩니다.';
84
+ } else if (text.includes('다크')) {
85
+ replyText = '최상위 html 태그의 data-theme="dark" 속성을 통해 라이트/다크 시맨틱 토큰 매트릭스가 즉시 스위칭됩니다.';
81
86
  }
82
87
 
83
88
  const newBotMsg: ChatMessage = {
@@ -89,145 +94,124 @@ export function InteractiveChatBlock() {
89
94
 
90
95
  setMessages((prev) => [...prev, newBotMsg]);
91
96
  setIsTyping(false);
92
- }, 1100);
97
+ }, 800);
93
98
  };
94
99
 
95
100
  return (
96
- <div className="w-full bg-(--bg-primary) rounded-2xl border border-(--border-subtle) shadow-sm overflow-hidden flex flex-col h-[520px] transition-colors">
97
- {/* Chat Header */}
98
- <div className="px-5 py-3.5 border-b border-(--border-subtle) bg-(--bg-secondary)/50 flex items-center justify-between">
99
- <div className="flex items-center gap-3">
100
- <div className="relative">
101
- <div className="w-9 h-9 rounded-xl bg-(--accent-primary) flex items-center justify-center text-white shadow-xs">
102
- <Bot className="w-5 h-5" />
103
- </div>
104
- <span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-emerald-500 border-2 border-(--bg-primary)" />
101
+ <Card className="flex flex-col h-[480px] overflow-hidden bg-(--bg-primary) border border-(--border-subtle) transition-colors">
102
+ {/* 1. Chat Header */}
103
+ <div className="px-4 py-3 border-b border-(--border-subtle) flex items-center justify-between">
104
+ <div className="flex items-center gap-2">
105
+ <div className="w-7 h-7 rounded-(--radius-sm) bg-(--bg-secondary) border border-(--border-subtle) text-(--text-primary) flex items-center justify-center font-bold text-xs">
106
+ <Bot className="w-3.5 h-3.5" />
105
107
  </div>
106
108
  <div>
107
- <div className="flex items-center gap-1.5">
108
- <h3 className="text-sm font-bold text-(--text-primary)">Atom AI Agent</h3>
109
- <span className="px-1.5 py-0.2 rounded text-[10px] font-semibold bg-(--accent-primary)/10 text-(--accent-primary)">
110
- 온라인
111
- </span>
109
+ <div className="text-xs font-semibold text-(--text-primary)">
110
+ 디자인 토큰 어시스턴트
112
111
  </div>
113
- <p className="text-[11px] text-(--text-muted)">평균 응답 속도 0.8초 · 디자인 시스템 도우미</p>
112
+ <div className="text-[11px] text-(--text-muted)">온디맨드 응답</div>
114
113
  </div>
115
114
  </div>
116
- <div className="flex items-center gap-1">
117
- <button
118
- onClick={() => setMessages(messages.slice(0, 1))}
119
- className="text-xs text-(--text-muted) hover:text-(--text-primary) px-2 py-1 rounded transition-colors"
120
- >
121
- 대화 비우기
122
- </button>
123
- <button className="p-1.5 text-(--text-muted) hover:text-(--text-primary) rounded-lg transition-colors">
124
- <MoreVertical className="w-4 h-4" />
125
- </button>
126
- </div>
115
+
116
+ <Button
117
+ variant="outline"
118
+ size="sm"
119
+ onClick={() => setMessages(messages.slice(0, 1))}
120
+ className="text-xs py-1 px-2.5 h-auto"
121
+ >
122
+ 대화 초기화
123
+ </Button>
127
124
  </div>
128
125
 
129
- {/* Messages Scroll Feed */}
130
- <div className="flex-1 p-4 sm:p-5 overflow-y-auto space-y-4">
126
+ {/* 2. Messages Scroll Area */}
127
+ <div ref={scrollRef} className="flex-1 p-4 overflow-y-auto space-y-3.5 bg-(--bg-primary)">
131
128
  {messages.map((m) => {
132
129
  const isUser = m.sender === 'user';
133
130
  return (
134
131
  <div
135
132
  key={m.id}
136
- className={`flex items-end gap-2.5 ${isUser ? 'flex-row-reverse' : 'flex-row'}`}
133
+ className={`flex items-start gap-2.5 ${isUser ? 'flex-row-reverse' : 'flex-row'}`}
137
134
  >
138
- {/* Avatar */}
139
135
  <div
140
- className={`w-7 h-7 rounded-lg flex items-center justify-center text-xs shrink-0 ${
136
+ className={`w-6 h-6 rounded-(--radius-sm) flex items-center justify-center text-xs shrink-0 ${
141
137
  isUser
142
- ? 'bg-(--bg-secondary) text-(--text-secondary) border border-(--border-subtle)'
143
- : 'bg-(--accent-primary)/15 text-(--accent-primary)'
138
+ ? 'bg-(--text-primary) text-(--bg-primary)'
139
+ : 'bg-(--bg-secondary) text-(--text-primary) border border-(--border-subtle)'
144
140
  }`}
145
141
  >
146
- {isUser ? <User className="w-4 h-4" /> : <Sparkles className="w-4 h-4" />}
142
+ {isUser ? <User className="w-3 h-3" /> : <Bot className="w-3 h-3" />}
147
143
  </div>
148
144
 
149
- {/* Message Bubble */}
150
- <div className={`max-w-[78%] flex flex-col ${isUser ? 'items-end' : 'items-start'}`}>
145
+ <div className={`max-w-[75%] space-y-1 ${isUser ? 'items-end' : 'items-start'}`}>
151
146
  <div
152
- className={`px-4 py-2.5 rounded-2xl text-xs sm:text-sm leading-relaxed ${
147
+ className={`p-3 rounded-(--radius) text-xs leading-relaxed ${
153
148
  isUser
154
- ? 'bg-(--accent-primary) text-white rounded-br-xs shadow-xs'
155
- : 'bg-(--bg-secondary) text-(--text-primary) border border-(--border-subtle) rounded-bl-xs'
149
+ ? 'bg-(--text-primary) text-(--bg-primary) font-medium'
150
+ : 'bg-(--bg-secondary) text-(--text-primary) border border-(--border-subtle)'
156
151
  }`}
157
152
  >
158
153
  {m.text}
159
154
  </div>
160
- <div className="flex items-center gap-1 mt-1 text-[10px] text-(--text-muted)">
161
- <span>{m.timestamp}</span>
162
- {isUser && <CheckCheck className="w-3 h-3 text-(--accent-primary)" />}
155
+ <div className={`text-[11px] text-(--text-muted) px-1 ${isUser ? 'text-right' : 'text-left'}`}>
156
+ {m.timestamp}
163
157
  </div>
164
158
  </div>
165
159
  </div>
166
160
  );
167
161
  })}
168
162
 
169
- {/* Typing Pulse Indicator */}
170
163
  {isTyping && (
171
- <div className="flex items-center gap-2.5">
172
- <div className="w-7 h-7 rounded-lg bg-(--accent-primary)/15 text-(--accent-primary) flex items-center justify-center shrink-0">
173
- <Sparkles className="w-4 h-4" />
174
- </div>
175
- <div className="bg-(--bg-secondary) border border-(--border-subtle) px-3 py-2 rounded-2xl rounded-bl-xs flex items-center gap-1">
176
- <span className="w-2 h-2 rounded-full bg-(--accent-primary) animate-bounce" style={{ animationDelay: '0ms' }} />
177
- <span className="w-2 h-2 rounded-full bg-(--accent-primary) animate-bounce" style={{ animationDelay: '150ms' }} />
178
- <span className="w-2 h-2 rounded-full bg-(--accent-primary) animate-bounce" style={{ animationDelay: '300ms' }} />
179
- </div>
164
+ <div className="flex items-center gap-2 text-xs text-(--text-muted)">
165
+ <Bot className="w-3.5 h-3.5" />
166
+ <span>확인 중...</span>
180
167
  </div>
181
168
  )}
182
- <div ref={messagesEndRef} />
183
169
  </div>
184
170
 
185
- {/* Quick Prompts Chips */}
186
- <div className="px-4 py-2 bg-(--bg-primary) border-t border-(--border-subtle) flex items-center gap-1.5 overflow-x-auto scrollbar-none">
187
- {QUICK_PROMPTS.map((prompt, idx) => (
188
- <button
171
+ {/* 3. Quick Suggestion Pills */}
172
+ <div className="px-3 py-2 border-t border-(--border-subtle) flex items-center gap-1.5 overflow-x-auto scrollbar-none">
173
+ {QUICK_PROMPTS.map((q, idx) => (
174
+ <Button
189
175
  key={idx}
190
- onClick={() => handleSendMessage(prompt)}
191
- className="shrink-0 text-[11px] font-medium px-2.5 py-1 rounded-full bg-(--bg-secondary) hover:bg-(--border-subtle) text-(--text-secondary) hover:text-(--text-primary) border border-(--border-subtle) transition-colors"
176
+ variant="outline"
177
+ size="sm"
178
+ onClick={() => handleSend(q)}
179
+ className="shrink-0 text-xs py-1 px-2.5 h-auto whitespace-nowrap"
192
180
  >
193
- {prompt}
194
- </button>
181
+ {q}
182
+ </Button>
195
183
  ))}
196
184
  </div>
197
185
 
198
- {/* Input Bar */}
199
- <div className="p-3 sm:p-4 bg-(--bg-primary) border-t border-(--border-subtle)">
200
- <form
201
- onSubmit={(e) => {
202
- e.preventDefault();
203
- handleSendMessage();
204
- }}
205
- className="flex items-center gap-2"
206
- >
207
- <button
208
- type="button"
209
- className="p-2 text-(--text-muted) hover:text-(--text-primary) hover:bg-(--bg-secondary) rounded-xl transition-colors shrink-0"
210
- title="첨부 파일"
211
- >
212
- <Paperclip className="w-4 h-4" />
213
- </button>
186
+ {/* 4. Bottom Input Bar composed with Input & Button */}
187
+ <div className="p-3 border-t border-(--border-subtle) bg-(--bg-primary)">
188
+ <div className="flex items-center gap-2">
214
189
  <input
215
190
  type="text"
216
- value={inputValue}
217
- onChange={(e) => setInputValue(e.target.value)}
218
- placeholder="AI 에이전트에게 메시지를 입력하세요 (Enter 전송)..."
219
- className="flex-1 text-xs sm:text-sm px-3.5 py-2.5 rounded-xl bg-(--bg-secondary) text-(--text-primary) border border-(--border-subtle) focus:outline-none focus:border-(--accent-primary) placeholder:text-(--text-muted) transition-colors"
191
+ value={inputVal}
192
+ onChange={(e) => setInputVal(e.target.value)}
193
+ onKeyDown={(e) => {
194
+ if (e.key === 'Enter') {
195
+ e.preventDefault();
196
+ handleSend();
197
+ }
198
+ }}
199
+ placeholder="디자인 시스템이나 토큰 규칙에 관해 질문하세요..."
200
+ className="flex-1 text-xs px-3 py-2 rounded-(--radius-sm) bg-(--bg-secondary) text-(--text-primary) border border-(--border-subtle) focus:outline-none focus:border-(--border-strong) transition-colors"
220
201
  />
221
- <button
222
- type="submit"
223
- disabled={!inputValue.trim()}
224
- className="p-2.5 rounded-xl bg-(--accent-primary) hover:bg-(--accent-hover) disabled:opacity-40 disabled:hover:bg-(--accent-primary) text-white font-medium shadow-xs transition-colors shrink-0"
202
+ <Button
203
+ type="button"
204
+ variant="primary"
205
+ size="sm"
206
+ onClick={() => handleSend()}
207
+ aria-label="전송"
225
208
  title="전송"
209
+ className="p-2 h-auto"
226
210
  >
227
- <Send className="w-4 h-4" />
228
- </button>
229
- </form>
211
+ <Send className="w-3.5 h-3.5" />
212
+ </Button>
213
+ </div>
230
214
  </div>
231
- </div>
215
+ </Card>
232
216
  );
233
217
  }
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import React from 'react';
4
+ import { Card, Badge } from '@/components/ui';
4
5
  import {
5
6
  TrendingUp,
6
7
  TrendingDown,
@@ -8,7 +9,6 @@ import {
8
9
  Users,
9
10
  Target,
10
11
  Server,
11
- ArrowUpRight,
12
12
  } from 'lucide-react';
13
13
 
14
14
  interface MetricItem {
@@ -25,9 +25,9 @@ interface MetricItem {
25
25
  const METRICS: MetricItem[] = [
26
26
  {
27
27
  id: 'm-arr',
28
- label: '연간 반복 매출 (ARR)',
29
- subLabel: '전체 유료 구독 합산',
30
- value: '₩142,850,000',
28
+ label: '월간 반복 매출 (MRR)',
29
+ subLabel: '구독 플랜 총액',
30
+ value: '₩48,250,000',
31
31
  change: '+18.4%',
32
32
  isPositive: true,
33
33
  icon: 'dollar',
@@ -35,9 +35,9 @@ const METRICS: MetricItem[] = [
35
35
  },
36
36
  {
37
37
  id: 'm-mau',
38
- label: '월간 활성 개발자 (MAU)',
39
- subLabel: '토큰 CLI 호출 계정',
40
- value: '49,210 명',
38
+ label: '활성 개발자 (MAU)',
39
+ subLabel: '토큰 CLI 활성 호출자',
40
+ value: '49,210명',
41
41
  change: '+12.6%',
42
42
  isPositive: true,
43
43
  icon: 'users',
@@ -45,23 +45,23 @@ const METRICS: MetricItem[] = [
45
45
  },
46
46
  {
47
47
  id: 'm-cvr',
48
- label: '체험 가입 전환율 (CVR)',
49
- subLabel: '스타터 → 프로 전환',
50
- value: '4.92 %',
48
+ label: '유료 플랜 전환율',
49
+ subLabel: '무료에서 Pro 업그레이드',
50
+ value: '4.92%',
51
51
  change: '-0.3%',
52
52
  isPositive: false,
53
53
  icon: 'target',
54
- sparkline: [60, 58, 62, 54, 52, 55, 48, 50],
54
+ sparkline: [60, 58, 62, 55, 50, 52, 48, 45],
55
55
  },
56
56
  {
57
- id: 'm-sla',
58
- label: 'API 응답 신뢰도 & 가동률',
59
- subLabel: '글로벌 엣지 네트워크',
60
- value: '99.99 %',
61
- change: '+0.01%',
57
+ id: 'm-uptime',
58
+ label: 'API 가동률 & SLA',
59
+ subLabel: '엣지 컴파일 노드 헬스',
60
+ value: '99.98%',
61
+ change: '+0.02%',
62
62
  isPositive: true,
63
63
  icon: 'server',
64
- sparkline: [98, 99, 99, 100, 99, 100, 100, 100],
64
+ sparkline: [95, 96, 98, 97, 99, 99, 100, 100],
65
65
  },
66
66
  ];
67
67
 
@@ -69,110 +69,82 @@ export function MetricStatsBlock() {
69
69
  const getIcon = (type: MetricItem['icon']) => {
70
70
  switch (type) {
71
71
  case 'dollar':
72
- return <DollarSign className="w-4 h-4 text-emerald-500" />;
72
+ return <DollarSign className="w-3.5 h-3.5 text-(--accent-primary)" />;
73
73
  case 'users':
74
- return <Users className="w-4 h-4 text-blue-500" />;
74
+ return <Users className="w-3.5 h-3.5 text-blue-500" />;
75
75
  case 'target':
76
- return <Target className="w-4 h-4 text-amber-500" />;
76
+ return <Target className="w-3.5 h-3.5 text-amber-500" />;
77
77
  case 'server':
78
- return <Server className="w-4 h-4 text-purple-500" />;
78
+ return <Server className="w-3.5 h-3.5 text-emerald-500" />;
79
79
  }
80
80
  };
81
81
 
82
- const renderSparkline = (points: number[], isPositive: boolean) => {
83
- const min = Math.min(...points);
84
- const max = Math.max(...points);
85
- const range = max - min || 1;
86
- const width = 80;
87
- const height = 28;
88
-
89
- const coords = points.map((p, idx) => {
90
- const x = (idx / (points.length - 1)) * width;
91
- const y = height - ((p - min) / range) * (height - 6) - 3;
92
- return `${idx === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`;
93
- }).join(' ');
94
-
95
- const strokeColor = isPositive ? 'var(--atom-success, #10b981)' : 'var(--atom-destructive, #f04452)';
96
-
97
- return (
98
- <svg width={width} height={height} className="overflow-visible">
99
- <path
100
- d={coords}
101
- fill="none"
102
- stroke={strokeColor}
103
- strokeWidth="2"
104
- strokeLinecap="round"
105
- strokeLinejoin="round"
106
- />
107
- </svg>
108
- );
109
- };
110
-
111
82
  return (
112
- <div className="w-full bg-(--bg-primary) rounded-2xl border border-(--border-subtle) shadow-sm p-5 sm:p-6 transition-colors">
113
- {/* Top Header */}
114
- <div className="flex flex-col sm:flex-row sm:items-center justify-between pb-4 border-b border-(--border-subtle) mb-5">
115
- <div>
116
- <span className="text-[11px] font-semibold text-(--accent-primary) uppercase tracking-wider">
117
- 경영 & 인프라 텔레메트리
118
- </span>
119
- <h3 className="text-lg font-bold text-(--text-primary) mt-0.5">
120
- 4대 핵심 비즈니스 KPI 지표 대시보드
121
- </h3>
122
- </div>
123
- <div className="text-[11px] text-(--text-muted) flex items-center gap-1.5 mt-2 sm:mt-0">
124
- <span className="w-2 h-2 rounded-full bg-emerald-500" />
125
- <span>실시간 동기화 상태: 정상 (Latency 14ms)</span>
126
- </div>
127
- </div>
83
+ <Card className="overflow-hidden transition-colors">
84
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 divide-y sm:divide-y-0 sm:divide-x divide-(--border-subtle)">
85
+ {METRICS.map((m) => {
86
+ const maxVal = Math.max(...m.sparkline);
87
+ const minVal = Math.min(...m.sparkline);
88
+ const range = maxVal - minVal || 1;
128
89
 
129
- {/* 4 Cards Grid */}
130
- <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
131
- {METRICS.map((metric) => (
132
- <div
133
- key={metric.id}
134
- className="p-4 rounded-xl bg-(--bg-secondary)/50 hover:bg-(--bg-secondary) border border-(--border-subtle) hover:border-(--border-strong) transition-all duration-200 shadow-xs flex flex-col justify-between"
135
- >
136
- <div>
137
- {/* Header inside card */}
138
- <div className="flex items-center justify-between mb-2">
139
- <div className="p-2 rounded-lg bg-(--bg-primary) border border-(--border-subtle)">
140
- {getIcon(metric.icon)}
141
- </div>
142
- <div
143
- className={`inline-flex items-center gap-0.5 text-xs font-bold px-2 py-0.5 rounded-md ${
144
- metric.isPositive
145
- ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
146
- : 'bg-rose-500/10 text-rose-600 dark:text-rose-400'
147
- }`}
148
- >
149
- {metric.isPositive ? (
150
- <TrendingUp className="w-3 h-3" />
151
- ) : (
152
- <TrendingDown className="w-3 h-3" />
153
- )}
154
- <span>{metric.change}</span>
90
+ const points = m.sparkline
91
+ .map((val, idx) => {
92
+ const x = (idx / (m.sparkline.length - 1)) * 70;
93
+ const y = 24 - ((val - minVal) / range) * 20;
94
+ return `${x},${y}`;
95
+ })
96
+ .join(' ');
97
+
98
+ return (
99
+ <div key={m.id} className="p-4 sm:p-5 flex flex-col justify-between space-y-3">
100
+ <div className="flex items-center justify-between">
101
+ <div className="flex items-center gap-2">
102
+ <div className="p-1.5 rounded-(--radius-sm) bg-(--bg-secondary) border border-(--border-subtle)">
103
+ {getIcon(m.icon)}
104
+ </div>
105
+ <span className="text-xs text-(--text-secondary) font-medium">{m.label}</span>
155
106
  </div>
156
107
  </div>
157
108
 
158
- {/* Title & Sub */}
159
- <div className="text-xs font-semibold text-(--text-secondary)">{metric.label}</div>
160
- <div className="text-[10px] text-(--text-muted) mb-2">{metric.subLabel}</div>
109
+ <div className="flex items-baseline justify-between">
110
+ <div>
111
+ <div className="text-xl font-bold tracking-tight text-(--text-primary)">
112
+ {m.value}
113
+ </div>
114
+ <div className="text-[11px] text-(--text-secondary) mt-0.5">{m.subLabel}</div>
115
+ </div>
161
116
 
162
- {/* Value */}
163
- <div className="text-xl sm:text-2xl font-black text-(--text-primary) tracking-tight">
164
- {metric.value}
117
+ {/* Mini SVG Sparkline */}
118
+ <div className="w-18 h-7">
119
+ <svg className="w-full h-full overflow-visible" viewBox="0 0 70 24">
120
+ <polyline
121
+ fill="none"
122
+ stroke="currentColor"
123
+ strokeWidth="1.5"
124
+ strokeLinecap="round"
125
+ strokeLinejoin="round"
126
+ points={points}
127
+ className={m.isPositive ? 'text-emerald-500' : 'text-rose-500'}
128
+ />
129
+ </svg>
130
+ </div>
165
131
  </div>
166
- </div>
167
132
 
168
- {/* Sparkline & Footer */}
169
- <div className="flex items-end justify-between pt-4 mt-2 border-t border-(--border-subtle)">
170
- <span className="text-[10px] text-(--text-muted)">지난 30일 추이</span>
171
- <div>{renderSparkline(metric.sparkline, metric.isPositive)}</div>
133
+ <div className="pt-2 border-t border-(--border-subtle) flex items-center justify-between text-[11px]">
134
+ <span className="text-(--text-secondary)">전월 동기 대비</span>
135
+ <span
136
+ className={`font-semibold flex items-center gap-0.5 ${
137
+ m.isPositive ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'
138
+ }`}
139
+ >
140
+ {m.isPositive ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
141
+ <span>{m.change}</span>
142
+ </span>
143
+ </div>
172
144
  </div>
173
- </div>
174
- ))}
145
+ );
146
+ })}
175
147
  </div>
176
- </div>
148
+ </Card>
177
149
  );
178
150
  }