feat(ui): Midnight Glass v2 전면 리디자인 — 기반+셸+대시보드

- theme.ts 재작성: glass/gradient/glow 토큰군, 기본 dark=Midnight(블루),
  6종 테마 변형 전부 파생 토큰 유지 (테마 시스템 보존)
- DS 리스킨(API 호환): 글래스 카드/버튼, Pretendard 타이포, 소프트 LED
- 신규: GradientWave(canvas 스펙트럼 음파), StatRing(컬러 링 타일)
- 보더리스: frame:false + 커스텀 TitleBar, 네이티브 스크롤바 제거(폭 불변)
  + OverlayScrollbars 오버레이 스크롤
- AppLayout 와이드 사이드바(lucide, 그라디언트 액티브 필), 대시보드 재구축,
  StatusBar(브랜드/로컬시간), Pretendard 번들, i18n +20키(ko/en)
This commit is contained in:
Yun Chan 2026-07-21 20:14:15 +09:00
parent f3ca0eafd4
commit a8a1d6e546
25 changed files with 1638 additions and 960 deletions

View file

@ -1,16 +1,20 @@
// src/renderer/components/AppLayout.tsx
// 시안 A+B 융합: 인스트루먼트 섀시 사이드바 + 콘텐츠 영역
// v2 "Midnight Glass": 보더리스 셸 — 와이드 사이드바(그라디언트 액티브 필) +
// 앰비언트 배경 + 오버레이 스크롤(레이아웃 폭 불변) + 커스텀 타이틀바
import { useState, useEffect } from 'react'
import { Alert, Box, Snackbar, Typography, Tooltip } from '@mui/material'
import DashboardIcon from '@mui/icons-material/Dashboard'
import HistoryIcon from '@mui/icons-material/History'
import MenuBookIcon from '@mui/icons-material/MenuBook'
import ExtensionIcon from '@mui/icons-material/Extension'
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver'
import AutoStoriesIcon from '@mui/icons-material/AutoStories'
import GroupsIcon from '@mui/icons-material/Groups'
import SettingsIcon from '@mui/icons-material/Settings'
import { Alert, Box, Snackbar, Typography } from '@mui/material'
import {
LayoutDashboard,
History as HistoryIcon,
BookOpen,
SquareTerminal,
MessagesSquare,
Library,
Users,
Settings,
} from 'lucide-react'
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
import { Led } from '@d3ro/ui/components/ds'
import { DashboardPage } from '../pages/DashboardPage'
import { HistoryPage } from '../pages/HistoryPage'
@ -23,7 +27,8 @@ import { SettingsModal } from './SettingsModal'
import { LicenseModal } from './LicenseModal'
import { OnboardingModal } from './OnboardingModal'
import { StatusBar } from './StatusBar'
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '@d3ro/ui/theme'
import { TitleBar } from './TitleBar'
import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type { TranslationKey } from '@d3ro/i18n'
import type { LicenseTier } from '@d3ro/core/types'
@ -33,18 +38,19 @@ type Route = 'dashboard' | 'history' | 'dictionary' | 'commands' | 'conversation
interface NavItem {
route: Route
labelKey: TranslationKey
abbr: string
icon: React.ReactElement
}
const ICON_SIZE = 18
const NAV_ITEMS: NavItem[] = [
{ route: 'dashboard', labelKey: 'nav.dashboard', abbr: 'DASH', icon: <DashboardIcon sx={{ fontSize: 20 }} /> },
{ route: 'history', labelKey: 'nav.history', abbr: 'HIST', icon: <HistoryIcon sx={{ fontSize: 20 }} /> },
{ route: 'dictionary', labelKey: 'nav.dictionary', abbr: 'DICT', icon: <MenuBookIcon sx={{ fontSize: 20 }} /> },
{ route: 'commands', labelKey: 'nav.commands', abbr: 'CMD', icon: <ExtensionIcon sx={{ fontSize: 20 }} /> },
{ route: 'conversation', labelKey: 'nav.conversation', abbr: 'TALK', icon: <RecordVoiceOverIcon sx={{ fontSize: 20 }} /> },
{ route: 'knowledge', labelKey: 'nav.knowledge', abbr: 'RAG', icon: <AutoStoriesIcon sx={{ fontSize: 20 }} /> },
{ route: 'meeting', labelKey: 'nav.meeting', abbr: 'MTG', icon: <GroupsIcon sx={{ fontSize: 20 }} /> },
{ route: 'dashboard', labelKey: 'nav.dashboard', icon: <LayoutDashboard size={ICON_SIZE} /> },
{ route: 'history', labelKey: 'nav.history', icon: <HistoryIcon size={ICON_SIZE} /> },
{ route: 'dictionary', labelKey: 'nav.dictionary', icon: <BookOpen size={ICON_SIZE} /> },
{ route: 'commands', labelKey: 'nav.commands', icon: <SquareTerminal size={ICON_SIZE} /> },
{ route: 'conversation', labelKey: 'nav.conversation', icon: <MessagesSquare size={ICON_SIZE} /> },
{ route: 'knowledge', labelKey: 'nav.knowledge', icon: <Library size={ICON_SIZE} /> },
{ route: 'meeting', labelKey: 'nav.meeting', icon: <Users size={ICON_SIZE} /> },
]
function tierToLedColor(tier: LicenseTier): 'amber' | 'green' {
@ -91,6 +97,10 @@ export function AppLayout(): React.ReactElement {
const handleOpenLicenseModal = () => setLicenseModalOpen(true)
window.addEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
// 대시보드 CTA(키 설정/시스템 설정)에서 설정 모달 열기
const handleOpenSettings = () => setSettingsOpen(true)
window.addEventListener('d3ro:open-settings', handleOpenSettings)
// Phase 3.2: Premium LLM fallback/upgrade 이벤트 구독
const unsubFallback = window.electronAPI.llm.premium.onFallback((e) => {
setFallbackMsg(e.reason)
@ -105,88 +115,108 @@ export function AppLayout(): React.ReactElement {
unsubFallback()
unsubUpgradeReq()
window.removeEventListener('d3ro:open-license-modal', handleOpenLicenseModal)
window.removeEventListener('d3ro:open-settings', handleOpenSettings)
}
}, [])
return (
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column', bgcolor: d3roPalette.bg.app }}>
<TitleBar />
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* ── 사이드바: 인스트루먼트 섀시 스타일 ─── */}
{/* ── 사이드바: 와이드 메뉴 (레퍼런스 패턴) ─── */}
<Box
sx={{
width: 72,
width: 168,
flexShrink: 0,
bgcolor: d3roPalette.bg.sidebar,
borderRight: `1px solid ${d3roPalette.border.subtle}`,
borderRight: `1px solid ${d3roPalette.glass.hairline}`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
py: 2,
gap: 1,
px: 1.5,
pt: 3,
pb: 2,
gap: 0.5,
}}
>
{/* 로고 LED — reflects license tier */}
<Box sx={{ mb: 2, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
<Led color={tierToLedColor(currentTier)} pulse={currentTier !== 'free'} size={10} />
{/* 로고 — 그라디언트 브랜드 텍스트 */}
<Box sx={{ px: 1.5, mb: 3, display: 'flex', flexDirection: 'column', gap: 0.25 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color={tierToLedColor(currentTier)} pulse={currentTier !== 'free'} size={8} />
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: '20px',
fontWeight: 800,
letterSpacing: '0.02em',
backgroundImage: d3roPalette.gradient.logo,
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
lineHeight: 1.1,
}}
>
D3RO
</Typography>
</Box>
<Typography
sx={{
fontSize: d3roTypo.micro.size,
fontFamily: d3roFontMono,
letterSpacing: d3roTypo.micro.spacing,
fontFamily: d3roFontSans,
fontSize: '13px',
fontWeight: 500,
letterSpacing: '0.12em',
color: d3roPalette.text.dimLabel,
fontWeight: d3roTypo.micro.weight,
pl: '16px',
}}
>
D3RO
Voice
</Typography>
</Box>
{/* 네비게이션 버튼 */}
{/* 네비게이션 */}
{NAV_ITEMS.map((item) => {
const isActive = currentRoute === item.route
return (
<Tooltip key={item.route} title={t(item.labelKey)} placement="right" arrow>
<Box
onClick={() => setCurrentRoute(item.route)}
<Box
key={item.route}
onClick={() => setCurrentRoute(item.route)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.25,
px: 1.5,
py: 1.25,
borderRadius: d3roRadius.inner,
cursor: 'pointer',
color: isActive ? d3roPalette.text.primary : d3roPalette.text.inactive,
// 액티브: 글래스 필 + 그라디언트 헤어라인 보더 + 소프트 글로우
border: '1px solid transparent',
background: isActive
? `linear-gradient(${d3roPalette.glass.raised}, ${d3roPalette.glass.raised}) padding-box, ${d3roPalette.gradient.accent} border-box`
: 'transparent',
boxShadow: isActive ? d3roShadow.glowSoft : 'none',
transition: 'color 0.15s ease, background-color 0.15s ease',
'&:hover': {
color: d3roPalette.text.primary,
bgcolor: isActive ? undefined : d3roPalette.glass.raised,
},
'& svg': {
color: isActive ? d3roPalette.accent.light : 'inherit',
flexShrink: 0,
},
}}
>
{item.icon}
<Typography
sx={{
width: 48,
height: 48,
borderRadius: d3roRadius.button,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
cursor: 'pointer',
bgcolor: isActive ? d3roPalette.bg.chassis : 'transparent',
boxShadow: isActive
? d3roShadow.buttonPressed
: d3roShadow.buttonRaised,
color: isActive ? d3roPalette.accent.amber : d3roPalette.text.inactive,
transition: 'all 0.05s linear',
transform: isActive ? 'translateY(1px)' : 'none',
'&:active': {
transform: 'translateY(2px)',
boxShadow: d3roShadow.buttonPressed,
},
'&:hover': {
color: isActive ? d3roPalette.accent.amber : d3roPalette.text.hover,
},
fontFamily: d3roFontSans,
fontSize: d3roTypo.compact.size,
fontWeight: isActive ? 600 : 500,
lineHeight: 1,
}}
>
{item.icon}
<Typography
sx={{
fontSize: d3roTypo.nano.size,
fontFamily: d3roFontMono,
fontWeight: d3roTypo.nano.weight,
letterSpacing: d3roTypo.nano.spacing,
}}
>
{item.abbr}
</Typography>
</Box>
</Tooltip>
{t(item.labelKey)}
</Typography>
</Box>
)
})}
@ -194,50 +224,64 @@ export function AppLayout(): React.ReactElement {
<Box sx={{ flex: 1 }} />
{/* Settings */}
<Tooltip title={t('nav.settings')} placement="right" arrow>
<Box
onClick={() => setSettingsOpen(true)}
sx={{
width: 48,
height: 48,
borderRadius: d3roRadius.button,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: d3roPalette.text.inactive,
boxShadow: d3roShadow.buttonRaised,
transition: 'all 0.05s linear',
'&:active': {
transform: 'translateY(2px)',
boxShadow: d3roShadow.buttonPressed,
},
'&:hover': { color: d3roPalette.text.hover },
}}
>
<SettingsIcon sx={{ fontSize: 20 }} />
</Box>
</Tooltip>
<Box
onClick={() => setSettingsOpen(true)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.25,
px: 1.5,
py: 1.25,
borderRadius: d3roRadius.inner,
cursor: 'pointer',
color: d3roPalette.text.inactive,
transition: 'color 0.15s ease, background-color 0.15s ease',
'&:hover': { color: d3roPalette.text.primary, bgcolor: d3roPalette.glass.raised },
}}
>
<Settings size={ICON_SIZE} />
<Typography sx={{ fontFamily: d3roFontSans, fontSize: d3roTypo.compact.size, fontWeight: 500, lineHeight: 1 }}>
{t('nav.settings')}
</Typography>
</Box>
</Box>
{/* ── 콘텐츠 영역 ────────────────────────── */}
{/* ── 콘텐츠 영역: 앰비언트 배경 + 오버레이 스크롤 ── */}
<Box
component="main"
sx={{
flexGrow: 1,
overflow: 'auto',
minWidth: 0,
position: 'relative',
bgcolor: d3roPalette.bg.app,
// 미묘한 방사형 비네팅 (시안 A 배경)
background: `radial-gradient(circle at 50% 30%, ${d3roPalette.bg.chassis} 0%, ${d3roPalette.bg.app} 70%)`,
// 앰비언트 그라디언트 오브 (좌상단 액센트 / 우하단 퍼플 힌트)
background: `
radial-gradient(ellipse 55% 40% at 12% -5%, ${d3roPalette.accent.dim} 0%, transparent 60%),
radial-gradient(ellipse 50% 35% at 95% 105%, ${d3roPalette.tag.purpleBg} 0%, transparent 60%),
${d3roPalette.bg.app}
`,
}}
>
{currentRoute === 'dashboard' && <DashboardPage />}
{currentRoute === 'history' && <HistoryPage />}
{currentRoute === 'dictionary' && <DictionaryPage />}
{currentRoute === 'commands' && <CommandsPage />}
{currentRoute === 'conversation' && <VoiceConversationPage />}
{currentRoute === 'knowledge' && <KnowledgeBasePage />}
{currentRoute === 'meeting' && <MeetingModePage />}
<OverlayScrollbarsComponent
defer
options={{
scrollbars: {
theme: 'os-theme-dark',
autoHide: 'move',
autoHideDelay: 800,
clickScroll: true,
},
}}
style={{ height: '100%', width: '100%' }}
>
{currentRoute === 'dashboard' && <DashboardPage />}
{currentRoute === 'history' && <HistoryPage />}
{currentRoute === 'dictionary' && <DictionaryPage />}
{currentRoute === 'commands' && <CommandsPage />}
{currentRoute === 'conversation' && <VoiceConversationPage />}
{currentRoute === 'knowledge' && <KnowledgeBasePage />}
{currentRoute === 'meeting' && <MeetingModePage />}
</OverlayScrollbarsComponent>
</Box>
</Box>
<StatusBar />

View file

@ -1,22 +1,34 @@
// src/renderer/components/StatusBar.tsx
// 인스트루먼트 섀시 하단 — 각인 스타일 상태 표시 + Ollama 오프라인 넛징
// 타이포 토큰 적용 (d3roTypo SSOT)
// v2 "Midnight Glass" 하단 상태바 — 좌: 브랜드+버전+백엔드 상태, 우: 로컬 시간.
// Ollama 오프라인 넛징 버블 유지.
import { useState, useEffect } from 'react'
import { Box, Typography, Fade, IconButton } from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import { X } from 'lucide-react'
import { Led } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '@d3ro/ui/theme'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '@d3ro/ui/theme'
import { OllamaGuideModal } from './OllamaGuideModal'
import { useI18n } from '@d3ro/i18n'
import type { LLMStatus } from '@d3ro/core/types'
function useClock(): string {
const [now, setNow] = useState(() => new Date())
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(timer)
}, [])
const pad = (n: number): string => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
}
export function StatusBar(): React.ReactElement {
const { t } = useI18n()
const [llmStatus, setLlmStatus] = useState<LLMStatus | null>(null)
const [showNudge, setShowNudge] = useState(false)
const [guideOpen, setGuideOpen] = useState(false)
const [captionActive, setCaptionActive] = useState(false)
const [version, setVersion] = useState('')
const clock = useClock()
// 자막 상태 구독
useEffect(() => {
@ -30,6 +42,9 @@ export function StatusBar(): React.ReactElement {
}, [])
useEffect(() => {
window.electronAPI.system.getVersion().then((r) => {
if (r.success) setVersion(r.data)
})
// 초기 상태 로드 + 이벤트 구독 (폴링 불필요 — onStatusChanged로 실시간 갱신)
window.electronAPI.llm.getStatus().then((r) => {
if (r.success) setLlmStatus(r.data)
@ -60,9 +75,9 @@ export function StatusBar(): React.ReactElement {
gap: 2,
px: 2,
py: 0.5,
borderTop: `1px solid ${d3roPalette.border.subtle}`,
borderTop: `1px solid ${d3roPalette.glass.hairline}`,
bgcolor: d3roPalette.bg.sidebar,
minHeight: 28,
minHeight: 30,
}}
>
{/* 넛징 버블 */}
@ -70,11 +85,11 @@ export function StatusBar(): React.ReactElement {
<Box
sx={{
position: 'absolute',
bottom: 32,
bottom: 34,
left: 8,
bgcolor: d3roPalette.bg.card,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: d3roRadius.button,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
borderRadius: d3roRadius.inner,
px: 2,
py: 1.5,
boxShadow: d3roShadow.tooltip,
@ -88,7 +103,7 @@ export function StatusBar(): React.ReactElement {
width: 12,
height: 12,
bgcolor: d3roPalette.bg.card,
border: `1px solid ${d3roPalette.border.default}`,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
borderTop: 'none',
borderLeft: 'none',
transform: 'rotate(45deg)',
@ -100,7 +115,7 @@ export function StatusBar(): React.ReactElement {
{t('status.nudge.title')}
</Typography>
<IconButton size="small" onClick={() => setShowNudge(false)} sx={{ color: d3roPalette.text.inactive, p: 0, ml: 1 }}>
<CloseIcon sx={{ fontSize: 14 }} />
<X size={14} />
</IconButton>
</Box>
<Typography sx={{ fontSize: d3roTypo.meta.size, color: d3roPalette.text.inactive, mb: 1 }}>
@ -110,7 +125,7 @@ export function StatusBar(): React.ReactElement {
onClick={() => { setGuideOpen(true); setShowNudge(false) }}
sx={{
fontSize: d3roTypo.meta.size,
color: d3roPalette.accent.amber,
color: d3roPalette.accent.light,
fontWeight: d3roTypo.label.weight,
cursor: 'pointer',
'&:hover': { textDecoration: 'underline' },
@ -121,62 +136,66 @@ export function StatusBar(): React.ReactElement {
</Box>
</Fade>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Led color={connected ? 'green' : 'red'} size={6} />
{/* 좌: 브랜드 + 버전 */}
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.75, cursor: connected ? 'default' : 'pointer' }}
onClick={() => !connected && setGuideOpen(true)}
>
<Led color={connected ? 'green' : 'red'} size={7} />
<Typography
onClick={() => !connected && setGuideOpen(true)}
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.engrave.size,
color: connected ? d3roPalette.text.inactive : d3roPalette.tag.red,
letterSpacing: d3roTypo.engrave.spacing,
fontWeight: d3roTypo.engrave.weight,
cursor: connected ? 'default' : 'pointer',
fontFamily: d3roFontSans,
fontSize: d3roTypo.meta.size,
fontWeight: 600,
color: connected ? d3roPalette.text.secondary : d3roPalette.tag.red,
'&:hover': connected ? {} : { textDecoration: 'underline' },
}}
>
{connected ? t('status.ollama') : t('status.offline')}
D3RO Voice
</Typography>
{version && (
<Typography sx={{ fontFamily: d3roFontSans, fontSize: d3roTypo.meta.size, color: d3roPalette.text.dimLabel }}>
v{version}
</Typography>
)}
</Box>
{llmStatus?.activeModel && (
<Typography sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.engrave.size,
fontSize: d3roTypo.meta.size,
color: d3roPalette.text.dimLabel,
letterSpacing: d3roTypo.label.spacing,
}}>
{llmStatus.activeModel.toUpperCase()}
{llmStatus.activeModel}
</Typography>
)}
{/* 자막 상태 표시 */}
{captionActive && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Led color="amber" pulse size={6} />
<Led color="red" pulse size={6} />
<Typography sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.engrave.size,
color: d3roPalette.accent.amber,
letterSpacing: d3roTypo.engrave.spacing,
fontWeight: d3roTypo.engrave.weight,
fontFamily: d3roFontSans,
fontSize: d3roTypo.meta.size,
fontWeight: 600,
color: d3roPalette.tag.red,
}}>
CAPTION
{t('dashboard.caption')}
</Typography>
</Box>
)}
<Box sx={{ flex: 1 }} />
<Typography sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.engrave.size,
color: d3roPalette.text.muted,
letterSpacing: d3roTypo.engrave.spacing,
fontWeight: d3roTypo.engrave.weight,
}}>
PRECISION DATA LINK
</Typography>
{/* 우: 로컬 시간 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: d3roTypo.meta.size, color: d3roPalette.text.dimLabel }}>
{t('status.localTime')}
</Typography>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.meta.size, color: d3roPalette.text.secondary, fontVariantNumeric: 'tabular-nums' }}>
{clock}
</Typography>
</Box>
</Box>
<OllamaGuideModal open={guideOpen} onClose={() => setGuideOpen(false)} />

View file

@ -0,0 +1,97 @@
// src/renderer/components/TitleBar.tsx
// v2 보더리스 UI: 커스텀 타이틀바 — 투명 드래그 스트립 + 우측 윈도우 컨트롤.
// 콘텐츠 위에 오버레이되며(레퍼런스처럼 컨트롤이 콘텐츠 위에 떠 있음),
// 드래그 영역은 -webkit-app-region: drag, 버튼은 no-drag.
import { useState, useEffect, useCallback } from 'react'
import { Box } from '@mui/material'
import { Minus, Square, Copy, X } from 'lucide-react'
import { d3roPalette } from '@d3ro/ui/theme'
const BAR_HEIGHT = 40
export function TitleBar(): React.ReactElement {
const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => {
window.electronAPI.window.isMaximized().then((max) => setIsMaximized(max))
}, [])
const handleMaximize = useCallback(() => {
window.electronAPI.window.maximize()
// 토글 후 상태 재조회 (이벤트 채널이 없어 폴링 1회)
setTimeout(() => {
window.electronAPI.window.isMaximized().then((max) => setIsMaximized(max))
}, 120)
}, [])
return (
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: BAR_HEIGHT,
zIndex: (theme) => theme.zIndex.drawer + 2,
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'flex-start',
WebkitAppRegion: 'drag',
pointerEvents: 'auto',
}}
>
<Box
sx={{
display: 'flex',
WebkitAppRegion: 'no-drag',
m: '6px 8px 0 0',
gap: '2px',
}}
>
<ControlButton onClick={() => window.electronAPI.window.minimize()}>
<Minus size={14} />
</ControlButton>
<ControlButton onClick={handleMaximize}>
{isMaximized ? <Copy size={12} style={{ transform: 'scaleX(-1)' }} /> : <Square size={11} />}
</ControlButton>
<ControlButton danger onClick={() => window.electronAPI.window.close()}>
<X size={14} />
</ControlButton>
</Box>
</Box>
)
}
function ControlButton({
children,
danger = false,
onClick,
}: {
children: React.ReactNode
danger?: boolean
onClick: () => void
}): React.ReactElement {
return (
<Box
onClick={onClick}
sx={{
width: 34,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '8px',
cursor: 'pointer',
color: d3roPalette.text.inactive,
transition: 'background-color 0.12s ease, color 0.12s ease',
'&:hover': {
bgcolor: danger ? d3roPalette.tag.redBg : d3roPalette.glass.raised,
color: danger ? d3roPalette.tag.red : d3roPalette.text.primary,
},
}}
>
{children}
</Box>
)
}