@mandujs/core 0.19.2 → 0.20.0

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,332 +1,485 @@
1
- /**
2
- * Mandu Kitchen DevTools - Panel Container
3
- * @version 1.0.3
4
- */
5
-
6
- import React, { useState, useCallback } from 'react';
7
- import { colors, typography, spacing, borderRadius, shadows, zIndex, animation, testIds } from '../../../design-tokens';
8
- import type { KitchenState } from '../../state-manager';
9
-
10
- // ============================================================================
11
- // Types
12
- // ============================================================================
13
-
14
- export type TabId = 'errors' | 'islands' | 'network' | 'guard' | 'preview';
15
-
16
- export interface TabDefinition {
17
- id: TabId;
18
- label: string;
19
- icon: string;
20
- testId: string;
21
- }
22
-
23
- export interface PanelContainerProps {
24
- state: KitchenState;
25
- activeTab: TabId;
26
- onTabChange: (tab: TabId) => void;
27
- onClose: () => void;
28
- onRestart?: () => void;
29
- position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
30
- children: React.ReactNode;
31
- }
32
-
33
- // ============================================================================
34
- // Constants
35
- // ============================================================================
36
-
37
- export const TABS: TabDefinition[] = [
38
- { id: 'errors', label: '에러', icon: '🔥', testId: testIds.tabErrors },
39
- { id: 'islands', label: 'Islands', icon: '🏝️', testId: testIds.tabIslands },
40
- { id: 'network', label: 'Network', icon: '📡', testId: testIds.tabNetwork },
41
- { id: 'guard', label: 'Guard', icon: '🛡️', testId: testIds.tabGuard },
42
- { id: 'preview', label: 'Preview', icon: '📝', testId: testIds.tabPreview },
43
- ];
44
-
45
- // ============================================================================
46
- // Styles
47
- // ============================================================================
48
-
49
- const styles = {
50
- container: {
51
- position: 'fixed' as const,
52
- width: '420px',
53
- maxHeight: '70vh',
54
- backgroundColor: colors.background.dark,
55
- borderRadius: borderRadius.lg,
56
- boxShadow: shadows.xl,
57
- display: 'flex',
58
- flexDirection: 'column' as const,
59
- overflow: 'hidden',
60
- zIndex: zIndex.devtools,
61
- fontFamily: typography.fontFamily.sans,
62
- transition: `all ${animation.duration.normal} ${animation.easing.easeOut}`,
63
- },
64
- header: {
65
- display: 'flex',
66
- alignItems: 'center',
67
- justifyContent: 'space-between',
68
- padding: `${spacing.sm} ${spacing.md}`,
69
- borderBottom: `1px solid ${colors.background.light}`,
70
- backgroundColor: colors.background.medium,
71
- },
72
- title: {
73
- display: 'flex',
74
- alignItems: 'center',
75
- gap: spacing.sm,
76
- fontSize: typography.fontSize.sm,
77
- fontWeight: typography.fontWeight.semibold,
78
- color: colors.text.primary,
79
- },
80
- logo: {
81
- fontSize: typography.fontSize.lg,
82
- },
83
- headerActions: {
84
- display: 'flex',
85
- alignItems: 'center',
86
- gap: spacing.xs,
87
- },
88
- restartButton: {
89
- background: 'transparent',
90
- border: 'none',
91
- color: colors.text.secondary,
92
- fontSize: typography.fontSize.md,
93
- cursor: 'pointer',
94
- padding: spacing.xs,
95
- borderRadius: borderRadius.sm,
96
- lineHeight: 1,
97
- transition: `all ${animation.duration.fast}`,
98
- },
99
- closeButton: {
100
- background: 'transparent',
101
- border: 'none',
102
- color: colors.text.secondary,
103
- fontSize: typography.fontSize.lg,
104
- cursor: 'pointer',
105
- padding: spacing.xs,
106
- borderRadius: borderRadius.sm,
107
- lineHeight: 1,
108
- transition: `color ${animation.duration.fast}`,
109
- },
110
- tabs: {
111
- display: 'flex',
112
- borderBottom: `1px solid ${colors.background.light}`,
113
- backgroundColor: colors.background.dark,
114
- },
115
- tab: {
116
- flex: 1,
117
- display: 'flex',
118
- alignItems: 'center',
119
- justifyContent: 'center',
120
- gap: spacing.xs,
121
- padding: `${spacing.sm} ${spacing.md}`,
122
- background: 'transparent',
123
- border: 'none',
124
- borderBottom: '2px solid transparent',
125
- color: colors.text.secondary,
126
- fontSize: typography.fontSize.sm,
127
- cursor: 'pointer',
128
- transition: `all ${animation.duration.fast}`,
129
- },
130
- tabActive: {
131
- color: colors.brand.accent,
132
- borderBottomColor: colors.brand.accent,
133
- backgroundColor: 'rgba(232, 150, 122, 0.12)',
134
- },
135
- tabIcon: {
136
- fontSize: typography.fontSize.md,
137
- },
138
- tabBadge: {
139
- minWidth: '18px',
140
- height: '18px',
141
- padding: '0 4px',
142
- borderRadius: borderRadius.full,
143
- backgroundColor: colors.semantic.error,
144
- color: colors.text.primary,
145
- fontSize: typography.fontSize.xs,
146
- fontWeight: typography.fontWeight.semibold,
147
- display: 'flex',
148
- alignItems: 'center',
149
- justifyContent: 'center',
150
- },
151
- content: {
152
- flex: 1,
153
- overflow: 'auto',
154
- minHeight: '200px',
155
- maxHeight: '50vh',
156
- },
157
- resizeHandle: {
158
- position: 'absolute' as const,
159
- width: '100%',
160
- height: '4px',
161
- cursor: 'ns-resize',
162
- backgroundColor: 'transparent',
163
- },
164
- };
165
-
166
- const positionStyles: Record<string, React.CSSProperties> = {
167
- 'bottom-right': { bottom: '80px', right: '16px' },
168
- 'bottom-left': { bottom: '80px', left: '16px' },
169
- 'top-right': { top: '16px', right: '16px' },
170
- 'top-left': { top: '16px', left: '16px' },
171
- };
172
-
173
- // ============================================================================
174
- // Component
175
- // ============================================================================
176
-
177
- export function PanelContainer({
178
- state,
179
- activeTab,
180
- onTabChange,
181
- onClose,
182
- onRestart,
183
- position,
184
- children,
185
- }: PanelContainerProps): React.ReactElement {
186
- const [isResizing, setIsResizing] = useState(false);
187
- const [height, setHeight] = useState(400);
188
- const [hoveredTab, setHoveredTab] = useState<TabId | null>(null);
189
- const [isCloseHovered, setIsCloseHovered] = useState(false);
190
- const [isRestartHovered, setIsRestartHovered] = useState(false);
191
- const [isExpandHovered, setIsExpandHovered] = useState(false);
192
-
193
- const getTabBadgeCount = useCallback((tabId: TabId): number => {
194
- switch (tabId) {
195
- case 'errors':
196
- return state.errors.filter(e => e.severity === 'error' || e.severity === 'critical').length;
197
- case 'islands':
198
- return Array.from(state.islands.values()).filter(i => i.status === 'error').length;
199
- case 'guard':
200
- return state.guardViolations.length;
201
- case 'preview':
202
- return state.recentChanges?.length ?? 0;
203
- default:
204
- return 0;
205
- }
206
- }, [state]);
207
-
208
- const containerStyle: React.CSSProperties = {
209
- ...styles.container,
210
- ...positionStyles[position],
211
- height: `${height}px`,
212
- };
213
-
214
- return (
215
- <div
216
- data-testid={testIds.panel}
217
- style={containerStyle}
218
- >
219
- {/* Resize Handle (top) */}
220
- {position.startsWith('bottom') && (
221
- <div
222
- style={{ ...styles.resizeHandle, top: 0 }}
223
- onMouseDown={() => setIsResizing(true)}
224
- />
225
- )}
226
-
227
- {/* Header */}
228
- <div style={styles.header}>
229
- <div style={styles.title}>
230
- <span style={styles.logo}>🥟</span>
231
- <span>Mandu Kitchen</span>
232
- </div>
233
- <div style={styles.headerActions}>
234
- {onRestart && (
235
- <button
236
- data-testid={testIds.restartButton}
237
- style={{
238
- ...styles.restartButton,
239
- ...(isRestartHovered ? {
240
- color: colors.semantic.warning,
241
- backgroundColor: `${colors.semantic.warning}18`,
242
- } : {}),
243
- }}
244
- onClick={onRestart}
245
- onMouseEnter={() => setIsRestartHovered(true)}
246
- onMouseLeave={() => setIsRestartHovered(false)}
247
- aria-label="캐시 지우고 완전 재시작"
248
- title="캐시 지우고 완전 재시작"
249
- >
250
- 🔄
251
- </button>
252
- )}
253
- <button
254
- style={{
255
- ...styles.restartButton,
256
- ...(isExpandHovered ? {
257
- color: colors.brand.accent,
258
- backgroundColor: `${colors.brand.accent}18`,
259
- } : {}),
260
- }}
261
- onClick={() => window.open('/__kitchen', '_blank')}
262
- onMouseEnter={() => setIsExpandHovered(true)}
263
- onMouseLeave={() => setIsExpandHovered(false)}
264
- aria-label="풀 페이지로 열기"
265
- title="풀 페이지로 열기"
266
- >
267
- 🔲
268
- </button>
269
- <button
270
- style={{
271
- ...styles.closeButton,
272
- ...(isCloseHovered ? { color: colors.text.primary, backgroundColor: 'rgba(255, 255, 255, 0.08)' } : {}),
273
- }}
274
- onClick={onClose}
275
- onMouseEnter={() => setIsCloseHovered(true)}
276
- onMouseLeave={() => setIsCloseHovered(false)}
277
- aria-label="패널 닫기"
278
- >
279
- ×
280
- </button>
281
- </div>
282
- </div>
283
-
284
- {/* Tabs */}
285
- <div style={styles.tabs} role="tablist">
286
- {TABS.map((tab) => {
287
- const isActive = activeTab === tab.id;
288
- const badgeCount = getTabBadgeCount(tab.id);
289
-
290
- return (
291
- <button
292
- key={tab.id}
293
- data-testid={tab.testId}
294
- role="tab"
295
- aria-selected={isActive}
296
- style={{
297
- ...styles.tab,
298
- ...(isActive ? styles.tabActive : {}),
299
- ...(!isActive && hoveredTab === tab.id ? {
300
- color: colors.text.primary,
301
- backgroundColor: 'rgba(255, 255, 255, 0.05)',
302
- } : {}),
303
- }}
304
- onClick={() => onTabChange(tab.id)}
305
- onMouseEnter={() => setHoveredTab(tab.id)}
306
- onMouseLeave={() => setHoveredTab(null)}
307
- >
308
- <span style={styles.tabIcon}>{tab.icon}</span>
309
- <span>{tab.label}</span>
310
- {badgeCount > 0 && (
311
- <span style={styles.tabBadge}>{badgeCount > 99 ? '99+' : badgeCount}</span>
312
- )}
313
- </button>
314
- );
315
- })}
316
- </div>
317
-
318
- {/* Content */}
319
- <div style={styles.content} role="tabpanel">
320
- {children}
321
- </div>
322
-
323
- {/* Resize Handle (bottom) */}
324
- {position.startsWith('top') && (
325
- <div
326
- style={{ ...styles.resizeHandle, bottom: 0 }}
327
- onMouseDown={() => setIsResizing(true)}
328
- />
329
- )}
330
- </div>
331
- );
332
- }
1
+ /**
2
+ * Mandu Kitchen DevTools - Panel Container
3
+ * @version 1.0.3
4
+ */
5
+
6
+ import React, { useState, useCallback } from 'react';
7
+ import { colors, typography, spacing, borderRadius, shadows, zIndex, animation, testIds } from '../../../design-tokens';
8
+ import type { KitchenState } from '../../state-manager';
9
+
10
+ export type TabId = 'errors' | 'islands' | 'network' | 'guard' | 'preview';
11
+
12
+ export interface TabDefinition {
13
+ id: TabId;
14
+ label: string;
15
+ icon: string;
16
+ testId: string;
17
+ }
18
+
19
+ export interface PanelContainerProps {
20
+ state: KitchenState;
21
+ activeTab: TabId;
22
+ onTabChange: (tab: TabId) => void;
23
+ onClose: () => void;
24
+ onRestart?: () => void;
25
+ position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
26
+ children: React.ReactNode;
27
+ }
28
+
29
+ export const TABS: TabDefinition[] = [
30
+ { id: 'errors', label: 'Issues', icon: 'ERR', testId: testIds.tabErrors },
31
+ { id: 'islands', label: 'Islands', icon: 'ISL', testId: testIds.tabIslands },
32
+ { id: 'network', label: 'Network', icon: 'NET', testId: testIds.tabNetwork },
33
+ { id: 'guard', label: 'Guard', icon: 'GRD', testId: testIds.tabGuard },
34
+ { id: 'preview', label: 'Changes', icon: 'CHG', testId: testIds.tabPreview },
35
+ ];
36
+
37
+ const styles = {
38
+ container: {
39
+ position: 'fixed' as const,
40
+ width: '468px',
41
+ maxWidth: 'calc(100vw - 32px)',
42
+ maxHeight: '76vh',
43
+ background: `linear-gradient(180deg, ${colors.background.medium}, ${colors.background.dark})`,
44
+ border: `1px solid ${colors.background.light}`,
45
+ borderRadius: '20px',
46
+ boxShadow: shadows.xl,
47
+ display: 'flex',
48
+ flexDirection: 'column' as const,
49
+ overflow: 'hidden',
50
+ zIndex: zIndex.devtools,
51
+ fontFamily: typography.fontFamily.sans,
52
+ transition: `all ${animation.duration.normal} ${animation.easing.easeOut}`,
53
+ },
54
+ header: {
55
+ display: 'flex',
56
+ flexDirection: 'column' as const,
57
+ gap: spacing.md,
58
+ padding: `${spacing.md} ${spacing.md} ${spacing.sm}`,
59
+ borderBottom: `1px solid ${colors.background.light}`,
60
+ background: 'linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0))',
61
+ },
62
+ headerTop: {
63
+ display: 'flex',
64
+ alignItems: 'flex-start',
65
+ justifyContent: 'space-between',
66
+ gap: spacing.md,
67
+ },
68
+ title: {
69
+ display: 'flex',
70
+ alignItems: 'center',
71
+ gap: spacing.md,
72
+ minWidth: 0,
73
+ },
74
+ logo: {
75
+ display: 'flex',
76
+ alignItems: 'center',
77
+ justifyContent: 'center',
78
+ minWidth: '42px',
79
+ height: '42px',
80
+ padding: '0 10px',
81
+ borderRadius: borderRadius.full,
82
+ backgroundColor: `${colors.brand.accent}15`,
83
+ border: `1px solid ${colors.brand.accent}40`,
84
+ color: colors.brand.accent,
85
+ fontFamily: typography.fontFamily.mono,
86
+ fontSize: typography.fontSize.xs,
87
+ fontWeight: typography.fontWeight.bold,
88
+ letterSpacing: '0.14em',
89
+ },
90
+ titleGroup: {
91
+ display: 'flex',
92
+ flexDirection: 'column' as const,
93
+ gap: '4px',
94
+ minWidth: 0,
95
+ },
96
+ titleText: {
97
+ fontSize: typography.fontSize.md,
98
+ fontWeight: typography.fontWeight.semibold,
99
+ color: colors.text.primary,
100
+ whiteSpace: 'nowrap' as const,
101
+ overflow: 'hidden',
102
+ textOverflow: 'ellipsis',
103
+ },
104
+ subtitle: {
105
+ fontSize: typography.fontSize.xs,
106
+ color: colors.text.secondary,
107
+ },
108
+ headerActions: {
109
+ display: 'flex',
110
+ alignItems: 'center',
111
+ gap: spacing.sm,
112
+ flexWrap: 'wrap' as const,
113
+ justifyContent: 'flex-end',
114
+ },
115
+ statusBadge: {
116
+ padding: `${spacing.xs} ${spacing.sm}`,
117
+ borderRadius: borderRadius.full,
118
+ fontSize: typography.fontSize.xs,
119
+ fontWeight: typography.fontWeight.semibold,
120
+ letterSpacing: '0.08em',
121
+ textTransform: 'uppercase' as const,
122
+ border: `1px solid ${colors.background.light}`,
123
+ },
124
+ headerButton: {
125
+ padding: `${spacing.xs} ${spacing.sm}`,
126
+ borderRadius: borderRadius.full,
127
+ backgroundColor: colors.background.light,
128
+ border: `1px solid transparent`,
129
+ color: colors.text.secondary,
130
+ fontSize: typography.fontSize.xs,
131
+ fontWeight: typography.fontWeight.medium,
132
+ cursor: 'pointer',
133
+ transition: `all ${animation.duration.fast}`,
134
+ },
135
+ restartButton: {
136
+ backgroundColor: colors.background.light,
137
+ border: `1px solid transparent`,
138
+ color: colors.text.secondary,
139
+ fontSize: typography.fontSize.xs,
140
+ fontWeight: typography.fontWeight.medium,
141
+ cursor: 'pointer',
142
+ padding: `${spacing.xs} ${spacing.sm}`,
143
+ borderRadius: borderRadius.full,
144
+ lineHeight: 1,
145
+ transition: `all ${animation.duration.fast}`,
146
+ },
147
+ closeButton: {
148
+ backgroundColor: 'transparent',
149
+ border: `1px solid transparent`,
150
+ color: colors.text.secondary,
151
+ fontSize: typography.fontSize.md,
152
+ cursor: 'pointer',
153
+ padding: `${spacing.xs} ${spacing.sm}`,
154
+ borderRadius: borderRadius.full,
155
+ lineHeight: 1,
156
+ transition: `all ${animation.duration.fast}`,
157
+ },
158
+ summaryRow: {
159
+ display: 'flex',
160
+ gap: spacing.xs,
161
+ flexWrap: 'wrap' as const,
162
+ },
163
+ summaryChip: {
164
+ display: 'inline-flex',
165
+ alignItems: 'center',
166
+ gap: spacing.xs,
167
+ padding: `${spacing.xs} ${spacing.sm}`,
168
+ borderRadius: borderRadius.full,
169
+ backgroundColor: 'rgba(255, 255, 255, 0.04)',
170
+ border: `1px solid rgba(255, 255, 255, 0.06)`,
171
+ fontSize: typography.fontSize.xs,
172
+ color: colors.text.secondary,
173
+ },
174
+ summaryValue: {
175
+ color: colors.text.primary,
176
+ fontWeight: typography.fontWeight.semibold,
177
+ },
178
+ tabs: {
179
+ display: 'flex',
180
+ gap: spacing.xs,
181
+ padding: `0 ${spacing.md} ${spacing.md}`,
182
+ borderBottom: `1px solid ${colors.background.light}`,
183
+ backgroundColor: 'transparent',
184
+ },
185
+ tab: {
186
+ flex: 1,
187
+ display: 'flex',
188
+ alignItems: 'center',
189
+ justifyContent: 'center',
190
+ gap: spacing.xs,
191
+ padding: `${spacing.sm} ${spacing.sm}`,
192
+ backgroundColor: 'rgba(255, 255, 255, 0.02)',
193
+ border: `1px solid rgba(255, 255, 255, 0.04)`,
194
+ borderRadius: borderRadius.md,
195
+ color: colors.text.secondary,
196
+ fontSize: typography.fontSize.xs,
197
+ fontWeight: typography.fontWeight.medium,
198
+ cursor: 'pointer',
199
+ transition: `all ${animation.duration.fast}`,
200
+ },
201
+ tabActive: {
202
+ color: colors.text.primary,
203
+ borderColor: `${colors.brand.accent}55`,
204
+ backgroundColor: 'rgba(232, 150, 122, 0.14)',
205
+ boxShadow: 'inset 0 1px 0 rgba(255, 255, 255, 0.04)',
206
+ },
207
+ tabIcon: {
208
+ padding: '2px 6px',
209
+ borderRadius: borderRadius.full,
210
+ backgroundColor: 'rgba(255, 255, 255, 0.06)',
211
+ fontSize: '10px',
212
+ fontWeight: typography.fontWeight.bold,
213
+ letterSpacing: '0.08em',
214
+ fontFamily: typography.fontFamily.mono,
215
+ color: colors.text.secondary,
216
+ },
217
+ tabBadge: {
218
+ minWidth: '18px',
219
+ height: '18px',
220
+ padding: '0 4px',
221
+ borderRadius: borderRadius.full,
222
+ backgroundColor: colors.semantic.error,
223
+ color: colors.text.primary,
224
+ fontSize: typography.fontSize.xs,
225
+ fontWeight: typography.fontWeight.semibold,
226
+ display: 'flex',
227
+ alignItems: 'center',
228
+ justifyContent: 'center',
229
+ },
230
+ content: {
231
+ flex: 1,
232
+ overflow: 'auto',
233
+ minHeight: '240px',
234
+ maxHeight: '56vh',
235
+ },
236
+ resizeHandle: {
237
+ position: 'absolute' as const,
238
+ width: '100%',
239
+ height: '4px',
240
+ cursor: 'ns-resize',
241
+ backgroundColor: 'transparent',
242
+ },
243
+ } as const;
244
+
245
+ const positionStyles: Record<string, React.CSSProperties> = {
246
+ 'bottom-right': { bottom: '80px', right: '16px' },
247
+ 'bottom-left': { bottom: '80px', left: '16px' },
248
+ 'top-right': { top: '16px', right: '16px' },
249
+ 'top-left': { top: '16px', left: '16px' },
250
+ };
251
+
252
+ export function PanelContainer({
253
+ state,
254
+ activeTab,
255
+ onTabChange,
256
+ onClose,
257
+ onRestart,
258
+ position,
259
+ children,
260
+ }: PanelContainerProps): React.ReactElement {
261
+ const [, setIsResizing] = useState(false);
262
+ const [height] = useState(420);
263
+ const [hoveredTab, setHoveredTab] = useState<TabId | null>(null);
264
+ const [isCloseHovered, setIsCloseHovered] = useState(false);
265
+ const [isRestartHovered, setIsRestartHovered] = useState(false);
266
+ const [isExpandHovered, setIsExpandHovered] = useState(false);
267
+
268
+ const getTabBadgeCount = useCallback((tabId: TabId): number => {
269
+ switch (tabId) {
270
+ case 'errors':
271
+ return state.errors.filter(e => e.severity === 'error' || e.severity === 'critical').length;
272
+ case 'islands':
273
+ return Array.from(state.islands.values()).filter(i => i.status === 'error').length;
274
+ case 'guard':
275
+ return state.guardViolations.length;
276
+ case 'preview':
277
+ return state.recentChanges?.length ?? 0;
278
+ default:
279
+ return 0;
280
+ }
281
+ }, [state]);
282
+
283
+ const issueCount = getTabBadgeCount('errors');
284
+ const islandsCount = state.islands.size;
285
+ const networkCount = state.networkRequests.size;
286
+ const guardCount = state.guardViolations.length;
287
+ const changeCount = state.recentChanges?.length ?? 0;
288
+
289
+ const statusToneMap: Record<KitchenState['manduState'], { label: string; color: string; background: string }> = {
290
+ normal: {
291
+ label: 'Healthy',
292
+ color: colors.semantic.success,
293
+ background: `${colors.semantic.success}14`,
294
+ },
295
+ warning: {
296
+ label: 'Attention',
297
+ color: colors.semantic.warning,
298
+ background: `${colors.semantic.warning}14`,
299
+ },
300
+ error: {
301
+ label: 'Action',
302
+ color: colors.semantic.error,
303
+ background: `${colors.semantic.error}14`,
304
+ },
305
+ loading: {
306
+ label: 'Syncing',
307
+ color: colors.semantic.info,
308
+ background: `${colors.semantic.info}14`,
309
+ },
310
+ hmr: {
311
+ label: 'Updated',
312
+ color: colors.brand.accent,
313
+ background: `${colors.brand.accent}14`,
314
+ },
315
+ };
316
+ const statusTone = statusToneMap[state.manduState];
317
+
318
+ const containerStyle: React.CSSProperties = {
319
+ ...styles.container,
320
+ ...positionStyles[position],
321
+ height: `${height}px`,
322
+ };
323
+
324
+ return (
325
+ <div data-testid={testIds.panel} style={containerStyle}>
326
+ {position.startsWith('bottom') && (
327
+ <div
328
+ style={{ ...styles.resizeHandle, top: 0 }}
329
+ onMouseDown={() => setIsResizing(true)}
330
+ />
331
+ )}
332
+
333
+ <div style={styles.header}>
334
+ <div style={styles.headerTop}>
335
+ <div style={styles.title}>
336
+ <span style={styles.logo}>MK</span>
337
+ <div style={styles.titleGroup}>
338
+ <span style={styles.titleText}>Mandu Dev Console</span>
339
+ <span style={styles.subtitle}>
340
+ {state.hmrConnected ? 'Live runtime diagnostics' : 'Runtime diagnostics'}
341
+ </span>
342
+ </div>
343
+ </div>
344
+ <div style={styles.headerActions}>
345
+ <span
346
+ style={{
347
+ ...styles.statusBadge,
348
+ color: statusTone.color,
349
+ backgroundColor: statusTone.background,
350
+ borderColor: `${statusTone.color}45`,
351
+ }}
352
+ >
353
+ {statusTone.label}
354
+ </span>
355
+ {onRestart && (
356
+ <button
357
+ data-testid={testIds.restartButton}
358
+ style={{
359
+ ...styles.restartButton,
360
+ ...(isRestartHovered ? {
361
+ color: colors.semantic.warning,
362
+ borderColor: `${colors.semantic.warning}40`,
363
+ backgroundColor: `${colors.semantic.warning}16`,
364
+ } : {}),
365
+ }}
366
+ onClick={onRestart}
367
+ onMouseEnter={() => setIsRestartHovered(true)}
368
+ onMouseLeave={() => setIsRestartHovered(false)}
369
+ aria-label="캐시 지우고 완전 재시작"
370
+ title="캐시 지우고 완전 재시작"
371
+ >
372
+ Reset
373
+ </button>
374
+ )}
375
+ <button
376
+ style={{
377
+ ...styles.headerButton,
378
+ ...(isExpandHovered ? {
379
+ color: colors.brand.accent,
380
+ borderColor: `${colors.brand.accent}35`,
381
+ backgroundColor: `${colors.brand.accent}12`,
382
+ } : {}),
383
+ }}
384
+ onClick={() => window.open('/__kitchen', '_blank')}
385
+ onMouseEnter={() => setIsExpandHovered(true)}
386
+ onMouseLeave={() => setIsExpandHovered(false)}
387
+ aria-label="풀 페이지로 열기"
388
+ title="풀 페이지로 열기"
389
+ >
390
+ Open
391
+ </button>
392
+ <button
393
+ style={{
394
+ ...styles.closeButton,
395
+ ...(isCloseHovered ? {
396
+ color: colors.text.primary,
397
+ borderColor: 'rgba(255, 255, 255, 0.12)',
398
+ backgroundColor: 'rgba(255, 255, 255, 0.06)',
399
+ } : {}),
400
+ }}
401
+ onClick={onClose}
402
+ onMouseEnter={() => setIsCloseHovered(true)}
403
+ onMouseLeave={() => setIsCloseHovered(false)}
404
+ aria-label="패널 닫기"
405
+ >
406
+ ×
407
+ </button>
408
+ </div>
409
+ </div>
410
+ <div style={styles.summaryRow}>
411
+ <span style={styles.summaryChip}>
412
+ Issues <span style={styles.summaryValue}>{issueCount}</span>
413
+ </span>
414
+ <span style={styles.summaryChip}>
415
+ Islands <span style={styles.summaryValue}>{islandsCount}</span>
416
+ </span>
417
+ <span style={styles.summaryChip}>
418
+ Requests <span style={styles.summaryValue}>{networkCount}</span>
419
+ </span>
420
+ <span style={styles.summaryChip}>
421
+ Guard <span style={styles.summaryValue}>{guardCount}</span>
422
+ </span>
423
+ <span style={styles.summaryChip}>
424
+ Changes <span style={styles.summaryValue}>{changeCount}</span>
425
+ </span>
426
+ </div>
427
+ </div>
428
+
429
+ <div style={styles.tabs} role="tablist">
430
+ {TABS.map((tab) => {
431
+ const isActive = activeTab === tab.id;
432
+ const badgeCount = getTabBadgeCount(tab.id);
433
+
434
+ return (
435
+ <button
436
+ key={tab.id}
437
+ data-testid={tab.testId}
438
+ role="tab"
439
+ aria-selected={isActive}
440
+ style={{
441
+ ...styles.tab,
442
+ ...(isActive ? styles.tabActive : {}),
443
+ ...(!isActive && hoveredTab === tab.id ? {
444
+ color: colors.text.primary,
445
+ borderColor: 'rgba(255, 255, 255, 0.09)',
446
+ backgroundColor: 'rgba(255, 255, 255, 0.05)',
447
+ } : {}),
448
+ }}
449
+ onClick={() => onTabChange(tab.id)}
450
+ onMouseEnter={() => setHoveredTab(tab.id)}
451
+ onMouseLeave={() => setHoveredTab(null)}
452
+ >
453
+ <span
454
+ style={{
455
+ ...styles.tabIcon,
456
+ ...(isActive ? {
457
+ color: colors.text.primary,
458
+ backgroundColor: 'rgba(255, 255, 255, 0.14)',
459
+ } : {}),
460
+ }}
461
+ >
462
+ {tab.icon}
463
+ </span>
464
+ <span>{tab.label}</span>
465
+ {badgeCount > 0 && (
466
+ <span style={styles.tabBadge}>{badgeCount > 99 ? '99+' : badgeCount}</span>
467
+ )}
468
+ </button>
469
+ );
470
+ })}
471
+ </div>
472
+
473
+ <div style={styles.content} role="tabpanel">
474
+ {children}
475
+ </div>
476
+
477
+ {position.startsWith('top') && (
478
+ <div
479
+ style={{ ...styles.resizeHandle, bottom: 0 }}
480
+ onMouseDown={() => setIsResizing(true)}
481
+ />
482
+ )}
483
+ </div>
484
+ );
485
+ }