/** * Mandu Kitchen DevTools - Panel Container * @version 1.0.3 */ import React, { useState, useCallback } from 'react'; import { colors, typography, spacing, borderRadius, shadows, zIndex, animation, testIds } from '../../../design-tokens'; import type { KitchenState } from '../../state-manager'; export type TabId = 'errors' | 'islands' | 'network' | 'guard' | 'preview'; export interface TabDefinition { id: TabId; label: string; icon: string; testId: string; } export interface PanelContainerProps { state: KitchenState; activeTab: TabId; onTabChange: (tab: TabId) => void; onClose: () => void; onRestart?: () => void; position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; children: React.ReactNode; } export const TABS: TabDefinition[] = [ { id: 'errors', label: 'Issues', icon: 'ERR', testId: testIds.tabErrors }, { id: 'islands', label: 'Client Mounts', icon: 'HYD', testId: testIds.tabIslands }, { id: 'network', label: 'Network', icon: 'NET', testId: testIds.tabNetwork }, { id: 'guard', label: 'Guard', icon: 'GRD', testId: testIds.tabGuard }, { id: 'preview', label: 'Changes', icon: 'CHG', testId: testIds.tabPreview }, ]; const styles = { container: { position: 'fixed' as const, width: '468px', maxWidth: 'calc(100vw - 32px)', maxHeight: '76vh', background: `linear-gradient(180deg, ${colors.background.medium}, ${colors.background.dark})`, border: `1px solid ${colors.background.light}`, borderRadius: '20px', boxShadow: shadows.xl, display: 'flex', flexDirection: 'column' as const, overflow: 'hidden', zIndex: zIndex.devtools, fontFamily: typography.fontFamily.sans, transition: `all ${animation.duration.normal} ${animation.easing.easeOut}`, }, header: { display: 'flex', flexDirection: 'column' as const, gap: spacing.md, padding: `${spacing.md} ${spacing.md} ${spacing.sm}`, borderBottom: `1px solid ${colors.background.light}`, background: 'linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0))', }, headerTop: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: spacing.md, }, title: { display: 'flex', alignItems: 'center', gap: spacing.md, minWidth: 0, }, logo: { display: 'flex', alignItems: 'center', justifyContent: 'center', minWidth: '42px', height: '42px', padding: '0 10px', borderRadius: borderRadius.full, backgroundColor: `${colors.brand.accent}15`, border: `1px solid ${colors.brand.accent}40`, color: colors.brand.accent, fontFamily: typography.fontFamily.mono, fontSize: typography.fontSize.xs, fontWeight: typography.fontWeight.bold, letterSpacing: '0.14em', }, titleGroup: { display: 'flex', flexDirection: 'column' as const, gap: '4px', minWidth: 0, }, titleText: { fontSize: typography.fontSize.md, fontWeight: typography.fontWeight.semibold, color: colors.text.primary, whiteSpace: 'nowrap' as const, overflow: 'hidden', textOverflow: 'ellipsis', }, subtitle: { fontSize: typography.fontSize.xs, color: colors.text.secondary, }, headerActions: { display: 'flex', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' as const, justifyContent: 'flex-end', }, statusBadge: { padding: `${spacing.xs} ${spacing.sm}`, borderRadius: borderRadius.full, fontSize: typography.fontSize.xs, fontWeight: typography.fontWeight.semibold, letterSpacing: '0.08em', textTransform: 'uppercase' as const, // #183: borderColor longhand override 충돌 회피 borderWidth: '1px', borderStyle: 'solid' as const, borderColor: colors.background.light, }, headerButton: { padding: `${spacing.xs} ${spacing.sm}`, borderRadius: borderRadius.full, backgroundColor: colors.background.light, // #183: borderColor longhand override 충돌 회피 borderWidth: '1px', borderStyle: 'solid' as const, borderColor: 'transparent', color: colors.text.secondary, fontSize: typography.fontSize.xs, fontWeight: typography.fontWeight.medium, cursor: 'pointer', transition: `all ${animation.duration.fast}`, }, restartButton: { backgroundColor: colors.background.light, // #183: borderColor longhand override 충돌 회피 borderWidth: '1px', borderStyle: 'solid' as const, borderColor: 'transparent', color: colors.text.secondary, fontSize: typography.fontSize.xs, fontWeight: typography.fontWeight.medium, cursor: 'pointer', padding: `${spacing.xs} ${spacing.sm}`, borderRadius: borderRadius.full, lineHeight: 1, transition: `all ${animation.duration.fast}`, }, closeButton: { backgroundColor: 'transparent', // #183: borderColor longhand override 충돌 회피 borderWidth: '1px', borderStyle: 'solid' as const, borderColor: 'transparent', color: colors.text.secondary, fontSize: typography.fontSize.md, cursor: 'pointer', padding: `${spacing.xs} ${spacing.sm}`, borderRadius: borderRadius.full, lineHeight: 1, transition: `all ${animation.duration.fast}`, }, summaryRow: { display: 'flex', gap: spacing.xs, flexWrap: 'wrap' as const, }, summaryChip: { display: 'inline-flex', alignItems: 'center', gap: spacing.xs, padding: `${spacing.xs} ${spacing.sm}`, borderRadius: borderRadius.full, backgroundColor: 'rgba(255, 255, 255, 0.04)', border: `1px solid rgba(255, 255, 255, 0.06)`, fontSize: typography.fontSize.xs, color: colors.text.secondary, }, summaryValue: { color: colors.text.primary, fontWeight: typography.fontWeight.semibold, }, tabs: { display: 'flex', gap: spacing.xs, padding: `0 ${spacing.md} ${spacing.md}`, borderBottom: `1px solid ${colors.background.light}`, backgroundColor: 'transparent', }, tab: { flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: spacing.xs, padding: `${spacing.sm} ${spacing.sm}`, backgroundColor: 'rgba(255, 255, 255, 0.02)', // #183: borderColor longhand override 충돌 회피 borderWidth: '1px', borderStyle: 'solid' as const, borderColor: 'rgba(255, 255, 255, 0.04)', borderRadius: borderRadius.md, color: colors.text.secondary, fontSize: typography.fontSize.xs, fontWeight: typography.fontWeight.medium, cursor: 'pointer', transition: `all ${animation.duration.fast}`, }, tabActive: { color: colors.text.primary, borderColor: `${colors.brand.accent}55`, backgroundColor: 'rgba(232, 150, 122, 0.14)', boxShadow: 'inset 0 1px 0 rgba(255, 255, 255, 0.04)', }, tabIcon: { padding: '2px 6px', borderRadius: borderRadius.full, backgroundColor: 'rgba(255, 255, 255, 0.06)', fontSize: '10px', fontWeight: typography.fontWeight.bold, letterSpacing: '0.08em', fontFamily: typography.fontFamily.mono, color: colors.text.secondary, }, tabBadge: { minWidth: '18px', height: '18px', padding: '0 4px', borderRadius: borderRadius.full, backgroundColor: colors.semantic.error, color: colors.text.primary, fontSize: typography.fontSize.xs, fontWeight: typography.fontWeight.semibold, display: 'flex', alignItems: 'center', justifyContent: 'center', }, content: { flex: 1, overflow: 'auto', minHeight: '240px', maxHeight: '56vh', }, resizeHandle: { position: 'absolute' as const, width: '100%', height: '4px', cursor: 'ns-resize', backgroundColor: 'transparent', }, } as const; const positionStyles: Record = { 'bottom-right': { bottom: '80px', right: '16px' }, 'bottom-left': { bottom: '80px', left: '16px' }, 'top-right': { top: '16px', right: '16px' }, 'top-left': { top: '16px', left: '16px' }, }; export function PanelContainer({ state, activeTab, onTabChange, onClose, onRestart, position, children, }: PanelContainerProps): React.ReactElement { const [, setIsResizing] = useState(false); const [height] = useState(420); const [hoveredTab, setHoveredTab] = useState(null); const [isCloseHovered, setIsCloseHovered] = useState(false); const [isRestartHovered, setIsRestartHovered] = useState(false); const [isExpandHovered, setIsExpandHovered] = useState(false); const getTabBadgeCount = useCallback((tabId: TabId): number => { switch (tabId) { case 'errors': return state.errors.filter(e => e.severity === 'error' || e.severity === 'critical').length; case 'islands': return Array.from(state.islands.values()).filter(i => i.status === 'error').length; case 'guard': return state.guardViolations.length; case 'preview': return state.recentChanges?.length ?? 0; default: return 0; } }, [state]); const issueCount = getTabBadgeCount('errors'); const islandsCount = state.islands.size; const networkCount = state.networkRequests.size; const guardCount = state.guardViolations.length; const changeCount = state.recentChanges?.length ?? 0; const statusToneMap: Record = { normal: { label: 'Healthy', color: colors.semantic.success, background: `${colors.semantic.success}14`, }, warning: { label: 'Attention', color: colors.semantic.warning, background: `${colors.semantic.warning}14`, }, error: { label: 'Action', color: colors.semantic.error, background: `${colors.semantic.error}14`, }, loading: { label: 'Syncing', color: colors.semantic.info, background: `${colors.semantic.info}14`, }, hmr: { label: 'Updated', color: colors.brand.accent, background: `${colors.brand.accent}14`, }, }; const statusTone = statusToneMap[state.manduState]; const containerStyle: React.CSSProperties = { ...styles.container, ...positionStyles[position], height: `${height}px`, }; return (
{position.startsWith('bottom') && (
setIsResizing(true)} /> )}
MK
Mandu Dev Console {state.hmrConnected ? 'Live runtime diagnostics' : 'Runtime diagnostics'}
{statusTone.label} {onRestart && ( )}
Issues {issueCount} Islands {islandsCount} Requests {networkCount} Guard {guardCount} Changes {changeCount}
{TABS.map((tab) => { const isActive = activeTab === tab.id; const badgeCount = getTabBadgeCount(tab.id); return ( ); })}
{children}
{position.startsWith('top') && (
setIsResizing(true)} /> )}
); }