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

@ -43,7 +43,6 @@
"@d3ro/i18n": "*",
"@d3ro/ui": "*",
"@electron-toolkit/preload": "^3.0.2",
"@supabase/supabase-js": "^2.45.0",
"@electron-toolkit/utils": "^4.0.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
@ -51,6 +50,7 @@
"@mui/icons-material": "^7.0.0",
"@mui/material": "^7.0.0",
"@nut-tree-fork/nut-js": "^4.2.6",
"@supabase/supabase-js": "^2.45.0",
"better-sqlite3": "^12.8.0",
"docx": "^9.6.1",
"drizzle-orm": "^0.45.2",
@ -59,7 +59,10 @@
"electron-store": "^10.0.0",
"electron-updater": "^6.3.9",
"fluent-ffmpeg": "^2.1.3",
"lucide-react": "^1.25.0",
"node-record-lpcm16": "^1.0.1",
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-react": "^0.5.6",
"pdf-parse": "^2.4.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View file

@ -50,6 +50,10 @@ export function createMainWindow(): BrowserWindow {
minHeight: 600,
show: false,
autoHideMenuBar: true,
// v2 보더리스 UI — 렌더러 TitleBar가 드래그/컨트롤 담당
frame: false,
// 첫 페인트 전 플래시 방지 (렌더러 테마 로드 전 임시 배경 — Midnight bg.app)
backgroundColor: '#0a0e1c',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,

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>
)
}

View file

@ -2,6 +2,8 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import 'overlayscrollbars/overlayscrollbars.css'
import './styles/global.css'
import { App } from './App'
const root = document.getElementById('root')

View file

@ -1,34 +1,131 @@
// src/renderer/pages/DashboardPage.tsx
// 레퍼런스(Meteorological Instrument) 스타일 대시보드:
// ScreenPanel 히어로 + 스탯 카드 + CRT 서비스 상태 + 히스토리
// v2 "Midnight Glass" 대시보드 — 레퍼런스 시안 재현:
// 히어로(웨이브+CTA) / 컬러 링 스탯 4타일 / 백엔드·요금제 / 사용량·실시간 자막 /
// 시스템 상태(웨이브+레벨미터) / 파일 전사 / 최근 히스토리
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Box } from '@mui/material'
import { CrtDisplay, InstrumentPanel, Led, MetalCard, PhosphorText, ScreenPanel, ButtonGroup, PhysicalButton } from '@d3ro/ui/components/ds'
import { Box, Button, Typography } from '@mui/material'
import {
Mic,
BookOpen,
CalendarDays,
Flame,
Settings2,
KeyRound,
BarChart3,
Star,
Check,
Cpu,
} from 'lucide-react'
import { GradientWave, Led, MetalCard, PhosphorText, PhysicalButton, StatRing } from '@d3ro/ui/components/ds'
import { EmptyStateCard, HistoryEntryCard } from '../components/shared'
import { d3roPalette, d3roTypo } from '@d3ro/ui/theme'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roTypo, typoSx } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { formatRecordingTime, formatRecordingTimeUnit, formatNumber, getDateKey } from '../utils/formatters'
import { formatHotkeyLabel } from '../utils/format-hotkey'
import { FileDropZone } from '../components/FileDropZone'
import { PREMIUM_MODEL_LIMITS } from '@d3ro/core/constants'
import type { StatsSummary, HistoryEntry, HotkeyBinding, CaptionState, LicenseTier, UsageQuota } from '@d3ro/core/types'
// ── 로컬 프리미티브 ───────────────────────────────────
/** 옅은 헤어라인 필 (레퍼런스의 "TUE" / "로컬 연결" 칩) */
function Pill({
children,
tone = 'neutral',
}: {
children: React.ReactNode
tone?: 'neutral' | 'accent' | 'green'
}): React.ReactElement {
const color =
tone === 'accent' ? d3roPalette.accent.light : tone === 'green' ? d3roPalette.tag.green : d3roPalette.text.secondary
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.5,
borderRadius: d3roRadius.pill,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
bgcolor: d3roPalette.glass.raised,
color,
...typoSx('meta'),
fontFamily: d3roFontSans,
textTransform: 'none',
whiteSpace: 'nowrap',
}}
>
{children}
</Box>
)
}
/** 카드 섹션 헤더 (제목 + 우측 액세서리) — 헤어라인 하단 구분 */
function CardHeader({
title,
right,
}: {
title: string
right?: React.ReactNode
}): React.ReactElement {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
pb: 1.5,
mb: 2,
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
}}
>
<PhosphorText variant="heading">{title}</PhosphorText>
{right}
</Box>
)
}
/** 그라디언트 프로그레스 바 */
function GradientBar({
ratio,
alt = false,
}: {
/** 0~1 (무제한이면 1) */
ratio: number
alt?: boolean
}): React.ReactElement {
return (
<Box sx={{ height: 5, borderRadius: d3roRadius.pill, bgcolor: d3roPalette.bg.inset, overflow: 'hidden' }}>
<Box
sx={{
height: '100%',
width: `${Math.min(100, Math.max(4, ratio * 100))}%`,
backgroundImage: alt ? d3roPalette.gradient.barAlt : d3roPalette.gradient.bar,
borderRadius: d3roRadius.pill,
transition: 'width 0.3s ease',
}}
/>
</Box>
)
}
// ── 메인 컴포넌트 ─────────────────────────────────────
export function DashboardPage(): React.ReactElement {
const { t, formatTime, formatRelativeDate } = useI18n()
const { t, formatRelativeDate } = useI18n()
const [stats, setStats] = useState<StatsSummary | null>(null)
const [history, setHistory] = useState<HistoryEntry[]>([])
const [ollamaConnected, setOllamaConnected] = useState(false)
const [llmModel, setLlmModel] = useState<string | null>(null)
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
const [activeView, setActiveView] = useState<'stats' | 'status'>('stats')
const [captionState, setCaptionState] = useState<CaptionState>('inactive')
const [audioLevel, setAudioLevel] = useState(0)
const audioDecayRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [licenseTier, setLicenseTier] = useState<LicenseTier>('free')
const [usageQuotas, setUsageQuotas] = useState<UsageQuota[]>([])
const [premiumStatus, setPremiumStatus] = useState<{ available: boolean; backend: 'local' | 'premium' } | null>(null)
const statsRef = useRef<HTMLDivElement>(null)
const loadData = useCallback(() => {
window.electronAPI.stats.getSummary().then((r) => {
@ -37,6 +134,9 @@ export function DashboardPage(): React.ReactElement {
window.electronAPI.llm.getStatus().then((r) => {
if (r.success) setOllamaConnected(r.data.connectionState === 'connected')
})
window.electronAPI.llm.getActiveModel().then((r) => {
if (r.success) setLlmModel(r.data)
})
window.electronAPI.history.getAll({ page: 0, pageSize: 10, sortOrder: 'desc' }).then((r) => {
if (r.success) setHistory(r.data.entries)
})
@ -103,311 +203,256 @@ export function DashboardPage(): React.ReactElement {
{ name: t('service.audioInput'), status: t('service.standby'), ok: true },
], [t, ollamaConnected, premiumStatus])
const allOk = services.every((s) => s.ok || s.name === t('service.premiumLlm'))
const now = new Date()
const weekday = now.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase()
const openSettings = useCallback(() => {
window.dispatchEvent(new Event('d3ro:open-settings'))
}, [])
const statTiles = [
{ ring: 'blue' as const, icon: <Mic size={20} />, label: t('dashboard.recordingTime'), value: formatRecordingTime(stats?.totalRecordingTimeMs ?? 0), unit: formatRecordingTimeUnit(stats?.totalRecordingTimeMs ?? 0), sub: t('dashboard.sessionsToday') },
{ ring: 'purple' as const, icon: <BookOpen size={20} />, label: t('dashboard.recognizedWords'), value: formatNumber(stats?.totalWordCount ?? 0), unit: t('dashboard.total'), sub: null },
{ ring: 'green' as const, icon: <CalendarDays size={20} />, label: t('dashboard.sessionsToday'), value: `${stats?.todaySessionCount ?? 0}`, unit: t('dashboard.sessions'), sub: null },
{ ring: 'orange' as const, icon: <Flame size={20} />, label: t('dashboard.streakUse'), value: `${stats?.streakDays ?? 0}`, unit: t('dashboard.days'), sub: null },
]
return (
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4, pb: 8 }}>
{/* ── 1. 인스트루먼트 패널: 스크린 + 스탯 + 버튼 그리드 ── */}
<InstrumentPanel
engravingLeft="D3RO-VOICE"
engravingRight="v1.0.0"
engravingBottom="LOCAL AI VOICE ASSISTANT"
>
<Box
sx={{
display: 'grid',
gridTemplateColumns: '1fr 180px',
gap: 2.5,
alignItems: 'stretch',
}}
>
{/* 좌측: 스크린 디스플레이 (레퍼런스의 .display-module) */}
<ScreenPanel height={200}>
{/* 상단 라벨 */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<PhosphorText variant="label">
{activeView === 'stats'
? t('dashboard.sessionOverview').toUpperCase()
: t('dashboard.systemStatus').toUpperCase()}
</PhosphorText>
<PhosphorText variant="label">
{new Date().toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase()}
</PhosphorText>
</Box>
{/* 중앙: 큰 수치 or 서비스 상태 */}
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
{activeView === 'stats' ? (
<>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
<PhosphorText variant="hero">
{stats?.todaySessionCount ?? 0}
</PhosphorText>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
{t('dashboard.sessionsToday').toUpperCase()}
</PhosphorText>
</Box>
<PhosphorText variant="label" sx={{ mt: 0.5, color: d3roPalette.text.dimLabel }}>
{dictationBinding
? t('dashboard.pressToRecord', { key: formatHotkeyLabel(dictationBinding).toUpperCase() }).toUpperCase()
: t('dashboard.hotkeyNotSet').toUpperCase()}
</PhosphorText>
</>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{services.map((svc) => (
<Box key={svc.name} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color={svc.ok ? 'green' : 'red'} size={6} />
<PhosphorText variant="label" sx={{ flex: 1 }}>{svc.name}</PhosphorText>
<PhosphorText
variant="value"
sx={{ fontSize: d3roTypo.meta.size, color: svc.ok ? d3roPalette.accent.amber : d3roPalette.tag.red }}
>
{svc.status}
</PhosphorText>
</Box>
))}
</Box>
)}
</Box>
{/* 하단: 보조 수치 (레퍼런스의 .screen-bottom) */}
{activeView === 'stats' && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<Box>
<PhosphorText variant="label">{t('dashboard.words').toUpperCase()}</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
<PhosphorText variant="value">
{formatNumber(stats?.totalWordCount ?? 0)}
</PhosphorText>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
{t('dashboard.total').toUpperCase()}
</PhosphorText>
</Box>
</Box>
<Box sx={{ textAlign: 'right' }}>
<PhosphorText variant="label">{t('dashboard.streak').toUpperCase()}</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, justifyContent: 'flex-end' }}>
<PhosphorText variant="value">
{stats?.streakDays ?? 0}
</PhosphorText>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel }}>
{t('dashboard.days').toUpperCase()}
</PhosphorText>
</Box>
</Box>
</Box>
)}
</ScreenPanel>
{/* 우측: 컨트롤 패널 */}
<Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
{/* LED 상태 클러스터 */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, pt: 1 }}>
<Led color={ollamaConnected ? 'green' : 'red'} size={8} />
<Led color="amber" pulse size={8} />
</Box>
{/* 버튼 그룹 */}
<ButtonGroup>
<PhysicalButton
selected={activeView === 'stats'}
onClick={() => setActiveView('stats')}
sx={{ minWidth: 0 }}
>
{t('dashboard.stat').toUpperCase()}
</PhysicalButton>
<PhysicalButton
selected={activeView === 'status'}
onClick={() => setActiveView('status')}
sx={{ minWidth: 0 }}
>
{t('dashboard.sys').toUpperCase()}
</PhysicalButton>
</ButtonGroup>
<Box sx={{ maxWidth: 980, mx: 'auto', p: 4, pt: 5, pb: 8 }}>
{/* ── 1. 히어로 카드 ─────────────────────────── */}
<MetalCard sx={{ p: 3.5 }}>
{/* 상단 라인: 라벨 / 날짜 / 시스템 칩 */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="amber" pulse size={7} />
<PhosphorText variant="meta" sx={{ color: d3roPalette.accent.light, textTransform: 'none', fontFamily: d3roFontSans }}>
{t('dashboard.sessionsToday')}
</PhosphorText>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
{now.toLocaleDateString()}
</PhosphorText>
<Pill>{weekday}</Pill>
<Pill tone={allOk ? 'green' : 'neutral'}>
<Led color={allOk ? 'green' : 'orange'} size={6} />
{allOk ? t('dashboard.systemOk') : t('dashboard.systemIssue')}
</Pill>
</Box>
</Box>
</InstrumentPanel>
{/* ── 2. 통계 카드 (인스트루먼트 패널 아래) ──── */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 2,
mt: 3,
}}
>
{[
{ label: t('dashboard.recording').toUpperCase(), value: formatRecordingTime(stats?.totalRecordingTimeMs ?? 0), unit: formatRecordingTimeUnit(stats?.totalRecordingTimeMs ?? 0) },
{ label: t('dashboard.words').toUpperCase(), value: formatNumber(stats?.totalWordCount ?? 0), unit: t('dashboard.total').toUpperCase() },
{ label: t('dashboard.today').toUpperCase(), value: `${stats?.todaySessionCount ?? 0}`, unit: t('dashboard.sessions').toUpperCase() },
{ label: t('dashboard.streak').toUpperCase(), value: `${stats?.streakDays ?? 0}`, unit: t('dashboard.days').toUpperCase() },
].map((card) => (
<MetalCard key={card.label}>
<Box sx={{ textAlign: 'center', py: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
{card.label}
</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 0.5 }}>
<PhosphorText variant="value" sx={{ fontSize: d3roTypo.title.size, fontWeight: 300 }}>
{card.value}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr auto auto', gap: 3, alignItems: 'start' }}>
{/* 좌: 헤드라인 + 힌트 + 키 설정 */}
<Box sx={{ minWidth: 0 }}>
<PhosphorText variant="hero" sx={{ mb: 1 }}>
{t('dashboard.heroIdle')}
</PhosphorText>
<PhosphorText variant="body" sx={{ mb: 2 }}>
{dictationBinding
? t('dashboard.pressToRecord', { key: formatHotkeyLabel(dictationBinding).toUpperCase() })
: t('dashboard.hotkeyNotSet')}
</PhosphorText>
<PhysicalButton onClick={openSettings} sx={{ height: 34, px: 1.5, gap: 0.75 }}>
<KeyRound size={13} />
{t('dashboard.keySetup')}
</PhysicalButton>
</Box>
{/* 중: 수치 2종 */}
<Box sx={{ display: 'flex', gap: 4, pt: 0.5 }}>
{[
{ label: t('dashboard.words'), value: formatNumber(stats?.totalWordCount ?? 0), unit: t('dashboard.total') },
{ label: t('dashboard.sessionsToday'), value: `${stats?.todaySessionCount ?? 0}`, unit: '' },
].map((item) => (
<Box key={item.label} sx={{ textAlign: 'left' }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary, mb: 0.5, display: 'block' }}>
{item.label}
</PhosphorText>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size }}>
{card.unit}
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '34px', fontWeight: 700, lineHeight: 1, color: d3roPalette.text.primary, fontVariantNumeric: 'tabular-nums' }}>
{item.value}
</Typography>
{item.unit && (
<PhosphorText variant="small" sx={{ color: d3roPalette.text.dimLabel }}>
{item.unit}
</PhosphorText>
)}
</Box>
</Box>
))}
</Box>
{/* 우: CTA 버튼 스택 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minWidth: 150 }}>
<Button
variant="contained"
color="primary"
startIcon={<BarChart3 size={15} />}
onClick={() => statsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
sx={{ py: 1.25 }}
>
{t('dashboard.viewStats')}
</Button>
<PhysicalButton onClick={openSettings} sx={{ gap: 0.75 }}>
<Settings2 size={14} />
{t('dashboard.systemSettings')}
</PhysicalButton>
</Box>
</Box>
{/* 하단: 그라디언트 웨이브 */}
<Box sx={{ mt: 2.5, height: 56 }}>
<GradientWave audioLevel={audioLevel} idleAmplitude={0.3} barWidth={3} barGap={3} />
</Box>
</MetalCard>
{/* ── 2. 컬러 링 스탯 4타일 ───────────────────── */}
<Box ref={statsRef} sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, mt: 2.5 }}>
{statTiles.map((tile) => (
<MetalCard key={tile.label} sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<StatRing color={tile.ring} size={52}>
{tile.icon}
</StatRing>
<Box sx={{ minWidth: 0 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary, display: 'block', mb: 0.5, whiteSpace: 'nowrap' }}>
{tile.label}
</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5 }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '26px', fontWeight: 700, lineHeight: 1, color: d3roPalette.text.primary, fontVariantNumeric: 'tabular-nums' }}>
{tile.value}
</Typography>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.dimLabel }}>
{tile.unit}
</PhosphorText>
</Box>
</Box>
</Box>
</MetalCard>
))}
</Box>
{/* ── 2.4. 백엔드 인디케이터 + 사용량 바 ─────────────── */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mt: 2 }}>
{/* 현재 백엔드 카드 */}
<MetalCard>
<Box sx={{ px: 1, py: 0.5 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
{t('dashboard.currentBackend').toUpperCase()}
</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led
color={premiumStatus?.backend === 'premium' ? 'green' : 'amber'}
size={8}
pulse={premiumStatus?.backend === 'premium'}
/>
<PhosphorText variant="value">
{premiumStatus?.backend === 'premium'
? t('dashboard.backendPremium').toUpperCase()
: t('dashboard.backendLocal').toUpperCase()}
{/* ── 3. 현재 백엔드 / 현재 요금제 ─────────────── */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mt: 2.5 }}>
{/* 백엔드 카드 */}
<MetalCard sx={{ p: 2.5 }}>
<CardHeader
title={t('dashboard.currentBackend')}
right={
<Pill tone="accent">
{premiumStatus?.backend === 'premium' ? t('dashboard.backendPremium') : t('dashboard.localConnected')}
</Pill>
}
/>
<MetalCard inset sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 2 }}>
<StatRing color="accent" size={46} thickness={2}>
<Cpu size={18} />
</StatRing>
<Box sx={{ minWidth: 0, flex: 1 }}>
<PhosphorText variant="heading" sx={{ mb: 0.5 }}>
{premiumStatus?.backend === 'premium' ? t('dashboard.backendPremium') : t('dashboard.backendLocal')}
</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
{t('dashboard.modelLabel')}: {llmModel ?? '—'}
</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
{t('dashboard.statusLabel')}: {ollamaConnected ? t('service.connected') : t('service.offline')}
</PhosphorText>
<Led color={ollamaConnected ? 'green' : 'red'} size={7} />
</Box>
</Box>
</Box>
</Box>
</MetalCard>
</MetalCard>
{/* 티어 카드 */}
<MetalCard>
<Box sx={{ px: 1, py: 0.5 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
{t('license.currentTier').toUpperCase()}
</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led
color={licenseTier === 'free' ? 'amber' : 'green'}
size={8}
pulse={licenseTier !== 'free'}
/>
<PhosphorText variant="value">
{t(`license.${licenseTier === 'pro_plus' ? 'proPlus' : licenseTier}`).toUpperCase()}
</PhosphorText>
{/* 요금제 카드 */}
<MetalCard sx={{ p: 2.5 }}>
<CardHeader title={t('license.currentTier')} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2.5 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
<StatRing color="blue" size={40} thickness={2}>
<Star size={15} />
</StatRing>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '30px', fontWeight: 800, color: d3roPalette.text.primary, lineHeight: 1 }}>
{t(`license.${licenseTier === 'pro_plus' ? 'proPlus' : licenseTier}`).toUpperCase()}
</Typography>
</Box>
{licenseTier === 'free' && <Pill>{t('dashboard.planFree')}</Pill>}
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1, pl: 2, borderLeft: `1px solid ${d3roPalette.glass.hairline}` }}>
{[t('dashboard.planFeatureLocal'), t('dashboard.planFeatureDaily')].map((feat) => (
<Box key={feat} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box sx={{ color: d3roPalette.tag.green, display: 'flex' }}><Check size={14} /></Box>
<PhosphorText variant="compact" sx={{ color: d3roPalette.text.secondary }}>
{feat}
</PhosphorText>
</Box>
))}
</Box>
</Box>
</MetalCard>
</Box>
{/* 사용량 바 — 모든 티어에서 표시 */}
{usageQuotas.length > 0 && (
<Box sx={{ mt: 2 }}>
<MetalCard>
<Box sx={{ px: 1, py: 0.5 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
{t('license.usageToday').toUpperCase()}
</PhosphorText>
{usageQuotas.map((q) => (
<Box key={q.feature} sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.3 }}>
<PhosphorText variant="small">
{t(`license.feature.${q.feature}`)}
</PhosphorText>
<PhosphorText variant="small" sx={{ color: q.remaining === 0 ? d3roPalette.tag.red : d3roPalette.accent.amber }}>
{q.limit === -1
? t('license.unlimited')
: `${q.used}/${q.limit}`}
</PhosphorText>
</Box>
{q.limit > 0 && (
<Box sx={{
height: 3,
borderRadius: '2px',
bgcolor: d3roPalette.bg.inset,
overflow: 'hidden',
}}>
<Box sx={{
height: '100%',
width: `${Math.min(100, (q.used / q.limit) * 100)}%`,
bgcolor: q.used >= q.limit ? d3roPalette.tag.red : d3roPalette.accent.amber,
borderRadius: '2px',
transition: 'width 0.3s ease',
}} />
</Box>
)}
{/* ── 4. 오늘 사용량 / 실시간 자막 ─────────────── */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2, mt: 2.5 }}>
{/* 사용량 카드 */}
<MetalCard sx={{ p: 2.5 }}>
<CardHeader title={t('license.usageToday')} />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{usageQuotas.length === 0 && (
<PhosphorText variant="small" sx={{ color: d3roPalette.text.inactive }}></PhosphorText>
)}
{usageQuotas.map((q, idx) => (
<MetalCard inset key={q.feature} sx={{ p: 1.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
<StatRing color={idx % 2 === 0 ? 'blue' : 'purple'} size={32} thickness={2}>
{idx % 2 === 0 ? <Mic size={13} /> : <Cpu size={13} />}
</StatRing>
<PhosphorText variant="compact" sx={{ flex: 1 }}>
{t(`license.feature.${q.feature}`)}
</PhosphorText>
<PhosphorText variant="small" sx={{ color: q.remaining === 0 && q.limit !== -1 ? d3roPalette.tag.red : d3roPalette.accent.light }}>
{q.limit === -1 ? t('license.unlimited') : `${q.used}/${q.limit}`}
</PhosphorText>
</Box>
))}
<GradientBar
ratio={q.limit === -1 ? 1 : Math.min(1, q.used / Math.max(1, q.limit))}
alt={idx % 2 === 1}
/>
</MetalCard>
))}
</Box>
</MetalCard>
{/* Premium 모델별 쿼터 표시 */}
{premiumStatus?.backend === 'premium' && PREMIUM_MODEL_LIMITS[licenseTier].length > 0 && (
<>
<PhosphorText variant="label" sx={{ mt: 1.5, mb: 0.5, display: 'block', color: d3roPalette.tag.green }}>
{t('license.premiumQuota').toUpperCase()}
</PhosphorText>
{PREMIUM_MODEL_LIMITS[licenseTier].map((m) => (
<Box key={m.model} sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.3 }}>
<PhosphorText variant="small">
{t(m.i18nKey)}
</PhosphorText>
<PhosphorText variant="small" sx={{ color: d3roPalette.accent.amber }}>
{m.limit === -1
? t('license.unlimited')
: `${m.limit}/${t(m.period === 'weekly' ? 'license.quotaWeekly' : 'license.quotaDaily')}`}
</PhosphorText>
</Box>
{m.limit > 0 && (
<Box sx={{
height: 3,
borderRadius: '2px',
bgcolor: d3roPalette.bg.inset,
overflow: 'hidden',
}}>
<Box sx={{
height: '100%',
width: '0%',
bgcolor: d3roPalette.tag.green,
borderRadius: '2px',
transition: 'width 0.3s ease',
}} />
</Box>
)}
</Box>
))}
</>
)}
</Box>
</MetalCard>
</Box>
)}
{/* ── 2.5. 실시간 자막 토글 ────────────────── */}
<Box sx={{ mt: 3 }}>
<MetalCard>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led
color={captionState === 'active' ? 'green' : captionState === 'starting' || captionState === 'stopping' ? 'amber' : 'off'}
size={8}
pulse={captionState === 'active'}
/>
<Box>
<PhosphorText variant="body" sx={{ fontWeight: d3roTypo.heading.weight }}>
{t('dashboard.caption').toUpperCase()}
{/* 실시간 자막 카드 */}
<MetalCard sx={{ p: 2.5, display: 'flex', flexDirection: 'column' }}>
<CardHeader
title={t('dashboard.caption')}
right={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Led
color={captionState === 'active' ? 'red' : 'off'}
size={7}
pulse={captionState === 'active'}
/>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.inactive }}>
{captionState === 'active' ? t('dashboard.captionActive') : t('dashboard.captionStandby')}
</PhosphorText>
{captionState === 'active' && (
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.micro.size, color: d3roPalette.tag.green }}>
{t('dashboard.captionActive').toUpperCase()}
</PhosphorText>
)}
</Box>
</Box>
<PhysicalButton
selected={captionState === 'active'}
}
/>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', gap: 2 }}>
<PhosphorText variant="body" sx={{ color: d3roPalette.text.secondary }}>
{t('dashboard.captionPlaceholder')}
</PhosphorText>
<Button
variant="contained"
color="primary"
startIcon={<Mic size={15} />}
onClick={async () => {
if (captionState === 'active') {
await window.electronAPI.caption.stop()
@ -415,46 +460,105 @@ export function DashboardPage(): React.ReactElement {
await window.electronAPI.caption.start()
}
}}
sx={{ minWidth: 100 }}
sx={{ alignSelf: 'center', px: 4 }}
>
{captionState === 'active' ? t('dashboard.captionStop').toUpperCase() : t('dashboard.captionStart').toUpperCase()}
</PhysicalButton>
{captionState === 'active' ? t('dashboard.captionStop') : t('dashboard.captionStart')}
</Button>
</Box>
</MetalCard>
</Box>
{/* ── 3. CRT 서비스 상태 (컴팩트) ────────────── */}
<Box sx={{ mt: 3 }}>
<CrtDisplay amplitude={0.05} frequency={6} height={100} audioLevel={audioLevel}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1, px: 1 }}>
{/* ── 5. 시스템 상태 ──────────────────────────── */}
<MetalCard sx={{ p: 2.5, mt: 2.5 }}>
<CardHeader title={t('dashboard.systemStatus')} />
<Box sx={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 3, alignItems: 'center' }}>
{/* 좌: 서비스 상태 리스트 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minWidth: 220 }}>
{services.map((svc) => (
<Box key={svc.name} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color={svc.ok ? 'green' : 'red'} size={6} />
<PhosphorText variant="label" sx={{ flex: 1 }}>{svc.name}</PhosphorText>
<Led color={svc.ok ? 'green' : 'red'} size={7} />
<PhosphorText variant="small" sx={{ flex: 1, color: d3roPalette.text.secondary }}>
{svc.name}
</PhosphorText>
<PhosphorText
variant="value"
sx={{ fontSize: d3roTypo.meta.size, color: svc.ok ? d3roPalette.accent.amber : d3roPalette.tag.red }}
variant="meta"
sx={{ color: svc.ok ? d3roPalette.tag.green : d3roPalette.tag.red }}
>
{svc.status}
</PhosphorText>
</Box>
))}
</Box>
</CrtDisplay>
</Box>
{/* ── 3.5 파일 전사 (Phase 12.1) ──────────────── */}
<Box sx={{ mt: 3 }}>
<PhosphorText variant="label" sx={{ mb: 1.5, display: 'block', color: d3roPalette.text.inactive }}>
{t('fileTranscription.title').toUpperCase()}
</PhosphorText>
{/* 중: 웨이브 + 레벨 미터 */}
<Box sx={{ minWidth: 0 }}>
<Box sx={{ height: 64, mb: 1.25 }}>
<GradientWave audioLevel={audioLevel} idleAmplitude={0.35} barWidth={2.5} barGap={2.5} glow={6} />
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<PhosphorText variant="meta" sx={{ whiteSpace: 'nowrap' }}>
{t('dashboard.inputLevel')}
</PhosphorText>
<Box sx={{ display: 'flex', gap: '3px', flex: 1 }}>
{Array.from({ length: 24 }, (_, i) => (
<Box
key={i}
sx={{
flex: 1,
height: 8,
borderRadius: '2px',
bgcolor: i < Math.round(audioLevel * 24) ? d3roPalette.tag.green : d3roPalette.bg.inset,
transition: 'background-color 0.1s linear',
}}
/>
))}
</Box>
<PhosphorText variant="meta" sx={{ color: d3roPalette.tag.green, minWidth: 34, textAlign: 'right' }}>
{Math.round(audioLevel * 100)}%
</PhosphorText>
</Box>
</Box>
{/* 우: 백엔드 요약 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25, minWidth: 180 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1.5 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
{t('service.ollamaLlm')}
</PhosphorText>
<PhosphorText variant="meta" sx={{ color: ollamaConnected ? d3roPalette.tag.green : d3roPalette.tag.red }}>
{ollamaConnected ? t('service.ready') : t('service.offline')}
</PhosphorText>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1.5 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
{t('dashboard.modelLabel')}
</PhosphorText>
<PhosphorText variant="meta" sx={{ color: d3roPalette.accent.light, fontFamily: d3roFontMono }}>
{llmModel ?? '—'}
</PhosphorText>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1.5 }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
{t('dashboard.statusLabel')}
</PhosphorText>
<PhosphorText variant="meta" sx={{ color: allOk ? d3roPalette.tag.green : d3roPalette.tag.orange }}>
{allOk ? 'CONNECTED' : 'CHECK'}
</PhosphorText>
</Box>
</Box>
</Box>
</MetalCard>
{/* ── 6. 파일 전사 ────────────────────────────── */}
<MetalCard sx={{ p: 2.5, mt: 2.5 }}>
<CardHeader title={t('fileTranscription.title')} />
<FileDropZone />
</Box>
</MetalCard>
{/* ── 4. 최근 히스토리 ──────────────────────── */}
{/* ── 7. 최근 히스토리 ──────────────────────── */}
<Box sx={{ mt: 4 }}>
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: d3roPalette.text.inactive }}>
{t('dashboard.recentTranscriptions').toUpperCase()}
<PhosphorText variant="heading" sx={{ mb: 2, display: 'block' }}>
{t('dashboard.recentTranscriptions')}
</PhosphorText>
{history.length === 0 ? (
@ -464,10 +568,10 @@ export function DashboardPage(): React.ReactElement {
<Box key={group.label} sx={{ mb: 3 }}>
{/* 날짜 구분자 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1.5 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, whiteSpace: 'nowrap' }}>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.dimLabel, whiteSpace: 'nowrap' }}>
{group.label}
</PhosphorText>
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.glass.hairline }} />
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size }}>
{t('dashboard.entries', { count: group.entries.length })}
</PhosphorText>

View file

@ -0,0 +1,47 @@
/* src/renderer/styles/global.css
* v2 "Midnight Glass" 전역 스타일 폰트, 보더리스 스크롤, 선택 색상.
* 색상은 전부 CSS 변수(테마 SSOT) 참조 hex 하드코딩 금지. */
@font-face {
font-family: 'Pretendard Variable';
font-weight: 45 920;
font-style: normal;
font-display: swap;
src: url('../assets/fonts/PretendardVariable.woff2') format('woff2-variations');
}
/* 보더리스 UI: 네이티브 스크롤바 완전 제거
* 스크롤 유무와 무관하게 레이아웃 불변.
* 시각적 인디케이터는 OverlayScrollbars(고유 스타일) 담당. */
*::-webkit-scrollbar {
width: 0;
height: 0;
background: transparent;
}
html,
body {
overscroll-behavior: none;
}
::selection {
background: var(--d3-accent-dim);
color: var(--d3-text-primary);
}
/* OverlayScrollbars 고유 스타일
* 스톡 os-theme-dark의 레이아웃을 그대로 쓰고 /크기 변수만 D3RO 토큰으로 오버라이드
* (커스텀 테마 클래스는 포지셔닝 CSS가 없어 스크롤바가 잘못된 위치에 그려짐) */
.os-theme-dark {
--os-size: 10px;
--os-padding-perpendicular: 3px;
--os-padding-axis: 6px;
--os-track-bg: transparent;
--os-track-bg-hover: transparent;
--os-track-bg-active: transparent;
--os-handle-border-radius: 999px;
--os-handle-bg: var(--d3-glass-hairlineStrong);
--os-handle-bg-hover: var(--d3-accent-dim);
--os-handle-bg-active: var(--d3-accent-main);
--os-handle-min-size: 36px;
}

View file

@ -2,6 +2,28 @@
> 마지막 갱신: 2026-07-21 (Whisper large-v3-turbo 전환 + 온보딩 2단계 부트스트랩)
## UI 전면 리디자인 "Midnight Glass" v2 (2026-07-21) — 기반 완성 ✅
### 방향 (사용자 레퍼런스 이미지 기반, /goal)
미드나이트 네이비 + 글래스모피즘 카드(옅은 헤어라인) + 시안→블루→퍼플→마젠타 그라디언트 + 보더리스 UI. **기존 테마 시스템(6종 변형) 유지가 필수 조건**.
### 구현
- **theme.ts 전면 재작성**: RawTheme에 glass(surface/raised/hairline/sheen/blur)·gradient(accent/logo/bar/barAlt/wave1-4)·glow 토큰군 추가. 기본 dark = Midnight(#0a0e1c 베이스, 블루 #3b82f6 액센트). 6개 테마 전부 파생 토큰 보유(Nord로 실사용 검증됨 — 테마 전환 정상). d3roPalette에 glass/gradient/glow + accent.main 별칭(amber는 deprecated 별칭 유지 → 전 페이지 무수정 호환)
- **DS 리스킨(동일 API)**: MetalCard→글래스 카드, PhysicalButton→글래스/그라디언트 버튼, PhosphorText→Pretendard 산세리프(메타류만 모노), Led→소프트 글로우(+blue/purple), ScreenPanel/ButtonGroup/InstrumentPanel 글래스화, **CrtDisplay를 WebGL 오실로스코프→그라디언트 스펙트럼 웨이브로 교체(API 호환)**
- **신규 DS**: GradientWave(canvas 2D, CSS 변수로 테마 반응, 미러 스펙트럼+글로우+유휴 애니메이션 — "음파" 자체 개발), StatRing(컬러 그라디언트 링 타일)
- **셸**: frame:false 보더리스 + TitleBar(드래그+min/max/close, lucide), AppLayout 와이드 사이드바(그라디언트 로고, 액티브=그라디언트 헤어라인 필+글로우, lucide 아이콘), 앰비언트 그라디언트 오브 배경
- **스크롤**: 네이티브 스크롤바 width 0 전역 제거(레이아웃 폭 불변) + OverlayScrollbars(os-theme-dark 변수 오버라이드; 커스텀 테마 클래스는 포지셔닝 CSS가 없어 오동작 — 스톡 테마+변수 방식이 정답)
- **폰트**: Pretendard Variable 로컬 번들(2MB woff2, 오프라인)
- **대시보드 재구축**: 히어로(헤드라인+수치+CTA+웨이브), 링 타일 4종, 백엔드/요금제, 사용량(그라디언트 바)/실시간 자막, 시스템 상태(웨이브+세그먼트 레벨미터), 파일 전사, 히스토리. i18n 신규 19키(ko/en)
- **StatusBar**: 브랜드+버전+모델 / 로컬 시간 시계
- 검증: tsc 0, vitest 41/41, 실행 스크린샷으로 대시보드/히스토리/대화/회의/지식/설정 전 페이지 확인. 스크롤 시 폭 불변 확인. 상단 1px 잡티는 detached DevTools 겹침(앱 버그 아님)
### 남은 마감(진행 중)
1. 내부 페이지들의 @mui/icons-material → lucide-react 통일
2. Vanilla 팝업 5종(recording-tip 등) 스타일 v2 정합
3. VoiceRecordingPanel(대화 몰입 패널) → GradientWave 교체
4. 라이트 테마 시각 검수, v0.2.0-alpha 릴리스
## 모델 현대화 트랙 (2026-07-21) — B 완료, A 완료(코드), C 완료(코드)
### 방향 결정 (사용자 컨펌)

28
package-lock.json generated
View file

@ -132,7 +132,10 @@
"electron-store": "^10.0.0",
"electron-updater": "^6.3.9",
"fluent-ffmpeg": "^2.1.3",
"lucide-react": "^1.25.0",
"node-record-lpcm16": "^1.0.1",
"overlayscrollbars": "^2.16.0",
"overlayscrollbars-react": "^0.5.6",
"pdf-parse": "^2.4.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@ -12134,6 +12137,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.25.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz",
"integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -14352,6 +14364,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/overlayscrollbars": {
"version": "2.16.0",
"resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.16.0.tgz",
"integrity": "sha512-N03oje/q7j93D0aLZtoCdsDSYLmhheSsv8H7oSLE7HhdV9P/bmCURtLV/KbPye7P/bpfyt/obSfDpGUYoJ0OWg==",
"license": "MIT"
},
"node_modules/overlayscrollbars-react": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz",
"integrity": "sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==",
"license": "MIT",
"peerDependencies": {
"overlayscrollbars": "^2.0.0",
"react": ">=16.8.0"
}
},
"node_modules/p-cancelable": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",

View file

@ -33,6 +33,24 @@
"dashboard.noHistory": "No history — press your hotkey to start recording",
"dashboard.copy": "Copy",
"dashboard.entries": "{{count}} entries",
"dashboard.heroIdle": "Ready for voice recognition",
"dashboard.viewStats": "View Stats",
"dashboard.systemSettings": "System Settings",
"dashboard.keySetup": "Key Setup",
"dashboard.systemOk": "System OK",
"dashboard.systemIssue": "Needs Attention",
"dashboard.captionPlaceholder": "Recognized text will appear here in real time.",
"dashboard.captionStandby": "Standby",
"dashboard.inputLevel": "Input Level",
"dashboard.recordingTime": "Recording Time",
"dashboard.recognizedWords": "Words Recognized",
"dashboard.streakUse": "Streak",
"dashboard.modelLabel": "Model",
"dashboard.statusLabel": "Status",
"dashboard.localConnected": "Local",
"dashboard.planFree": "Free Plan",
"dashboard.planFeatureLocal": "Unlimited local backend",
"dashboard.planFeatureDaily": "Unlimited daily transcription",
"dashboard.stat": "Stats",
"dashboard.sys": "System",
"dashboard.currentBackend": "Current Backend",
@ -155,6 +173,7 @@
"settings.about.voiceEngineValue": "STT: faster-whisper (local) / LLM: Ollama (local)",
"settings.about.description": "A fully local AI voice assistant built on Speakly reverse-engineering insights. Runs entirely offline with no cloud dependencies.",
"settings.about.restartOnboarding": "Restart Setup Wizard",
"status.localTime": "Local Time",
"status.ollama": "OLLAMA",
"status.offline": "OFFLINE",
"status.nudge.title": "Ollama is not running",

View file

@ -34,6 +34,24 @@
"dashboard.noHistory": "히스토리 없음 — 핫키를 눌러 녹음을 시작하세요",
"dashboard.copy": "복사",
"dashboard.entries": "{{count}}건",
"dashboard.heroIdle": "실시간 음성 인식 대기 중",
"dashboard.viewStats": "통계 보기",
"dashboard.systemSettings": "시스템 설정",
"dashboard.keySetup": "키 설정",
"dashboard.systemOk": "시스템 정상",
"dashboard.systemIssue": "점검 필요",
"dashboard.captionPlaceholder": "여기에 실시간 인식된 텍스트가 표시됩니다.",
"dashboard.captionStandby": "대기 중",
"dashboard.inputLevel": "입력 레벨",
"dashboard.recordingTime": "녹음 시간",
"dashboard.recognizedWords": "인식 단어 수",
"dashboard.streakUse": "연속 사용",
"dashboard.modelLabel": "모델",
"dashboard.statusLabel": "상태",
"dashboard.localConnected": "로컬 연결",
"dashboard.planFree": "무료 플랜",
"dashboard.planFeatureLocal": "로컬 백엔드 무제한",
"dashboard.planFeatureDaily": "일일 전사 무제한",
"dashboard.stat": "통계",
"dashboard.sys": "시스템",
"dashboard.currentBackend": "현재 백엔드",
@ -156,6 +174,7 @@
"settings.about.voiceEngineValue": "STT: faster-whisper (로컬) / LLM: Ollama (로컬)",
"settings.about.description": "Speakly 리버스엔지니어링 노하우 기반 로컬 AI 음성 어시스턴트. 클라우드 의존성 없이 완전 로컬로 동작합니다.",
"settings.about.restartOnboarding": "초기 설정 안내 다시 보기",
"status.localTime": "로컬 시간",
"status.ollama": "OLLAMA",
"status.offline": "OFFLINE",
"status.nudge.title": "Ollama가 실행되지 않고 있어요",

View file

@ -1,11 +1,10 @@
'use client'
// src/renderer/components/ds/ButtonGroup.tsx
// 시안 A: 인셋 버튼 클러스터 — 레퍼런스의 .button-group 패턴
// 물리 버튼들을 인셋 패널 안에 배치하여 그룹화
// v2 "Midnight Glass": 세그먼트 글래스 그룹 — 반투명 표면에 버튼 클러스터
import { Box } from '@mui/material'
import { d3roPalette, d3roShadow, d3roRadius } from '../../theme'
import { d3roPalette, d3roRadius } from '../../theme'
interface ButtonGroupProps {
children: React.ReactNode
@ -20,7 +19,7 @@ export function ButtonGroup({ children, horizontal = false }: ButtonGroupProps):
bgcolor: d3roPalette.bg.inset,
p: '6px',
borderRadius: d3roRadius.inner,
boxShadow: d3roShadow.inset,
border: `1px solid ${d3roPalette.glass.hairline}`,
display: 'flex',
flexDirection: horizontal ? 'row' : 'column',
gap: '6px',

View file

@ -1,284 +1,98 @@
'use client'
// src/renderer/components/ds/CrtDisplay.tsx
// 시안 A: CRT 디스플레이 — WebGL 셰이더 (스캔라인, 비네팅, 노이즈, 글리치, 파형)
// v2 "Midnight Glass": 히어로 디스플레이 — 딥 글래스 패널 + 그라디언트 스펙트럼 웨이브.
// (v1 WebGL 오실로스코프를 대체. props API는 v1과 호환 유지.)
import { useRef, useEffect, useCallback } from 'react'
import { useState, useEffect } from 'react'
import { Box } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { d3roPalette, d3roFontMono, d3roShadow } from '../../theme'
// ── WebGL 유틸 ─────────────────────────────────────────
function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
const shader = gl.createShader(type)
if (!shader) return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
gl.deleteShader(shader)
return null
}
return shader
}
function createProgram(gl: WebGLRenderingContext, vsSource: string, fsSource: string): WebGLProgram | null {
const vs = createShader(gl, gl.VERTEX_SHADER, vsSource)
const fs = createShader(gl, gl.FRAGMENT_SHADER, fsSource)
if (!vs || !fs) return null
const prog = gl.createProgram()
if (!prog) return null
gl.attachShader(prog, vs)
gl.attachShader(prog, fs)
gl.linkProgram(prog)
return prog
}
const VERTEX_SHADER = `
attribute vec2 position;
varying vec2 vUv;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
vUv = position * 0.5 + 0.5;
}
`
const FRAGMENT_SHADER = `
precision highp float;
varying vec2 vUv;
uniform float u_time;
uniform float u_glitch;
uniform float u_amp;
uniform float u_freq;
float random(vec2 st) { return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123); }
void main() {
vec2 uv = vUv;
// Glitch displacement
if (u_glitch > 0.0) {
float gOffset = (random(vec2(uv.y * 5.0, u_time)) - 0.5) * u_glitch * 0.1;
uv.x += gOffset;
}
// Base phosphor background
vec3 color = vec3(0.02, 0.015, 0.01);
// Background Grid
float gridX = step(0.98, fract(uv.x * 15.0));
float gridY = step(0.98, fract(uv.y * 10.0));
color += vec3(0.1, 0.04, 0.02) * max(gridX, gridY) * (1.0 - u_glitch);
// Oscilloscope Waveform
float t = u_time * 0.5 + u_glitch * random(uv) * 0.1;
float waveY = sin((uv.x * u_freq) + t) * u_amp;
float waveDist = abs((uv.y - 0.5) - waveY);
float lineThick = 0.005 + (u_glitch * 0.02);
float waveGlow = smoothstep(lineThick * 4.0, 0.0, waveDist);
float waveCore = smoothstep(lineThick, 0.0, waveDist);
// Amber wave (#f25b29)
vec3 waveColor = vec3(0.95, 0.35, 0.16);
color += waveColor * waveCore;
color += waveColor * 0.4 * waveGlow;
// Scanlines
float scanline = sin(uv.y * 800.0 - u_time * 10.0) * 0.04;
color -= scanline;
// Noise
float noise = random(uv + u_time) * 0.08;
color += noise;
// Vignette
float dist = distance(vUv, vec2(0.5));
float vig = smoothstep(0.8, 0.4, dist);
color *= vig;
// Edge darkening
color *= smoothstep(0.0, 0.02, vUv.x) * smoothstep(1.0, 0.98, vUv.x);
color *= smoothstep(0.0, 0.05, vUv.y) * smoothstep(1.0, 0.95, vUv.y);
gl_FragColor = vec4(color, 1.0);
}
`
const QUAD = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1])
// ── Props ──────────────────────────────────────────────
import { d3roPalette, d3roFontSans, d3roRadius } from '../../theme'
import { GradientWave } from './GradientWave'
interface CrtDisplayProps {
/** 파형 진폭 (0.0 ~ 1.0) */
/** 유휴 파형 진폭 (0.0 ~ 1.0) */
amplitude?: number
/** 파형 주파수 */
/** 파형 속도 배율 (v1 주파수 개념 호환 — 8 기준 1배) */
frequency?: number
/** 글리치 트리거 (변경 시 글리치 발생) */
/** 글리치 트리거 (변경 시 플래시 발생) */
glitchTrigger?: number
/** 실시간 오디오 레벨 (0.0~1.0) — 파형 진폭에 반영 */
audioLevel?: number
/** 오버레이 콘텐츠 (인광 텍스트 등) */
/** 오버레이 콘텐츠 */
children?: React.ReactNode
/** 높이 (기본 280px) */
height?: number | string
}
export function CrtDisplay({
amplitude = 0.1,
amplitude = 0.25,
frequency = 8.0,
glitchTrigger = 0,
audioLevel = 0,
children,
height = 280,
}: CrtDisplayProps): React.ReactElement {
const theme = useTheme()
const isLight = theme.palette.mode === 'light'
const canvasRef = useRef<HTMLCanvasElement>(null)
const glRef = useRef<{
gl: WebGLRenderingContext
uTime: WebGLUniformLocation | null
uGlitch: WebGLUniformLocation | null
uAmp: WebGLUniformLocation | null
uFreq: WebGLUniformLocation | null
} | null>(null)
const animRef = useRef<number>(0)
const startTimeRef = useRef(Date.now())
const glitchRef = useRef(0)
const ampRef = useRef(amplitude)
const freqRef = useRef(frequency)
const audioLevelRef = useRef(audioLevel)
const currentAmpRef = useRef(amplitude)
const currentFreqRef = useRef(frequency)
const [flash, setFlash] = useState(false)
// amplitude/frequency/audioLevel 변경 추적
useEffect(() => {
ampRef.current = amplitude
freqRef.current = frequency
}, [amplitude, frequency])
useEffect(() => {
audioLevelRef.current = audioLevel
}, [audioLevel])
// 글리치 트리거
// v1 글리치 → v2 소프트 플래시
useEffect(() => {
if (glitchTrigger > 0) {
glitchRef.current = 1.0
setFlash(true)
const timer = setTimeout(() => setFlash(false), 220)
return () => clearTimeout(timer)
}
return undefined
}, [glitchTrigger])
const render = useCallback(() => {
const ctx = glRef.current
if (!ctx) return
const { gl, uTime, uGlitch, uAmp, uFreq } = ctx
// audioLevel → amplitude 반영: 기본 amplitude + 오디오 레벨로 증폭
const targetAmp = ampRef.current + audioLevelRef.current * 0.35
// Smoothing
currentAmpRef.current += (targetAmp - currentAmpRef.current) * 0.15
currentFreqRef.current += (freqRef.current - currentFreqRef.current) * 0.1
glitchRef.current *= 0.85
gl.uniform1f(uTime, (Date.now() - startTimeRef.current) / 1000)
gl.uniform1f(uGlitch, glitchRef.current)
gl.uniform1f(uAmp, currentAmpRef.current)
gl.uniform1f(uFreq, currentFreqRef.current)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
animRef.current = requestAnimationFrame(render)
}, [])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const gl = canvas.getContext('webgl', { alpha: true })
if (!gl) return
canvas.width = canvas.clientWidth * 2
canvas.height = canvas.clientHeight * 2
gl.viewport(0, 0, canvas.width, canvas.height)
const prog = createProgram(gl, VERTEX_SHADER, FRAGMENT_SHADER)
if (!prog) return
gl.useProgram(prog)
const buffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, QUAD, gl.STATIC_DRAW)
const posLoc = gl.getAttribLocation(prog, 'position')
gl.enableVertexAttribArray(posLoc)
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0)
glRef.current = {
gl,
uTime: gl.getUniformLocation(prog, 'u_time'),
uGlitch: gl.getUniformLocation(prog, 'u_glitch'),
uAmp: gl.getUniformLocation(prog, 'u_amp'),
uFreq: gl.getUniformLocation(prog, 'u_freq'),
}
startTimeRef.current = Date.now()
animRef.current = requestAnimationFrame(render)
return () => {
cancelAnimationFrame(animRef.current)
}
}, [render])
return (
<Box
sx={{
position: 'relative',
height,
bgcolor: d3roPalette.bg.crtBezel,
borderRadius: '8px',
boxShadow: d3roShadow.insetDeep,
bgcolor: d3roPalette.bg.crtGlass,
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.glass.hairline}`,
overflow: 'hidden',
}}
>
{/* Glass surface */}
{/* 앰비언트 배경 글로우 */}
<Box
sx={{
position: 'absolute',
inset: '2px',
borderRadius: '6px',
bgcolor: d3roPalette.bg.crtGlass,
overflow: 'hidden',
boxShadow: d3roShadow.screenGlow,
inset: 0,
background: `radial-gradient(ellipse 70% 55% at 50% 100%, ${d3roPalette.accent.dim} 0%, transparent 70%)`,
pointerEvents: 'none',
}}
>
<canvas
ref={canvasRef}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
// 라이트 모드에서 WebGL 셰이더(다크 전용)를 반전하여 밝은 배경에 어울리게 조정
...(isLight && { filter: 'invert(0.88) hue-rotate(180deg)', opacity: 0.9 }),
}}
/>
/>
{/* Glass reflection */}
<Box
sx={{
position: 'absolute',
top: 0, left: 0, right: 0, bottom: '50%',
background: isLight
? 'linear-gradient(180deg, rgba(255,255,255,0.30) 0%, rgba(255,255,255,0) 100%)'
: 'linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0) 100%)',
pointerEvents: 'none',
zIndex: 10,
}}
/>
{/* 그라디언트 스펙트럼 웨이브 — 하단 영역 */}
<Box sx={{ position: 'absolute', left: 12, right: 12, bottom: 8, height: '38%' }}>
<GradientWave audioLevel={audioLevel} idleAmplitude={amplitude} speed={frequency / 8} />
</Box>
{/* Content overlay (phosphor text) */}
{/* 상단 시인 */}
<Box
sx={{
position: 'absolute',
inset: 0,
background: d3roPalette.glass.sheen,
pointerEvents: 'none',
}}
/>
{/* 플래시 오버레이 (글리치 호환) */}
<Box
sx={{
position: 'absolute',
inset: 0,
bgcolor: d3roPalette.accent.dim,
opacity: flash ? 1 : 0,
transition: 'opacity 0.2s ease',
pointerEvents: 'none',
}}
/>
{/* 콘텐츠 오버레이 */}
<Box
sx={{
position: 'absolute',
@ -287,10 +101,9 @@ export function CrtDisplay({
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
color: d3roPalette.accent.amber,
textShadow: '0 0 6px rgba(242, 91, 41, 0.4)',
color: d3roPalette.text.primary,
pointerEvents: 'none',
fontFamily: d3roFontMono,
fontFamily: d3roFontSans,
}}
>
{children}

View file

@ -0,0 +1,165 @@
'use client'
// src/renderer/components/ds/GradientWave.tsx
// v2 시그니처 비주얼: 시안→블루→퍼플→마젠타 그라디언트 스펙트럼 웨이브.
// canvas 2D — 테마 CSS 변수(--d3-gradient-wave1..4)를 읽어 테마 전환에 자동 반응.
// 미러형(중앙선 기준 상하 대칭) 바 스펙트럼 + 소프트 글로우 + 유휴 애니메이션.
import { useRef, useEffect } from 'react'
import { Box } from '@mui/material'
interface GradientWaveProps {
/** 실시간 오디오 레벨 0~1 (없으면 유휴 애니메이션만) */
audioLevel?: number
/** 유휴 상태 기본 진폭 0~1 (기본 0.25) */
idleAmplitude?: number
/** 애니메이션 속도 배율 (기본 1) */
speed?: number
/** 바 폭 px (기본 3) */
barWidth?: number
/** 바 간격 px (기본 3) */
barGap?: number
/** 글로우 강도 px (기본 8, 0이면 비활성) */
glow?: number
/** 높이 (CSS 값, 기본 '100%') */
height?: number | string
}
/** :root에 주입된 웨이브 그라디언트 스톱을 읽는다 */
function readWaveColors(): [string, string, string, string] {
const style = getComputedStyle(document.documentElement)
const read = (name: string, fallback: string): string =>
style.getPropertyValue(name).trim() || fallback
return [
read('--d3-gradient-wave1', '#22d3ee'),
read('--d3-gradient-wave2', '#3b82f6'),
read('--d3-gradient-wave3', '#8b5cf6'),
read('--d3-gradient-wave4', '#e879f9'),
]
}
/** 결정적 의사난수 (바 인덱스별 고정 시드) */
function seeded(i: number): number {
const x = Math.sin(i * 127.1 + 311.7) * 43758.5453
return x - Math.floor(x)
}
export function GradientWave({
audioLevel = 0,
idleAmplitude = 0.25,
speed = 1,
barWidth = 3,
barGap = 3,
glow = 8,
height = '100%',
}: GradientWaveProps): React.ReactElement {
const canvasRef = useRef<HTMLCanvasElement>(null)
const levelRef = useRef(audioLevel)
const smoothedRef = useRef(0)
useEffect(() => {
levelRef.current = audioLevel
}, [audioLevel])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
let raf = 0
let width = 0
let heightPx = 0
let colors = readWaveColors()
let gradient: CanvasGradient | null = null
let frame = 0
const start = performance.now()
const rebuildGradient = (): void => {
gradient = ctx.createLinearGradient(0, 0, width, 0)
gradient.addColorStop(0, colors[0])
gradient.addColorStop(0.38, colors[1])
gradient.addColorStop(0.7, colors[2])
gradient.addColorStop(1, colors[3])
}
const resize = (): void => {
const dpr = window.devicePixelRatio || 1
width = canvas.clientWidth
heightPx = canvas.clientHeight
canvas.width = Math.max(1, Math.round(width * dpr))
canvas.height = Math.max(1, Math.round(heightPx * dpr))
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
rebuildGradient()
}
const observer = new ResizeObserver(resize)
observer.observe(canvas)
resize()
const render = (): void => {
frame += 1
// 테마 전환 대응: 1초마다 색 재판독
if (frame % 60 === 0) {
const next = readWaveColors()
if (next.join() !== colors.join()) {
colors = next
rebuildGradient()
}
}
// 레벨 스무딩 (상승 빠르게, 하강 느리게)
const target = Math.min(1, levelRef.current)
const k = target > smoothedRef.current ? 0.4 : 0.08
smoothedRef.current += (target - smoothedRef.current) * k
ctx.clearRect(0, 0, width, heightPx)
if (!gradient || width <= 0) {
raf = requestAnimationFrame(render)
return
}
const t = ((performance.now() - start) / 1000) * speed
const step = barWidth + barGap
const count = Math.max(1, Math.floor(width / step))
const centerY = heightPx / 2
const maxHalf = heightPx / 2 - 1
ctx.fillStyle = gradient
if (glow > 0) {
ctx.shadowBlur = glow
ctx.shadowColor = colors[1]
}
for (let i = 0; i < count; i++) {
const x = i * step
const s = seeded(i)
// 유휴: 느리게 흐르는 다중 사인 + 바별 시드 → 오가닉한 스펙트럼
const idle =
(Math.sin(t * 1.4 + i * 0.35) * 0.5 + 0.5) * 0.55 +
(Math.sin(t * 0.7 + i * 0.11 + s * 6.28) * 0.5 + 0.5) * 0.45
const envelope = idleAmplitude * (0.25 + idle * 0.75) + smoothedRef.current * (0.4 + s * 0.6)
const half = Math.max(1.5, Math.min(1, envelope) * maxHalf)
const radius = Math.min(barWidth / 2, half)
ctx.beginPath()
ctx.roundRect(x, centerY - half, barWidth, half * 2, radius)
ctx.fill()
}
ctx.shadowBlur = 0
raf = requestAnimationFrame(render)
}
raf = requestAnimationFrame(render)
return () => {
cancelAnimationFrame(raf)
observer.disconnect()
}
}, [idleAmplitude, speed, barWidth, barGap, glow])
return (
<Box sx={{ width: '100%', height, minHeight: 0 }}>
<canvas ref={canvasRef} style={{ display: 'block', width: '100%', height: '100%' }} />
</Box>
)
}

View file

@ -1,19 +1,19 @@
'use client'
// src/renderer/components/ds/InstrumentPanel.tsx
// 시안 A: 메탈 섀시 컨테이너 — 노이즈 텍스처, 각인 텍스트, 물리적 존재감
// v2 "Midnight Glass": 히어로 글래스 패널 — 앰비언트 그라디언트 배경 + 헤어라인.
// v1의 각인(engraving) props는 호환 유지하되 모서리 메타 라벨로 은은하게 렌더.
import { Box, Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '../../theme'
interface InstrumentPanelProps {
children: React.ReactNode
/** 상단 좌측 각인 */
/** 상단 좌측 메타 라벨 */
engravingLeft?: string
/** 상단 우측 각인 */
/** 상단 우측 메타 라벨 */
engravingRight?: string
/** 하단 중앙 각인 */
/** 하단 중앙 메타 라벨 */
engravingBottom?: string
}
@ -27,30 +27,30 @@ export function InstrumentPanel({
<Box
sx={{
position: 'relative',
bgcolor: d3roPalette.bg.chassis,
bgcolor: d3roPalette.glass.surface,
backdropFilter: `blur(${d3roPalette.glass.blur})`,
borderRadius: d3roRadius.outer,
border: `1px solid ${d3roPalette.glass.hairline}`,
p: 3,
boxShadow: d3roShadow.chassis,
// 메탈 노이즈는 CSS로 시뮬레이션
boxShadow: d3roShadow.card,
overflow: 'hidden',
// 앰비언트 액센트 글로우 (좌상단 은은한 빛)
'&::before': {
content: '""',
position: 'absolute',
inset: 0,
borderRadius: d3roRadius.outer,
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")`,
opacity: 0.04,
mixBlendMode: 'overlay',
borderRadius: 'inherit',
background: `radial-gradient(ellipse 60% 40% at 18% 0%, ${d3roPalette.accent.dim} 0%, transparent 60%)`,
pointerEvents: 'none',
zIndex: 1,
},
}}
>
{/* 각인 텍스트 */}
{/* 모서리 메타 라벨 */}
<Engraving sx={{ top: 12, left: 24 }}>{engravingLeft}</Engraving>
<Engraving sx={{ top: 12, right: 24 }}>{engravingRight}</Engraving>
<Engraving sx={{ bottom: 12, left: '50%', transform: 'translateX(-50%)' }}>{engravingBottom}</Engraving>
{/* 콘텐츠 (z-index 5로 노이즈 위) */}
<Box sx={{ position: 'relative', zIndex: 5 }}>
{children}
</Box>
@ -59,20 +59,17 @@ export function InstrumentPanel({
}
function Engraving({ children, sx }: { children: string; sx: Record<string, unknown> }): React.ReactElement {
const theme = useTheme()
const isLight = theme.palette.mode === 'light'
return (
<Typography
sx={{
position: 'absolute',
fontSize: d3roTypo.engrave.size,
letterSpacing: d3roTypo.engrave.spacing,
color: d3roPalette.text.engraving,
textShadow: isLight
? '0 -1px 0 rgba(0,0,0,0.1)'
: '0 1px 0 rgba(255,255,255,0.08)',
color: d3roPalette.text.dimLabel,
opacity: 0.7,
fontWeight: 700,
fontFamily: d3roFontMono,
textTransform: 'uppercase',
zIndex: 2,
userSelect: 'none',
...sx,

View file

@ -1,18 +1,22 @@
'use client'
// src/renderer/components/ds/Led.tsx
// 시안 A: LED 인디케이터 — 물리적 LED, 활성 시 glow + pulse
// v2 "Midnight Glass": 상태 도트 — 소프트 글로우, 활성 시 pulse
// (레퍼런스의 "● READY" / "● 시스템 정상" 인디케이터)
import { Box } from '@mui/material'
import { d3roPalette } from '../../theme'
type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'off'
type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'blue' | 'purple' | 'off'
const LED_COLORS: Record<LedColor, { bg: string; glow: string }> = {
amber: { bg: d3roPalette.accent.amber, glow: d3roPalette.accent.amberGlow },
// 'amber'는 v1 호환 별칭 — 현재 테마의 프라이머리 액센트 색으로 렌더
amber: { bg: d3roPalette.accent.main, glow: d3roPalette.accent.glow },
green: { bg: d3roPalette.tag.green, glow: d3roPalette.tag.greenGlow },
red: { bg: d3roPalette.tag.red, glow: d3roPalette.tag.redGlow },
orange: { bg: d3roPalette.tag.orange, glow: d3roPalette.tag.orangeGlow },
blue: { bg: d3roPalette.tag.blue, glow: d3roPalette.tag.blueGlow },
purple: { bg: d3roPalette.tag.purple, glow: d3roPalette.tag.purpleGlow },
off: { bg: d3roPalette.led.off, glow: 'transparent' },
}
@ -34,16 +38,14 @@ export function Led({ color = 'off', pulse = false, size = 8 }: LedProps): React
borderRadius: '50%',
bgcolor: c.bg,
flexShrink: 0,
boxShadow: isActive
? `inset 0 1px 2px rgba(255,255,255,0.5), 0 0 10px ${c.glow}`
: 'inset 0 1px 3px rgba(0,0,0,0.9), 0 1px 0 rgba(255,255,255,0.05)',
transition: 'all 0.1s',
boxShadow: isActive ? `0 0 ${size * 1.2}px ${c.glow}` : 'none',
transition: 'all 0.15s ease',
...(pulse && isActive
? {
animation: 'led-pulse 1.5s ease-in-out infinite',
animation: 'led-pulse 1.6s ease-in-out infinite',
'@keyframes led-pulse': {
'0%, 100%': { opacity: 1 },
'50%': { opacity: 0.5 },
'50%': { opacity: 0.45 },
},
}
: {}),

View file

@ -1,13 +1,14 @@
'use client'
// packages/ui/src/components/ds/MetalCard.tsx
// 시안 A+B 융합: 메탈 카드 컨테이너 — 섀시 느낌의 인셋 패널
// 토큰 적용: d3roShadow, d3roRadius
// packages/ui/src/components/ds/MetalCard.tssx
// v2 "Midnight Glass": 글래스모피즘 카드 — 반투명 표면 + 옅은 헤어라인 + 상단 시인
// 토큰 적용: d3roPalette.glass, d3roShadow, d3roRadius
import { Box, type BoxProps } from '@mui/material'
import { d3roPalette, d3roShadow, d3roRadius } from '../../theme'
interface MetalCardProps extends Omit<BoxProps, 'component'> {
/** 카드 내부의 상승 표면 (카드 안의 카드) */
inset?: boolean
}
@ -21,14 +22,27 @@ export function MetalCard({
<Box
{...boxProps}
sx={{
bgcolor: inset ? d3roPalette.bg.inset : d3roPalette.bg.card,
position: 'relative',
bgcolor: inset ? d3roPalette.glass.raised : d3roPalette.glass.surface,
backdropFilter: `blur(${d3roPalette.glass.blur})`,
borderRadius: inset ? d3roRadius.inner : d3roRadius.card,
borderTop: inset ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
boxShadow: inset ? d3roShadow.inset : d3roShadow.card,
p: inset ? '6px' : 3,
border: `1px solid ${d3roPalette.glass.hairline}`,
boxShadow: inset ? 'none' : d3roShadow.card,
p: inset ? 2 : 3,
overflow: 'hidden',
transition: 'background-color 0.2s ease',
'&:hover': inset ? {} : { bgcolor: d3roPalette.bg.cardHover },
transition: 'border-color 0.2s ease',
// 상단 시인(광택) 오버레이
'&::before': inset
? {}
: {
content: '""',
position: 'absolute',
inset: 0,
borderRadius: 'inherit',
background: d3roPalette.glass.sheen,
pointerEvents: 'none',
},
'&:hover': inset ? {} : { borderColor: d3roPalette.glass.hairlineStrong },
...sx,
}}
>

View file

@ -1,11 +1,12 @@
'use client'
// src/renderer/components/ds/PhosphorText.tsx
// 시안 A: 인광 텍스트 — 앰버 glow, 모노 폰트, CRT 느낌
// 확장: title/stat/body/compact/meta/engrave/micro/nano 변형 추가
// v2 "Midnight Glass": 클린 타이포그래피 — 산세리프(Pretendard) 기반.
// 헤드라인/수치는 프라이머리 화이트, 메타/라벨류만 모노+대문자 유지.
// (변형 API는 v1과 동일 — 전 페이지 호환)
import { Typography, type TypographyProps } from '@mui/material'
import { d3roPalette, d3roFontMono, d3roTypo } from '../../theme'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo } from '../../theme'
type PhosphorVariant =
| 'hero' | 'title' | 'value' | 'heading'
@ -16,31 +17,27 @@ type PhosphorVariant =
interface VariantDef {
fontSize: string
color: string
glow: string
fontWeight: number
letterSpacing: string
lineHeight: number
textTransform?: 'uppercase' | 'none'
mono?: boolean
}
const amberGlowStrong = `0 0 8px ${d3roPalette.accent.amberGlow}`
const amberGlowMedium = `0 0 6px rgba(242, 91, 41, 0.4)`
const amberGlowSoft = `0 0 4px rgba(242, 91, 41, 0.3)`
const VARIANTS: Record<PhosphorVariant, VariantDef> = {
hero: { fontSize: d3roTypo.hero.size, color: d3roPalette.accent.amber, glow: amberGlowStrong, fontWeight: d3roTypo.hero.weight, letterSpacing: d3roTypo.hero.spacing, lineHeight: d3roTypo.hero.line },
title: { fontSize: d3roTypo.title.size, color: d3roPalette.accent.amber, glow: amberGlowMedium, fontWeight: d3roTypo.title.weight, letterSpacing: d3roTypo.title.spacing, lineHeight: d3roTypo.title.line },
value: { fontSize: d3roTypo.value.size, color: d3roPalette.accent.amber, glow: amberGlowSoft, fontWeight: d3roTypo.value.weight, letterSpacing: d3roTypo.value.spacing, lineHeight: d3roTypo.value.line },
heading: { fontSize: d3roTypo.heading.size, color: d3roPalette.text.primary, glow: 'none', fontWeight: d3roTypo.heading.weight, letterSpacing: d3roTypo.heading.spacing, lineHeight: d3roTypo.heading.line },
body: { fontSize: d3roTypo.body.size, color: d3roPalette.text.secondary, glow: 'none', fontWeight: d3roTypo.body.weight, letterSpacing: d3roTypo.body.spacing, lineHeight: d3roTypo.body.line },
compact: { fontSize: d3roTypo.compact.size, color: d3roPalette.text.primary, glow: 'none', fontWeight: d3roTypo.compact.weight, letterSpacing: d3roTypo.compact.spacing, lineHeight: d3roTypo.compact.line },
small: { fontSize: d3roTypo.small.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.small.weight, letterSpacing: d3roTypo.small.spacing, lineHeight: d3roTypo.small.line },
meta: { fontSize: d3roTypo.meta.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.meta.weight, letterSpacing: d3roTypo.meta.spacing, lineHeight: d3roTypo.meta.line, textTransform: 'uppercase' },
label: { fontSize: d3roTypo.label.size, color: d3roPalette.text.dimLabel, glow: 'none', fontWeight: d3roTypo.label.weight, letterSpacing: d3roTypo.label.spacing, lineHeight: d3roTypo.label.line, textTransform: 'uppercase' },
dim: { fontSize: d3roTypo.label.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.body.weight, letterSpacing: d3roTypo.label.spacing, lineHeight: d3roTypo.label.line },
engrave: { fontSize: d3roTypo.engrave.size, color: d3roPalette.text.engraving, glow: 'none', fontWeight: d3roTypo.engrave.weight, letterSpacing: d3roTypo.engrave.spacing, lineHeight: d3roTypo.engrave.line, textTransform: 'uppercase' },
micro: { fontSize: d3roTypo.micro.size, color: d3roPalette.text.dimLabel, glow: 'none', fontWeight: d3roTypo.micro.weight, letterSpacing: d3roTypo.micro.spacing, lineHeight: d3roTypo.micro.line, textTransform: 'uppercase' },
nano: { fontSize: d3roTypo.nano.size, color: d3roPalette.text.inactive, glow: 'none', fontWeight: d3roTypo.nano.weight, letterSpacing: d3roTypo.nano.spacing, lineHeight: d3roTypo.nano.line, textTransform: 'uppercase' },
hero: { fontSize: d3roTypo.hero.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.hero.weight, letterSpacing: d3roTypo.hero.spacing, lineHeight: d3roTypo.hero.line },
title: { fontSize: d3roTypo.title.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.title.weight, letterSpacing: d3roTypo.title.spacing, lineHeight: d3roTypo.title.line },
value: { fontSize: d3roTypo.value.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.value.weight, letterSpacing: d3roTypo.value.spacing, lineHeight: d3roTypo.value.line },
heading: { fontSize: d3roTypo.heading.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.heading.weight, letterSpacing: d3roTypo.heading.spacing, lineHeight: d3roTypo.heading.line },
body: { fontSize: d3roTypo.body.size, color: d3roPalette.text.secondary, fontWeight: d3roTypo.body.weight, letterSpacing: d3roTypo.body.spacing, lineHeight: d3roTypo.body.line },
compact: { fontSize: d3roTypo.compact.size, color: d3roPalette.text.primary, fontWeight: d3roTypo.compact.weight, letterSpacing: d3roTypo.compact.spacing, lineHeight: d3roTypo.compact.line },
small: { fontSize: d3roTypo.small.size, color: d3roPalette.text.inactive, fontWeight: d3roTypo.small.weight, letterSpacing: d3roTypo.small.spacing, lineHeight: d3roTypo.small.line },
meta: { fontSize: d3roTypo.meta.size, color: d3roPalette.text.inactive, fontWeight: d3roTypo.meta.weight, letterSpacing: d3roTypo.meta.spacing, lineHeight: d3roTypo.meta.line, textTransform: 'uppercase', mono: true },
label: { fontSize: d3roTypo.label.size, color: d3roPalette.text.label, fontWeight: d3roTypo.label.weight, letterSpacing: d3roTypo.label.spacing, lineHeight: d3roTypo.label.line, textTransform: 'uppercase' },
dim: { fontSize: d3roTypo.label.size, color: d3roPalette.text.inactive, fontWeight: d3roTypo.body.weight, letterSpacing: d3roTypo.label.spacing, lineHeight: d3roTypo.label.line },
engrave: { fontSize: d3roTypo.engrave.size, color: d3roPalette.text.dimLabel, fontWeight: d3roTypo.engrave.weight, letterSpacing: d3roTypo.engrave.spacing, lineHeight: d3roTypo.engrave.line, textTransform: 'uppercase', mono: true },
micro: { fontSize: d3roTypo.micro.size, color: d3roPalette.text.dimLabel, fontWeight: d3roTypo.micro.weight, letterSpacing: d3roTypo.micro.spacing, lineHeight: d3roTypo.micro.line, textTransform: 'uppercase', mono: true },
nano: { fontSize: d3roTypo.nano.size, color: d3roPalette.text.inactive, fontWeight: d3roTypo.nano.weight, letterSpacing: d3roTypo.nano.spacing, lineHeight: d3roTypo.nano.line, textTransform: 'uppercase', mono: true },
}
interface PhosphorTextProps extends Omit<TypographyProps, 'variant'> {
@ -54,11 +51,10 @@ export function PhosphorText({ variant = 'value', sx, ...props }: PhosphorTextPr
<Typography
{...props}
sx={{
fontFamily: d3roFontMono,
fontFamily: v.mono ? d3roFontMono : d3roFontSans,
fontSize: v.fontSize,
fontWeight: v.fontWeight,
color: v.color,
textShadow: v.glow !== 'none' ? v.glow : 'none',
letterSpacing: v.letterSpacing,
lineHeight: v.lineHeight,
fontVariantNumeric: 'tabular-nums',

View file

@ -1,11 +1,11 @@
'use client'
// src/renderer/components/ds/PhysicalButton.tsx
// 시안 A: 물리 버튼 — 돌출 그림자, 눌림 피드백, 선택 상태
// 토큰 적용: d3roShadow, d3roRadius, d3roTypo
// v2 "Midnight Glass": 글래스 버튼 — 기본은 반투명 표면 + 헤어라인,
// selected 시 액센트 그라디언트 + 글로우 (레퍼런스 프라이머리 버튼)
import { Button, type ButtonProps } from '@mui/material'
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '../../theme'
import { d3roPalette, d3roFontSans, d3roShadow, d3roRadius, d3roTypo } from '../../theme'
interface PhysicalButtonProps extends Omit<ButtonProps, 'variant'> {
selected?: boolean
@ -16,27 +16,29 @@ export function PhysicalButton({ selected = false, sx, ...props }: PhysicalButto
<Button
{...props}
sx={{
height: 44,
bgcolor: selected ? d3roPalette.bg.crtBezel : d3roPalette.bg.chassis,
border: 'none',
borderRadius: d3roRadius.small,
color: selected ? d3roPalette.accent.amber : d3roPalette.text.inactive,
fontFamily: d3roFontMono,
height: 42,
bgcolor: selected ? 'transparent' : d3roPalette.glass.raised,
backgroundImage: selected ? d3roPalette.gradient.accent : 'none',
border: `1px solid ${selected ? 'transparent' : d3roPalette.glass.hairline}`,
borderRadius: d3roRadius.button,
color: selected ? '#fff' : d3roPalette.text.secondary,
fontFamily: d3roFontSans,
fontSize: d3roTypo.small.size,
fontWeight: d3roTypo.small.weight,
fontWeight: 600,
cursor: 'pointer',
boxShadow: selected ? d3roShadow.buttonPressed : d3roShadow.buttonRaised,
transform: selected ? 'translateY(1px)' : 'none',
transition: 'all 0.05s linear',
boxShadow: selected ? d3roShadow.glowAccent : 'none',
transition: 'filter 0.15s ease, border-color 0.15s ease, color 0.15s ease, background-color 0.15s ease',
'&:active': {
transform: 'translateY(2px)',
boxShadow: d3roShadow.buttonActive,
filter: 'brightness(0.92)',
},
'&:hover': {
bgcolor: selected ? d3roPalette.bg.crtBezel : d3roPalette.bg.cardHover,
bgcolor: selected ? 'transparent' : d3roPalette.bg.cardHover,
borderColor: selected ? 'transparent' : d3roPalette.glass.hairlineStrong,
color: selected ? '#fff' : d3roPalette.text.primary,
filter: selected ? 'brightness(1.08)' : 'none',
},
textTransform: 'uppercase',
letterSpacing: d3roTypo.label.spacing,
textTransform: 'none',
letterSpacing: d3roTypo.small.spacing,
minWidth: 0,
...sx,
}}

View file

@ -1,12 +1,11 @@
'use client'
// src/renderer/components/ds/ScreenPanel.tsx
// 시안 A: CRT 없는 순수 스크린 패널 — 인셋 베젤 + 글래스 반사 + 인광 텍스트용
// 레퍼런스의 .display-module > .screen-glass 패턴
// v2 "Midnight Glass": 딥 글래스 패널 — 카드 내부의 어두운 정보 표시 영역
// (레퍼런스의 히어로 카드 내부 / "현재 백엔드" 내부 패널 패턴)
import { Box } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { d3roPalette, d3roShadow } from '../../theme'
import { d3roPalette, d3roRadius } from '../../theme'
interface ScreenPanelProps {
children: React.ReactNode
@ -15,45 +14,26 @@ interface ScreenPanelProps {
}
export function ScreenPanel({ children, height }: ScreenPanelProps): React.ReactElement {
const theme = useTheme()
const isLight = theme.palette.mode === 'light'
return (
<Box
sx={{
position: 'relative',
bgcolor: d3roPalette.bg.crtBezel,
borderRadius: '12px',
boxShadow: d3roShadow.insetDeep,
bgcolor: d3roPalette.bg.crtGlass,
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.glass.hairline}`,
overflow: 'hidden',
height,
// 상단 시인
'&::after': {
content: '""',
position: 'absolute',
inset: 0,
background: d3roPalette.glass.sheen,
pointerEvents: 'none',
zIndex: 1,
},
}}
>
{/* 글래스 배경 */}
<Box
sx={{
position: 'absolute',
inset: '2px',
borderRadius: '10px',
bgcolor: d3roPalette.bg.crtGlass,
boxShadow: d3roShadow.screenGlow,
// 상단 반사 (레퍼런스의 .screen-glass::after)
'&::after': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: '50%',
background: isLight
? 'linear-gradient(180deg, rgba(255,255,255,0.40) 0%, rgba(255,255,255,0) 100%)'
: 'linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0) 100%)',
pointerEvents: 'none',
zIndex: 10,
},
}}
/>
{/* 콘텐츠 오버레이 */}
<Box
sx={{
position: 'relative',

View file

@ -0,0 +1,66 @@
'use client'
// src/renderer/components/ds/StatRing.tsx
// v2 신규: 컬러 그라디언트 링 아이콘 — 스탯 타일의 원형 링 (레퍼런스 4타일 패턴)
import { Box } from '@mui/material'
import { d3roPalette } from '../../theme'
type RingColor = 'blue' | 'purple' | 'green' | 'orange' | 'red' | 'accent'
const RING_COLORS: Record<RingColor, { from: string; to: string; glow: string; icon: string }> = {
blue: { from: '#22d3ee', to: '#3b82f6', glow: 'rgba(59,130,246,0.35)', icon: d3roPalette.tag.blue },
purple: { from: '#8b5cf6', to: '#d946ef', glow: 'rgba(167,139,250,0.35)', icon: d3roPalette.tag.purple },
green: { from: '#34d399', to: '#10b981', glow: 'rgba(52,211,153,0.35)', icon: d3roPalette.tag.green },
orange: { from: '#fbbf24', to: '#f97316', glow: 'rgba(251,146,60,0.35)', icon: d3roPalette.tag.orange },
red: { from: '#f87171', to: '#ef4444', glow: 'rgba(248,113,113,0.35)', icon: d3roPalette.tag.red },
accent: { from: 'var(--d3-accent-light)', to: 'var(--d3-accent-dark)', glow: 'var(--d3-accent-dim)', icon: d3roPalette.accent.light },
}
interface StatRingProps {
/** 링 색 (기본 blue) */
color?: RingColor
/** 외경 px (기본 56) */
size?: number
/** 링 두께 px (기본 2) */
thickness?: number
/** 중앙 아이콘/콘텐츠 */
children?: React.ReactNode
}
export function StatRing({
color = 'blue',
size = 56,
thickness = 2,
children,
}: StatRingProps): React.ReactElement {
const c = RING_COLORS[color]
return (
<Box
sx={{
width: size,
height: size,
borderRadius: '50%',
p: `${thickness}px`,
background: `conic-gradient(from 210deg, ${c.from}, ${c.to}, ${c.from})`,
boxShadow: `0 0 ${size / 3.5}px ${c.glow}`,
flexShrink: 0,
}}
>
<Box
sx={{
width: '100%',
height: '100%',
borderRadius: '50%',
bgcolor: d3roPalette.bg.crtGlass,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: c.icon,
}}
>
{children}
</Box>
</Box>
)
}

View file

@ -10,3 +10,6 @@ export { PhosphorText } from './PhosphorText'
export { MetalDial } from './MetalDial'
export { ScreenPanel } from './ScreenPanel'
export { ButtonGroup } from './ButtonGroup'
// v2 "Midnight Glass" 신규
export { GradientWave } from './GradientWave'
export { StatRing } from './StatRing'

View file

@ -1,6 +1,13 @@
// src/renderer/theme.ts
// 08-design-system.md SSOT 기반 MUI 테마.
// D3RO 디자인 시스템 SSOT — "Midnight Glass" v2.
// CSS Custom Properties 기반: d3roPalette가 var()를 사용하여 테마 전환 시 자동 반응.
//
// v2 디자인 언어 (레퍼런스: 미드나이트 네이비 + 글래스모피즘):
// - 딥 네이비 베이스 + 반투명 글래스 카드 + 옅은 헤어라인
// - 시안→블루→퍼플→마젠타 그라디언트 (웨이브/프로그레스/로고)
// - 컬러 링 스탯 타일, 소프트 글로우, 보더리스 UI
// 테마 시스템: 6종(dark/light/nord/solarized/catppuccin/dracula) 전부
// 동일 토큰 구조를 가지며 glass/gradient/glow도 테마별로 파생된다.
import { createTheme, type Theme } from '@mui/material/styles'
import type { ThemeMode } from '@d3ro/core/types'
@ -58,120 +65,268 @@ interface RawTheme {
crtPhosphor: string
crtPhosphorDim: string
}
/** v2: 글래스모피즘 표면 */
glass: {
/** 카드 표면 (반투명) */
surface: string
/** 카드 내부 상승 표면 */
raised: string
/** 헤어라인 보더 */
hairline: string
hairlineStrong: string
/** 카드 상단 시인(광택) 오버레이 */
sheen: string
/** backdrop-filter blur 반경 */
blur: string
}
/** v2: 그라디언트 */
gradient: {
/** 프라이머리 버튼/액티브 요소 */
accent: string
/** 로고/브랜드 텍스트 */
logo: string
/** 프로그레스 바 (시안→블루) */
bar: string
/** 프로그레스 바 대체 (퍼플→마젠타) */
barAlt: string
/** 웨이브폼 컬러 스톱 4개 (canvas에서 getComputedStyle로 읽음) */
wave1: string
wave2: string
wave3: string
wave4: string
}
/** v2: 글로우 */
glow: {
/** 프라이머리 버튼/액티브 글로우 */
accent: string
soft: string
}
}
// ── 원시 색상값 (raw values) ────────────────────────────────────
// CSS 변수에 주입되는 실제 색상값. 컴포넌트에서 직접 사용하지 않는다.
const RAW: Record<ThemeKey, RawTheme> = {
// ── Midnight (기본 다크) — 레퍼런스 시안 ────────────────────
dark: {
bg: { app: '#19191b', card: '#242427', cardHover: '#2a2a2d', elevated: '#2e2e32', input: '#1e1e21', sidebar: '#1e1f21', inset: '#1b1c1e', chassis: '#242528', crtBezel: '#1a1a1c', crtGlass: '#050605' },
text: { primary: '#ffffff', secondary: '#8e8e93', label: '#7c7c82', disabled: '#4a4a4e', engraving: '#1a1a1c', inactive: '#77797c', dimLabel: '#5c2615', muted: '#3a3b3f', hover: '#aaaaaa' },
border: { subtle: 'rgba(255,255,255,0.04)', default: 'rgba(255,255,255,0.08)', strong: 'rgba(255,255,255,0.12)' },
bg: { app: '#0a0e1c', card: '#111a30', cardHover: '#152039', elevated: '#1a2540', input: '#0d1526', sidebar: '#0b101f', inset: '#0a111f', chassis: '#111a30', crtBezel: '#0d1424', crtGlass: '#070b16' },
text: { primary: '#eef2fb', secondary: '#93a4c8', label: '#67789e', disabled: '#3c4763', engraving: '#0a0e1c', inactive: '#7385ab', dimLabel: '#4e5f85', muted: '#2b3550', hover: '#c9d5f2' },
border: { subtle: 'rgba(148,180,255,0.07)', default: 'rgba(148,180,255,0.12)', strong: 'rgba(148,180,255,0.20)' },
shadow: {
card: '0 8px 30px rgba(0,0,0,0.3)',
buttonBase: '0 2px 0 rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06)',
chassis: '0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 #111111, 0 13px 4px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.15), inset 0 -1px 2px rgba(0,0,0,0.4)',
inset: 'inset 0 2px 6px rgba(0,0,0,0.6), 0 1px 1px rgba(255,255,255,0.05)',
insetDeep: 'inset 0 4px 12px rgba(0,0,0,0.9), inset 0 0 0 1px #000, 0 1px 1px rgba(255,255,255,0.1)',
buttonRaised: '0 3px 6px rgba(0,0,0,0.4), inset 0 1px 1px rgba(255,255,255,0.1), inset 0 -1px 2px rgba(0,0,0,0.2)',
buttonPressed: 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #000',
screenGlow: 'inset 0 0 20px rgba(0,0,0,0.8)',
tooltip: '0 8px 24px rgba(0,0,0,0.4)',
card: '0 12px 40px rgba(3,7,18,0.5)',
buttonBase: '0 1px 2px rgba(3,7,18,0.4)',
chassis: '0 24px 80px rgba(3,7,18,0.6)',
inset: 'inset 0 1px 4px rgba(3,7,18,0.5)',
insetDeep: 'inset 0 2px 8px rgba(3,7,18,0.7)',
buttonRaised: '0 2px 8px rgba(3,7,18,0.35), inset 0 1px 0 rgba(148,180,255,0.06)',
buttonPressed: 'inset 0 2px 6px rgba(3,7,18,0.6)',
screenGlow: 'inset 0 0 24px rgba(3,7,18,0.6)',
tooltip: '0 8px 24px rgba(3,7,18,0.5)',
},
accent: { main: '#3b82f6', dim: 'rgba(59,130,246,0.14)', glow: 'rgba(59,130,246,0.5)', light: '#60a5fa', dark: '#2563eb', crtPhosphor: '#60a5fa', crtPhosphorDim: '#3564b4' },
glass: {
surface: 'rgba(17,26,48,0.62)',
raised: 'rgba(26,38,68,0.55)',
hairline: 'rgba(148,180,255,0.10)',
hairlineStrong: 'rgba(148,180,255,0.18)',
sheen: 'linear-gradient(180deg, rgba(148,180,255,0.06) 0%, rgba(148,180,255,0) 42%)',
blur: '24px',
},
gradient: {
accent: 'linear-gradient(135deg, #1d4ed8 0%, #3b82f6 55%, #60a5fa 100%)',
logo: 'linear-gradient(90deg, #60a5fa 0%, #818cf8 100%)',
bar: 'linear-gradient(90deg, #22d3ee 0%, #3b82f6 100%)',
barAlt: 'linear-gradient(90deg, #8b5cf6 0%, #d946ef 100%)',
wave1: '#22d3ee', wave2: '#3b82f6', wave3: '#8b5cf6', wave4: '#e879f9',
},
glow: {
accent: '0 0 0 1px rgba(59,130,246,0.35), 0 8px 24px rgba(37,99,235,0.35)',
soft: '0 0 16px rgba(59,130,246,0.25)',
},
accent: { main: '#f25b29', dim: 'rgba(242,91,41,0.15)', glow: 'rgba(242,91,41,0.6)', light: '#ff7a4d', dark: '#c44a22', crtPhosphor: '#f25b29', crtPhosphorDim: '#c44a22' },
},
// ── Light — 블루 액센트 유지, 밝은 글래스 ────────────────────
light: {
bg: { app: '#f5f5f7', card: '#ffffff', cardHover: '#f7f7f9', elevated: '#f0f0f2', input: '#ffffff', sidebar: '#eeeef0', inset: '#e8e8ea', chassis: '#e2e2e5', crtBezel: '#d5d5d8', crtGlass: '#f0f0f2' },
text: { primary: '#1a1a1c', secondary: '#6e6e73', label: '#8e8e93', disabled: '#c7c7cc', engraving: '#d0d0d3', inactive: '#8e8e93', dimLabel: '#b07040', muted: '#b0b0b4', hover: '#555555' },
border: { subtle: 'rgba(0,0,0,0.06)', default: 'rgba(0,0,0,0.10)', strong: 'rgba(0,0,0,0.16)' },
bg: { app: '#f2f5fb', card: '#ffffff', cardHover: '#f7f9fd', elevated: '#eef2f9', input: '#ffffff', sidebar: '#e9eef7', inset: '#e4e9f2', chassis: '#ffffff', crtBezel: '#dde3ee', crtGlass: '#eef2f9' },
text: { primary: '#111827', secondary: '#4b5a75', label: '#6b7a95', disabled: '#b5bfd1', engraving: '#dde3ee', inactive: '#8593ab', dimLabel: '#5a6f96', muted: '#c3cddd', hover: '#2b3a55' },
border: { subtle: 'rgba(30,58,138,0.07)', default: 'rgba(30,58,138,0.12)', strong: 'rgba(30,58,138,0.20)' },
shadow: {
card: '0 4px 20px rgba(0,0,0,0.06)',
buttonBase: '0 2px 0 rgba(0,0,0,0.06), inset 0 1px 0 rgba(255,255,255,0.8)',
chassis: '0 40px 80px -20px rgba(0,0,0,0.08), 0 12px 0 #d5d5d8, 0 13px 4px rgba(0,0,0,0.06), inset 0 1px 1px rgba(255,255,255,0.8), inset 0 -1px 2px rgba(0,0,0,0.04)',
inset: 'inset 0 2px 6px rgba(0,0,0,0.08), 0 1px 1px rgba(255,255,255,0.6)',
insetDeep: 'inset 0 4px 12px rgba(0,0,0,0.12), inset 0 0 0 1px rgba(0,0,0,0.06), 0 1px 1px rgba(255,255,255,0.8)',
buttonRaised: '0 3px 6px rgba(0,0,0,0.08), inset 0 1px 1px rgba(255,255,255,0.8), inset 0 -1px 2px rgba(0,0,0,0.04)',
buttonPressed: 'inset 0 2px 6px rgba(0,0,0,0.12), inset 0 0 0 1px rgba(0,0,0,0.06)',
screenGlow: 'inset 0 0 20px rgba(0,0,0,0.06)',
tooltip: '0 8px 24px rgba(0,0,0,0.08)',
card: '0 8px 30px rgba(30,58,138,0.08)',
buttonBase: '0 1px 2px rgba(30,58,138,0.08)',
chassis: '0 24px 80px rgba(30,58,138,0.10)',
inset: 'inset 0 1px 4px rgba(30,58,138,0.06)',
insetDeep: 'inset 0 2px 8px rgba(30,58,138,0.10)',
buttonRaised: '0 2px 8px rgba(30,58,138,0.08), inset 0 1px 0 rgba(255,255,255,0.8)',
buttonPressed: 'inset 0 2px 6px rgba(30,58,138,0.10)',
screenGlow: 'inset 0 0 24px rgba(30,58,138,0.05)',
tooltip: '0 8px 24px rgba(30,58,138,0.10)',
},
accent: { main: '#2563eb', dim: 'rgba(37,99,235,0.10)', glow: 'rgba(37,99,235,0.35)', light: '#3b82f6', dark: '#1d4ed8', crtPhosphor: '#2563eb', crtPhosphorDim: '#6f96e8' },
glass: {
surface: 'rgba(255,255,255,0.72)',
raised: 'rgba(255,255,255,0.85)',
hairline: 'rgba(30,58,138,0.10)',
hairlineStrong: 'rgba(30,58,138,0.18)',
sheen: 'linear-gradient(180deg, rgba(255,255,255,0.6) 0%, rgba(255,255,255,0) 42%)',
blur: '24px',
},
gradient: {
accent: 'linear-gradient(135deg, #1d4ed8 0%, #2563eb 55%, #3b82f6 100%)',
logo: 'linear-gradient(90deg, #2563eb 0%, #6366f1 100%)',
bar: 'linear-gradient(90deg, #06b6d4 0%, #2563eb 100%)',
barAlt: 'linear-gradient(90deg, #7c3aed 0%, #c026d3 100%)',
wave1: '#06b6d4', wave2: '#2563eb', wave3: '#7c3aed', wave4: '#d946ef',
},
glow: {
accent: '0 0 0 1px rgba(37,99,235,0.25), 0 8px 24px rgba(37,99,235,0.25)',
soft: '0 0 16px rgba(37,99,235,0.18)',
},
accent: { main: '#f25b29', dim: 'rgba(242,91,41,0.15)', glow: 'rgba(242,91,41,0.6)', light: '#ff7a4d', dark: '#c44a22', crtPhosphor: '#f25b29', crtPhosphorDim: '#c44a22' },
},
// ── Nord (https://www.nordtheme.com/) ──────────────────────
nord: {
bg: { app: '#2e3440', card: '#3b4252', cardHover: '#434c5e', elevated: '#3b4252', input: '#3b4252', sidebar: '#2e3440', inset: '#2e3440', chassis: '#3b4252', crtBezel: '#2e3440', crtGlass: '#242831' },
text: { primary: '#eceff4', secondary: '#d8dee9', label: '#adb5c7', disabled: '#4c566a', engraving: '#2e3440', inactive: '#7b88a1', dimLabel: '#5e81ac', muted: '#434c5e', hover: '#e5e9f0' },
bg: { app: '#242933', card: '#2e3440', cardHover: '#343b49', elevated: '#3b4252', input: '#2a303c', sidebar: '#272c37', inset: '#232830', chassis: '#2e3440', crtBezel: '#272c37', crtGlass: '#1f242d' },
text: { primary: '#eceff4', secondary: '#aeb8cc', label: '#8b96ad', disabled: '#4c566a', engraving: '#242933', inactive: '#7b88a1', dimLabel: '#5e81ac', muted: '#434c5e', hover: '#e5e9f0' },
border: { subtle: 'rgba(216,222,233,0.06)', default: 'rgba(216,222,233,0.10)', strong: 'rgba(216,222,233,0.16)' },
shadow: {
card: '0 8px 30px rgba(0,0,0,0.35)',
buttonBase: '0 2px 0 rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06)',
chassis: '0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 #242831, 0 13px 4px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.1), inset 0 -1px 2px rgba(0,0,0,0.4)',
inset: 'inset 0 2px 6px rgba(0,0,0,0.5), 0 1px 1px rgba(255,255,255,0.04)',
insetDeep: 'inset 0 4px 12px rgba(0,0,0,0.8), inset 0 0 0 1px #242831, 0 1px 1px rgba(255,255,255,0.08)',
buttonRaised: '0 3px 6px rgba(0,0,0,0.35), inset 0 1px 1px rgba(255,255,255,0.08), inset 0 -1px 2px rgba(0,0,0,0.2)',
buttonPressed: 'inset 0 2px 6px rgba(0,0,0,0.7), inset 0 0 0 1px #242831',
screenGlow: 'inset 0 0 20px rgba(0,0,0,0.6)',
tooltip: '0 8px 24px rgba(0,0,0,0.4)',
card: '0 12px 40px rgba(10,13,18,0.45)',
buttonBase: '0 1px 2px rgba(10,13,18,0.4)',
chassis: '0 24px 80px rgba(10,13,18,0.55)',
inset: 'inset 0 1px 4px rgba(10,13,18,0.45)',
insetDeep: 'inset 0 2px 8px rgba(10,13,18,0.65)',
buttonRaised: '0 2px 8px rgba(10,13,18,0.3), inset 0 1px 0 rgba(216,222,233,0.06)',
buttonPressed: 'inset 0 2px 6px rgba(10,13,18,0.55)',
screenGlow: 'inset 0 0 24px rgba(10,13,18,0.5)',
tooltip: '0 8px 24px rgba(10,13,18,0.45)',
},
accent: { main: '#88c0d0', dim: 'rgba(136,192,208,0.14)', glow: 'rgba(136,192,208,0.5)', light: '#a3d0de', dark: '#6aacbe', crtPhosphor: '#88c0d0', crtPhosphorDim: '#5e9daf' },
glass: {
surface: 'rgba(46,52,64,0.62)',
raised: 'rgba(59,66,82,0.55)',
hairline: 'rgba(216,222,233,0.09)',
hairlineStrong: 'rgba(216,222,233,0.16)',
sheen: 'linear-gradient(180deg, rgba(216,222,233,0.05) 0%, rgba(216,222,233,0) 42%)',
blur: '24px',
},
gradient: {
accent: 'linear-gradient(135deg, #5e81ac 0%, #81a1c1 55%, #88c0d0 100%)',
logo: 'linear-gradient(90deg, #88c0d0 0%, #81a1c1 100%)',
bar: 'linear-gradient(90deg, #8fbcbb 0%, #5e81ac 100%)',
barAlt: 'linear-gradient(90deg, #b48ead 0%, #d08770 100%)',
wave1: '#8fbcbb', wave2: '#88c0d0', wave3: '#81a1c1', wave4: '#b48ead',
},
glow: {
accent: '0 0 0 1px rgba(136,192,208,0.3), 0 8px 24px rgba(94,129,172,0.35)',
soft: '0 0 16px rgba(136,192,208,0.22)',
},
// Nord Frost: #88c0d0 (차가운 시안)
accent: { main: '#88c0d0', dim: 'rgba(136,192,208,0.15)', glow: 'rgba(136,192,208,0.6)', light: '#a3d0de', dark: '#6aacbe', crtPhosphor: '#88c0d0', crtPhosphorDim: '#5e9daf' },
},
// ── Solarized Dark (https://ethanschoonover.com/solarized/) ─
solarized: {
bg: { app: '#002b36', card: '#073642', cardHover: '#0d4250', elevated: '#073642', input: '#073642', sidebar: '#002b36', inset: '#001f28', chassis: '#073642', crtBezel: '#002b36', crtGlass: '#001f28' },
text: { primary: '#eee8d5', secondary: '#93a1a1', label: '#839496', disabled: '#586e75', engraving: '#002b36', inactive: '#657b83', dimLabel: '#7a6c2e', muted: '#073642', hover: '#b2c0bf' },
bg: { app: '#00212b', card: '#073642', cardHover: '#0a3e4c', elevated: '#0d4250', input: '#03303c', sidebar: '#012731', inset: '#001f28', chassis: '#073642', crtBezel: '#012731', crtGlass: '#001b22' },
text: { primary: '#eee8d5', secondary: '#93a1a1', label: '#839496', disabled: '#586e75', engraving: '#00212b', inactive: '#657b83', dimLabel: '#7a6c2e', muted: '#073642', hover: '#b2c0bf' },
border: { subtle: 'rgba(131,148,150,0.08)', default: 'rgba(131,148,150,0.13)', strong: 'rgba(131,148,150,0.20)' },
shadow: {
card: '0 8px 30px rgba(0,0,0,0.4)',
buttonBase: '0 2px 0 rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.05)',
chassis: '0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 #001f28, 0 13px 4px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.08), inset 0 -1px 2px rgba(0,0,0,0.4)',
inset: 'inset 0 2px 6px rgba(0,0,0,0.6), 0 1px 1px rgba(255,255,255,0.04)',
insetDeep: 'inset 0 4px 12px rgba(0,0,0,0.9), inset 0 0 0 1px #001f28, 0 1px 1px rgba(255,255,255,0.06)',
buttonRaised: '0 3px 6px rgba(0,0,0,0.4), inset 0 1px 1px rgba(255,255,255,0.06), inset 0 -1px 2px rgba(0,0,0,0.2)',
buttonPressed: 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #001f28',
screenGlow: 'inset 0 0 20px rgba(0,0,0,0.7)',
tooltip: '0 8px 24px rgba(0,0,0,0.4)',
card: '0 12px 40px rgba(0,10,13,0.5)',
buttonBase: '0 1px 2px rgba(0,10,13,0.45)',
chassis: '0 24px 80px rgba(0,10,13,0.6)',
inset: 'inset 0 1px 4px rgba(0,10,13,0.5)',
insetDeep: 'inset 0 2px 8px rgba(0,10,13,0.7)',
buttonRaised: '0 2px 8px rgba(0,10,13,0.35), inset 0 1px 0 rgba(238,232,213,0.05)',
buttonPressed: 'inset 0 2px 6px rgba(0,10,13,0.6)',
screenGlow: 'inset 0 0 24px rgba(0,10,13,0.55)',
tooltip: '0 8px 24px rgba(0,10,13,0.5)',
},
accent: { main: '#b58900', dim: 'rgba(181,137,0,0.14)', glow: 'rgba(181,137,0,0.5)', light: '#d4a017', dark: '#8a6800', crtPhosphor: '#b58900', crtPhosphorDim: '#8a6800' },
glass: {
surface: 'rgba(7,54,66,0.62)',
raised: 'rgba(13,66,80,0.55)',
hairline: 'rgba(131,148,150,0.11)',
hairlineStrong: 'rgba(131,148,150,0.19)',
sheen: 'linear-gradient(180deg, rgba(238,232,213,0.04) 0%, rgba(238,232,213,0) 42%)',
blur: '24px',
},
gradient: {
accent: 'linear-gradient(135deg, #8a6800 0%, #b58900 55%, #d4a017 100%)',
logo: 'linear-gradient(90deg, #d4a017 0%, #cb4b16 100%)',
bar: 'linear-gradient(90deg, #2aa198 0%, #268bd2 100%)',
barAlt: 'linear-gradient(90deg, #6c71c4 0%, #d33682 100%)',
wave1: '#2aa198', wave2: '#268bd2', wave3: '#6c71c4', wave4: '#d33682',
},
glow: {
accent: '0 0 0 1px rgba(181,137,0,0.3), 0 8px 24px rgba(181,137,0,0.3)',
soft: '0 0 16px rgba(181,137,0,0.22)',
},
// Solarized yellow: #b58900 (따뜻한 골드)
accent: { main: '#b58900', dim: 'rgba(181,137,0,0.15)', glow: 'rgba(181,137,0,0.6)', light: '#d4a017', dark: '#8a6800', crtPhosphor: '#b58900', crtPhosphorDim: '#8a6800' },
},
// ── Catppuccin Mocha (https://catppuccin.com/) ──────────────
catppuccin: {
bg: { app: '#1e1e2e', card: '#313244', cardHover: '#3a3a54', elevated: '#313244', input: '#181825', sidebar: '#181825', inset: '#11111b', chassis: '#313244', crtBezel: '#1e1e2e', crtGlass: '#11111b' },
text: { primary: '#cdd6f4', secondary: '#bac2de', label: '#a6adc8', disabled: '#585b70', engraving: '#11111b', inactive: '#7f849c', dimLabel: '#585b70', muted: '#313244', hover: '#e6e9f8' },
border: { subtle: 'rgba(205,214,244,0.04)', default: 'rgba(205,214,244,0.08)', strong: 'rgba(205,214,244,0.14)' },
bg: { app: '#181825', card: '#1e1e2e', cardHover: '#242438', elevated: '#313244', input: '#191926', sidebar: '#161622', inset: '#11111b', chassis: '#1e1e2e', crtBezel: '#181825', crtGlass: '#0f0f18' },
text: { primary: '#cdd6f4', secondary: '#a8b2d8', label: '#8a94bc', disabled: '#585b70', engraving: '#11111b', inactive: '#7f849c', dimLabel: '#585b70', muted: '#313244', hover: '#e6e9f8' },
border: { subtle: 'rgba(205,214,244,0.05)', default: 'rgba(205,214,244,0.09)', strong: 'rgba(205,214,244,0.15)' },
shadow: {
card: '0 8px 30px rgba(0,0,0,0.4)',
buttonBase: '0 2px 0 rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.05)',
chassis: '0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 #11111b, 0 13px 4px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.08), inset 0 -1px 2px rgba(0,0,0,0.4)',
inset: 'inset 0 2px 6px rgba(0,0,0,0.6), 0 1px 1px rgba(255,255,255,0.04)',
insetDeep: 'inset 0 4px 12px rgba(0,0,0,0.9), inset 0 0 0 1px #11111b, 0 1px 1px rgba(255,255,255,0.06)',
buttonRaised: '0 3px 6px rgba(0,0,0,0.4), inset 0 1px 1px rgba(255,255,255,0.06), inset 0 -1px 2px rgba(0,0,0,0.2)',
buttonPressed: 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #11111b',
screenGlow: 'inset 0 0 20px rgba(0,0,0,0.7)',
tooltip: '0 8px 24px rgba(0,0,0,0.4)',
card: '0 12px 40px rgba(5,5,12,0.5)',
buttonBase: '0 1px 2px rgba(5,5,12,0.45)',
chassis: '0 24px 80px rgba(5,5,12,0.6)',
inset: 'inset 0 1px 4px rgba(5,5,12,0.5)',
insetDeep: 'inset 0 2px 8px rgba(5,5,12,0.7)',
buttonRaised: '0 2px 8px rgba(5,5,12,0.35), inset 0 1px 0 rgba(205,214,244,0.05)',
buttonPressed: 'inset 0 2px 6px rgba(5,5,12,0.6)',
screenGlow: 'inset 0 0 24px rgba(5,5,12,0.55)',
tooltip: '0 8px 24px rgba(5,5,12,0.5)',
},
accent: { main: '#cba6f7', dim: 'rgba(203,166,247,0.14)', glow: 'rgba(203,166,247,0.5)', light: '#dfc0ff', dark: '#a67fd4', crtPhosphor: '#cba6f7', crtPhosphorDim: '#a67fd4' },
glass: {
surface: 'rgba(30,30,46,0.62)',
raised: 'rgba(49,50,68,0.55)',
hairline: 'rgba(205,214,244,0.08)',
hairlineStrong: 'rgba(205,214,244,0.15)',
sheen: 'linear-gradient(180deg, rgba(205,214,244,0.05) 0%, rgba(205,214,244,0) 42%)',
blur: '24px',
},
gradient: {
accent: 'linear-gradient(135deg, #a67fd4 0%, #cba6f7 55%, #f5c2e7 100%)',
logo: 'linear-gradient(90deg, #89b4fa 0%, #cba6f7 100%)',
bar: 'linear-gradient(90deg, #89dceb 0%, #89b4fa 100%)',
barAlt: 'linear-gradient(90deg, #cba6f7 0%, #f5c2e7 100%)',
wave1: '#89dceb', wave2: '#89b4fa', wave3: '#cba6f7', wave4: '#f5c2e7',
},
glow: {
accent: '0 0 0 1px rgba(203,166,247,0.3), 0 8px 24px rgba(203,166,247,0.3)',
soft: '0 0 16px rgba(203,166,247,0.22)',
},
// Catppuccin Mauve: #cba6f7 (보라 파스텔)
accent: { main: '#cba6f7', dim: 'rgba(203,166,247,0.15)', glow: 'rgba(203,166,247,0.6)', light: '#dfc0ff', dark: '#a67fd4', crtPhosphor: '#cba6f7', crtPhosphorDim: '#a67fd4' },
},
// ── Dracula (https://draculatheme.com/) ─────────────────────
dracula: {
bg: { app: '#282a36', card: '#44475a', cardHover: '#4f5266', elevated: '#383a4a', input: '#383a4a', sidebar: '#21222c', inset: '#21222c', chassis: '#44475a', crtBezel: '#282a36', crtGlass: '#1e2029' },
text: { primary: '#f8f8f2', secondary: '#a9b0d0', label: '#8891b5', disabled: '#6272a4', engraving: '#21222c', inactive: '#6272a4', dimLabel: '#6272a4', muted: '#44475a', hover: '#ffffff' },
border: { subtle: 'rgba(248,248,242,0.04)', default: 'rgba(248,248,242,0.08)', strong: 'rgba(248,248,242,0.14)' },
bg: { app: '#1e2029', card: '#282a36', cardHover: '#2e3040', elevated: '#383a4a', input: '#232530', sidebar: '#21222c', inset: '#1b1c25', chassis: '#282a36', crtBezel: '#21222c', crtGlass: '#181a21' },
text: { primary: '#f8f8f2', secondary: '#b6bdd9', label: '#8891b5', disabled: '#6272a4', engraving: '#21222c', inactive: '#6272a4', dimLabel: '#6272a4', muted: '#44475a', hover: '#ffffff' },
border: { subtle: 'rgba(248,248,242,0.05)', default: 'rgba(248,248,242,0.09)', strong: 'rgba(248,248,242,0.15)' },
shadow: {
card: '0 8px 30px rgba(0,0,0,0.4)',
buttonBase: '0 2px 0 rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.06)',
chassis: '0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 #1e2029, 0 13px 4px rgba(0,0,0,0.5), inset 0 1px 1px rgba(255,255,255,0.1), inset 0 -1px 2px rgba(0,0,0,0.4)',
inset: 'inset 0 2px 6px rgba(0,0,0,0.6), 0 1px 1px rgba(255,255,255,0.04)',
insetDeep: 'inset 0 4px 12px rgba(0,0,0,0.9), inset 0 0 0 1px #1e2029, 0 1px 1px rgba(255,255,255,0.08)',
buttonRaised: '0 3px 6px rgba(0,0,0,0.4), inset 0 1px 1px rgba(255,255,255,0.08), inset 0 -1px 2px rgba(0,0,0,0.2)',
buttonPressed: 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #1e2029',
screenGlow: 'inset 0 0 20px rgba(0,0,0,0.7)',
tooltip: '0 8px 24px rgba(0,0,0,0.4)',
card: '0 12px 40px rgba(8,9,13,0.5)',
buttonBase: '0 1px 2px rgba(8,9,13,0.45)',
chassis: '0 24px 80px rgba(8,9,13,0.6)',
inset: 'inset 0 1px 4px rgba(8,9,13,0.5)',
insetDeep: 'inset 0 2px 8px rgba(8,9,13,0.7)',
buttonRaised: '0 2px 8px rgba(8,9,13,0.35), inset 0 1px 0 rgba(248,248,242,0.06)',
buttonPressed: 'inset 0 2px 6px rgba(8,9,13,0.6)',
screenGlow: 'inset 0 0 24px rgba(8,9,13,0.55)',
tooltip: '0 8px 24px rgba(8,9,13,0.5)',
},
accent: { main: '#bd93f9', dim: 'rgba(189,147,249,0.14)', glow: 'rgba(189,147,249,0.5)', light: '#d4b8ff', dark: '#9a70e0', crtPhosphor: '#bd93f9', crtPhosphorDim: '#9a70e0' },
glass: {
surface: 'rgba(40,42,54,0.62)',
raised: 'rgba(56,58,74,0.55)',
hairline: 'rgba(248,248,242,0.08)',
hairlineStrong: 'rgba(248,248,242,0.15)',
sheen: 'linear-gradient(180deg, rgba(248,248,242,0.05) 0%, rgba(248,248,242,0) 42%)',
blur: '24px',
},
gradient: {
accent: 'linear-gradient(135deg, #9a70e0 0%, #bd93f9 55%, #ff79c6 100%)',
logo: 'linear-gradient(90deg, #8be9fd 0%, #bd93f9 100%)',
bar: 'linear-gradient(90deg, #8be9fd 0%, #bd93f9 100%)',
barAlt: 'linear-gradient(90deg, #ff79c6 0%, #ffb86c 100%)',
wave1: '#8be9fd', wave2: '#bd93f9', wave3: '#ff79c6', wave4: '#ffb86c',
},
glow: {
accent: '0 0 0 1px rgba(189,147,249,0.3), 0 8px 24px rgba(189,147,249,0.3)',
soft: '0 0 16px rgba(189,147,249,0.22)',
},
// Dracula Purple: #bd93f9 (시그니처 퍼플)
accent: { main: '#bd93f9', dim: 'rgba(189,147,249,0.15)', glow: 'rgba(189,147,249,0.6)', light: '#d4b8ff', dark: '#9a70e0', crtPhosphor: '#bd93f9', crtPhosphorDim: '#9a70e0' },
},
}
@ -191,24 +346,58 @@ export const d3roPalette = {
crtGlass: 'var(--d3-bg-crtGlass)',
},
accent: {
/** 프라이머리 액센트 (v2 별칭 — amber와 동일 var) */
main: 'var(--d3-accent-main)',
light: 'var(--d3-accent-light)',
dark: 'var(--d3-accent-dark)',
dim: 'var(--d3-accent-dim)',
/** @deprecated v2에서 main으로 개명 — 기존 사용처 호환용 별칭 */
amber: 'var(--d3-accent-main)',
amberDim: 'var(--d3-accent-dim)',
amberGlow: 'var(--d3-accent-glow)',
glow: 'var(--d3-accent-glow)',
},
/** v2: 글래스 표면 토큰 */
glass: {
surface: 'var(--d3-glass-surface)',
raised: 'var(--d3-glass-raised)',
hairline: 'var(--d3-glass-hairline)',
hairlineStrong: 'var(--d3-glass-hairlineStrong)',
sheen: 'var(--d3-glass-sheen)',
blur: 'var(--d3-glass-blur)',
},
/** v2: 그라디언트 토큰 */
gradient: {
accent: 'var(--d3-gradient-accent)',
logo: 'var(--d3-gradient-logo)',
bar: 'var(--d3-gradient-bar)',
barAlt: 'var(--d3-gradient-barAlt)',
wave1: 'var(--d3-gradient-wave1)',
wave2: 'var(--d3-gradient-wave2)',
wave3: 'var(--d3-gradient-wave3)',
wave4: 'var(--d3-gradient-wave4)',
},
/** v2: 글로우 토큰 */
glow: {
accent: 'var(--d3-glow-accent)',
soft: 'var(--d3-glow-soft)',
},
tag: {
purple: '#b854f5',
purpleBg: 'rgba(184, 84, 245, 0.12)',
orange: '#f59e0b',
orangeBg: 'rgba(245, 158, 11, 0.12)',
red: '#ef4444',
redBg: 'rgba(239, 68, 68, 0.12)',
green: '#22c55e',
greenBg: 'rgba(34, 197, 94, 0.12)',
blue: '#3b82f6',
blueBg: 'rgba(59, 130, 246, 0.12)',
greenGlow: 'rgba(34, 197, 94, 0.6)',
redGlow: 'rgba(239, 68, 68, 0.6)',
orangeGlow: 'rgba(245, 158, 11, 0.6)',
purple: '#a78bfa',
purpleBg: 'rgba(167, 139, 250, 0.12)',
orange: '#fb923c',
orangeBg: 'rgba(251, 146, 60, 0.12)',
red: '#f87171',
redBg: 'rgba(248, 113, 113, 0.12)',
green: '#34d399',
greenBg: 'rgba(52, 211, 153, 0.12)',
blue: '#60a5fa',
blueBg: 'rgba(96, 165, 250, 0.12)',
greenGlow: 'rgba(52, 211, 153, 0.55)',
redGlow: 'rgba(248, 113, 113, 0.55)',
orangeGlow: 'rgba(251, 146, 60, 0.55)',
purpleGlow: 'rgba(167, 139, 250, 0.55)',
blueGlow: 'rgba(96, 165, 250, 0.55)',
},
text: {
primary: 'var(--d3-text-primary)',
@ -233,13 +422,13 @@ export const d3roPalette = {
bg: 'var(--d3-bg-chassis)',
},
led: {
off: '#111111',
off: '#1c2336',
},
} as const
export const d3roFontSans = [
'-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto',
'"Helvetica Neue"', 'Arial', 'sans-serif',
'"Pretendard Variable"', 'Pretendard', '-apple-system', 'BlinkMacSystemFont',
'"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif',
].join(',')
export const d3roFontMono = [
@ -248,19 +437,20 @@ export const d3roFontMono = [
].join(',')
// ── SSOT: 타이포그래피 토큰 ─────────────────────────────
// v2: 클린 산세리프(Pretendard) 기반, 굵은 헤드라인 + 큰 수치
export const d3roTypo = {
hero: { size: '42px', weight: 300, spacing: '-2px', line: 1 },
title: { size: '28px', weight: 300, spacing: '-1px', line: 1.2 },
value: { size: '20px', weight: 400, spacing: '0.02em', line: 1 },
heading: { size: '16px', weight: 600, spacing: '0.02em', line: 1.4 },
body: { size: '14px', weight: 400, spacing: '0.01em', line: 1.5 },
compact: { size: '13px', weight: 400, spacing: '0.01em', line: 1.5 },
small: { size: '12px', weight: 600, spacing: '0.03em', line: 1.4 },
meta: { size: '11px', weight: 600, spacing: '0.05em', line: 1.3 },
label: { size: '10px', weight: 700, spacing: '2px', line: 1.2 },
engrave: { size: '9px', weight: 700, spacing: '1.5px', line: 1 },
micro: { size: '8px', weight: 700, spacing: '1.5px', line: 1 },
nano: { size: '7px', weight: 700, spacing: '0.5px', line: 1 },
hero: { size: '32px', weight: 700, spacing: '-0.5px', line: 1.2 },
title: { size: '24px', weight: 700, spacing: '-0.3px', line: 1.25 },
value: { size: '20px', weight: 600, spacing: '0', line: 1.1 },
heading: { size: '16px', weight: 600, spacing: '0', line: 1.4 },
body: { size: '14px', weight: 400, spacing: '0', line: 1.55 },
compact: { size: '13px', weight: 400, spacing: '0', line: 1.5 },
small: { size: '12px', weight: 500, spacing: '0.01em', line: 1.4 },
meta: { size: '11px', weight: 600, spacing: '0.04em', line: 1.3 },
label: { size: '11px', weight: 600, spacing: '0.08em', line: 1.2 },
engrave: { size: '9px', weight: 700, spacing: '1px', line: 1 },
micro: { size: '9px', weight: 700, spacing: '1px', line: 1 },
nano: { size: '8px', weight: 700, spacing: '0.5px', line: 1 },
} as const
/**
@ -294,16 +484,19 @@ export const d3roShadow = {
dialog: 'var(--d3-shadow-card)',
tooltip: 'var(--d3-shadow-tooltip)',
screenGlow: 'var(--d3-shadow-screenGlow)',
/** v2: 액센트 글로우 */
glowAccent: 'var(--d3-glow-accent)',
glowSoft: 'var(--d3-glow-soft)',
} as const
// ── SSOT: 반경 토큰 ────────────────────────────────────
export const d3roRadius = {
outer: '24px',
card: '22px',
outer: '20px',
card: '16px',
inner: '12px',
button: '8px',
small: '6px',
xs: '4px',
button: '10px',
small: '8px',
xs: '6px',
pill: '999px',
} as const
@ -349,6 +542,22 @@ function buildCssVars(key: ThemeKey): Record<string, string> {
'--d3-accent-dark': r.accent.dark,
'--d3-accent-crtPhosphor': r.accent.crtPhosphor,
'--d3-accent-crtPhosphorDim': r.accent.crtPhosphorDim,
'--d3-glass-surface': r.glass.surface,
'--d3-glass-raised': r.glass.raised,
'--d3-glass-hairline': r.glass.hairline,
'--d3-glass-hairlineStrong': r.glass.hairlineStrong,
'--d3-glass-sheen': r.glass.sheen,
'--d3-glass-blur': r.glass.blur,
'--d3-gradient-accent': r.gradient.accent,
'--d3-gradient-logo': r.gradient.logo,
'--d3-gradient-bar': r.gradient.bar,
'--d3-gradient-barAlt': r.gradient.barAlt,
'--d3-gradient-wave1': r.gradient.wave1,
'--d3-gradient-wave2': r.gradient.wave2,
'--d3-gradient-wave3': r.gradient.wave3,
'--d3-gradient-wave4': r.gradient.wave4,
'--d3-glow-accent': r.glow.accent,
'--d3-glow-soft': r.glow.soft,
}
}
@ -369,7 +578,7 @@ function createD3ROTheme(key: ThemeKey): Theme {
palette: {
mode: muiMode,
primary: { main: accentMain, light: accentLight, dark: accentDark, contrastText: '#fff' },
secondary: { main: d3roPalette.tag.purple, light: '#d084ff', dark: '#8a3cc4' },
secondary: { main: d3roPalette.tag.purple, light: '#c4b5fd', dark: '#8b5cf6' },
error: { main: d3roPalette.tag.red },
warning: { main: d3roPalette.tag.orange },
success: { main: d3roPalette.tag.green },
@ -386,15 +595,18 @@ function createD3ROTheme(key: ThemeKey): Theme {
body1: { fontSize: '14px', lineHeight: 1.5 },
body2: { fontSize: '12px', lineHeight: 1.4 },
button: { textTransform: 'none' as const, fontWeight: 600, fontSize: '14px' },
caption: { fontSize: '11px', fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase' as const, color: r.text.label },
overline: { fontSize: '11px', fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase' as const, lineHeight: 1.2 },
caption: { fontSize: '11px', fontWeight: 600, letterSpacing: '0.06em', color: r.text.label },
overline: { fontSize: '11px', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase' as const, lineHeight: 1.2 },
},
shape: { borderRadius: 22 },
shape: { borderRadius: 16 },
components: {
MuiCssBaseline: {
styleOverrides: {
':root': cssVars,
body: { backgroundColor: r.bg.app, color: r.text.primary },
// 보더리스 UI: 네이티브 스크롤바 제거 (레이아웃 폭 불변).
// 시각적 스크롤 인디케이터는 OverlayScrollbars(고유 스타일)가 담당.
'*::-webkit-scrollbar': { width: 0, height: 0, background: 'transparent' },
},
},
MuiButton: {
@ -402,23 +614,27 @@ function createD3ROTheme(key: ThemeKey): Theme {
styleOverrides: {
root: {
textTransform: 'none', fontWeight: 600, borderRadius: 10, padding: '10px 20px',
transition: 'transform 0.05s linear, box-shadow 0.05s linear',
boxShadow: r.shadow.buttonBase,
'&:active': { transform: 'translateY(2px)', boxShadow: 'var(--d3-shadow-buttonPressed)' },
transition: 'filter 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease',
},
containedPrimary: {
color: '#fff',
'&:hover': { backgroundColor: accentDark },
backgroundImage: 'var(--d3-gradient-accent)',
boxShadow: 'var(--d3-glow-accent)',
'&:hover': { filter: 'brightness(1.1)', boxShadow: 'var(--d3-glow-accent)' },
'&:active': { filter: 'brightness(0.95)' },
'&.Mui-disabled': { backgroundImage: 'none' },
},
outlined: {
borderColor: r.border.strong,
borderColor: 'var(--d3-glass-hairlineStrong)',
backgroundColor: 'var(--d3-glass-raised)',
color: r.text.primary,
'&:hover': { borderColor: accentMain, color: accentMain },
'&:hover': { borderColor: accentMain, color: accentLight, backgroundColor: 'var(--d3-glass-raised)' },
},
containedSecondary: {
backgroundColor: r.bg.elevated,
color: r.text.primary,
'&:hover': { backgroundColor: muiMode === 'dark' ? '#353539' : '#e5e5e7' },
border: `1px solid ${r.glass.hairline}`,
'&:hover': { backgroundColor: r.bg.cardHover },
},
},
},
@ -426,42 +642,54 @@ function createD3ROTheme(key: ThemeKey): Theme {
defaultProps: { elevation: 0 },
styleOverrides: {
root: {
backgroundColor: r.bg.card, borderRadius: 22,
borderTop: `1px solid ${r.border.subtle}`,
backgroundColor: 'var(--d3-glass-surface)',
backdropFilter: 'blur(var(--d3-glass-blur))',
borderRadius: 16,
border: `1px solid ${r.glass.hairline}`,
boxShadow: r.shadow.card,
transition: 'background-color 0.2s ease',
'&:hover': { backgroundColor: r.bg.cardHover },
transition: 'border-color 0.2s ease',
'&:hover': { borderColor: r.glass.hairlineStrong },
},
},
},
MuiChip: {
styleOverrides: {
root: { borderRadius: 999, fontWeight: 700, fontSize: '11px', letterSpacing: '0.1em', textTransform: 'uppercase', height: 24 },
colorPrimary: { backgroundColor: accentDim, color: accentMain },
root: { borderRadius: 999, fontWeight: 600, fontSize: '11px', letterSpacing: '0.04em', height: 24 },
colorPrimary: { backgroundColor: accentDim, color: accentLight },
colorSecondary: { backgroundColor: d3roPalette.tag.purpleBg, color: d3roPalette.tag.purple },
colorSuccess: { backgroundColor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green },
colorError: { backgroundColor: d3roPalette.tag.redBg, color: d3roPalette.tag.red },
colorWarning: { backgroundColor: d3roPalette.tag.orangeBg, color: d3roPalette.tag.orange },
},
},
MuiDrawer: { styleOverrides: { paper: { width: 240, backgroundColor: r.bg.app, borderRight: `1px solid ${r.border.subtle}` } } },
MuiDrawer: { styleOverrides: { paper: { width: 240, backgroundColor: r.bg.sidebar, borderRight: `1px solid ${r.glass.hairline}` } } },
MuiListItemButton: {
styleOverrides: {
root: {
borderRadius: 10, marginLeft: 8, marginRight: 8,
'&.Mui-selected': { backgroundColor: accentDim, color: accentMain, fontWeight: 600, '&:hover': { backgroundColor: accentDim } },
borderRadius: 12, marginLeft: 8, marginRight: 8,
'&.Mui-selected': { backgroundColor: accentDim, color: accentLight, fontWeight: 600, '&:hover': { backgroundColor: accentDim } },
},
},
},
MuiDialog: {
styleOverrides: {
paper: {
backgroundColor: r.bg.card, borderRadius: 22,
border: `1px solid ${r.border.subtle}`,
backgroundColor: r.bg.card, borderRadius: 20,
backgroundImage: 'var(--d3-glass-sheen)',
border: `1px solid ${r.glass.hairline}`,
boxShadow: 'var(--d3-shadow-card)',
},
},
},
MuiBackdrop: {
styleOverrides: {
root: {
backgroundColor: muiMode === 'dark' ? 'rgba(4,8,20,0.6)' : 'rgba(30,58,138,0.18)',
backdropFilter: 'blur(8px)',
'&.MuiBackdrop-invisible': { backgroundColor: 'transparent', backdropFilter: 'none' },
},
},
},
MuiTextField: {
defaultProps: { size: 'small', variant: 'outlined' },
styleOverrides: {
@ -469,8 +697,8 @@ function createD3ROTheme(key: ThemeKey): Theme {
'& .MuiOutlinedInput-root': {
backgroundColor: r.bg.input, borderRadius: 10,
color: r.text.primary,
'& fieldset': { borderColor: r.border.default },
'&:hover fieldset': { borderColor: r.border.strong },
'& fieldset': { borderColor: r.glass.hairline },
'&:hover fieldset': { borderColor: r.glass.hairlineStrong },
'&.Mui-focused fieldset': { borderColor: accentMain },
},
},
@ -512,11 +740,16 @@ function createD3ROTheme(key: ThemeKey): Theme {
MuiTooltip: {
defaultProps: { arrow: true },
styleOverrides: {
tooltip: { backgroundColor: r.bg.elevated, color: r.text.primary, fontSize: '12px', borderRadius: 8, border: `1px solid ${r.border.subtle}` },
tooltip: { backgroundColor: r.bg.elevated, color: r.text.primary, fontSize: '12px', borderRadius: 8, border: `1px solid ${r.glass.hairline}` },
},
},
MuiTabs: { styleOverrides: { indicator: { backgroundColor: accentMain, height: 2, borderRadius: 2 } } },
MuiTab: { styleOverrides: { root: { textTransform: 'none', fontWeight: 500, fontSize: '14px', '&.Mui-selected': { color: accentLight, fontWeight: 600 } } } },
MuiPaper: {
styleOverrides: {
root: { backgroundImage: 'none' },
},
},
MuiTabs: { styleOverrides: { indicator: { backgroundColor: accentMain } } },
MuiTab: { styleOverrides: { root: { textTransform: 'none', fontWeight: 500, fontSize: '14px', '&.Mui-selected': { color: accentMain, fontWeight: 600 } } } },
},
})
}