Phase 8: SSOT 리팩토링 + HotkeyRecordModal + Dashboard 재작성

- d3roPalette 11개 토큰 추가 (sidebar, chassis, inactive 등)
- DS 컴포넌트 6개 + 페이지 5개 매직넘버 → 팔레트 참조 (0개 잔여)
- HotkeyRecordModal 신규: 커스텀 핫키 녹화 모달
- SettingsModal 재작성: 음성 모드 3개(받아쓰기/Agent/원터치) + 핫키 변경
- DashboardPage 재작성: Hero + 통계 4카드 + CRT 서비스 상태 + 히스토리 날짜 그룹핑
- recording-tip 색상 수정: #1F5DF2(파란) → #f25b29(앰버)
- 설계 문서: phase-8.md, speakly-settings-ui.md
This commit is contained in:
Yun Chan 2026-04-05 09:54:55 +09:00
parent f41fc277e3
commit 28371a9d1a
19 changed files with 1879 additions and 403 deletions

View file

@ -15,6 +15,7 @@ import { DictionaryPage } from '../pages/DictionaryPage'
import { CommandsPage } from '../pages/CommandsPage'
import { SettingsModal } from './SettingsModal'
import { StatusBar } from './StatusBar'
import { d3roPalette, d3roFontMono } from '../theme'
type Route = 'dashboard' | 'history' | 'dictionary' | 'commands'
@ -30,15 +31,15 @@ export function AppLayout(): React.ReactElement {
const [settingsOpen, setSettingsOpen] = useState(false)
return (
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column', bgcolor: '#19191b' }}>
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column', bgcolor: d3roPalette.bg.app }}>
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* ── 사이드바: 인스트루먼트 섀시 스타일 ─── */}
<Box
sx={{
width: 72,
flexShrink: 0,
bgcolor: '#1e1f21',
borderRight: '1px solid rgba(255,255,255,0.04)',
bgcolor: d3roPalette.bg.sidebar,
borderRight: `1px solid ${d3roPalette.border.subtle}`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
@ -52,9 +53,9 @@ export function AppLayout(): React.ReactElement {
<Typography
sx={{
fontSize: '8px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontFamily: d3roFontMono,
letterSpacing: '1.5px',
color: '#5c2615',
color: d3roPalette.text.dimLabel,
fontWeight: 700,
}}
>
@ -79,11 +80,11 @@ export function AppLayout(): React.ReactElement {
justifyContent: 'center',
gap: 0.5,
cursor: 'pointer',
bgcolor: isActive ? '#242528' : 'transparent',
bgcolor: isActive ? d3roPalette.bg.chassis : 'transparent',
boxShadow: isActive
? 'inset 0 2px 6px rgba(0,0,0,0.8), inset 0 0 0 1px #000'
: '0 2px 4px rgba(0,0,0,0.3), inset 0 1px 1px rgba(255,255,255,0.06)',
color: isActive ? '#f25b29' : '#77797c',
color: isActive ? d3roPalette.accent.amber : d3roPalette.text.inactive,
transition: 'all 0.05s linear',
transform: isActive ? 'translateY(1px)' : 'none',
'&:active': {
@ -91,7 +92,7 @@ export function AppLayout(): React.ReactElement {
boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.6)',
},
'&:hover': {
color: isActive ? '#f25b29' : '#aaa',
color: isActive ? d3roPalette.accent.amber : d3roPalette.text.hover,
},
}}
>
@ -99,7 +100,7 @@ export function AppLayout(): React.ReactElement {
<Typography
sx={{
fontSize: '7px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontFamily: d3roFontMono,
fontWeight: 700,
letterSpacing: '0.5px',
}}
@ -126,14 +127,14 @@ export function AppLayout(): React.ReactElement {
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: '#77797c',
color: d3roPalette.text.inactive,
boxShadow: '0 2px 4px rgba(0,0,0,0.3), inset 0 1px 1px rgba(255,255,255,0.06)',
transition: 'all 0.05s linear',
'&:active': {
transform: 'translateY(2px)',
boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.6)',
},
'&:hover': { color: '#aaa' },
'&:hover': { color: d3roPalette.text.hover },
}}
>
<SettingsIcon sx={{ fontSize: 20 }} />
@ -147,9 +148,9 @@ export function AppLayout(): React.ReactElement {
sx={{
flexGrow: 1,
overflow: 'auto',
bgcolor: '#19191b',
bgcolor: d3roPalette.bg.app,
// 미묘한 방사형 비네팅 (시안 A 배경)
background: 'radial-gradient(circle at 50% 30%, #252628 0%, #19191b 70%)',
background: `radial-gradient(circle at 50% 30%, ${d3roPalette.bg.chassis} 0%, ${d3roPalette.bg.app} 70%)`,
}}
>
{currentRoute === 'dashboard' && <DashboardPage />}

View file

@ -0,0 +1,334 @@
// src/renderer/components/HotkeyRecordModal.tsx
// Speakly HotkeyRecordModal 패턴: Modal → 키 입력 대기 → Chip 표시 → 저장/취소
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Box,
Button,
Chip,
Stack,
Typography,
} from '@mui/material'
import { d3roPalette } from '../theme'
import type { HotkeyBinding } from '@shared/types'
// ── 키 이름 매핑 (Windows 전용) ──────────────────────────
const KEY_DISPLAY_MAP: Record<number, string> = {
// Modifier keys
160: 'Left Shift',
161: 'Right Shift',
162: 'Left Ctrl',
163: 'Right Ctrl',
164: 'Left Alt',
165: 'Right Alt',
91: 'Left Win',
92: 'Right Win',
// Common keys
8: 'Backspace',
9: 'Tab',
13: 'Enter',
19: 'Pause',
20: 'Caps Lock',
27: 'Escape',
32: 'Space',
33: 'Page Up',
34: 'Page Down',
35: 'End',
36: 'Home',
37: 'Left',
38: 'Up',
39: 'Right',
40: 'Down',
45: 'Insert',
46: 'Delete',
// F-keys
112: 'F1', 113: 'F2', 114: 'F3', 115: 'F4',
116: 'F5', 117: 'F6', 118: 'F7', 119: 'F8',
120: 'F9', 121: 'F10', 122: 'F11', 123: 'F12',
// Number keys
48: '0', 49: '1', 50: '2', 51: '3', 52: '4',
53: '5', 54: '6', 55: '7', 56: '8', 57: '9',
}
// 시스템 예약 단축키 블랙리스트 (Windows)
const RESERVED_COMBOS: Array<{ ctrl: boolean; alt: boolean; shift: boolean; key: string }> = [
{ ctrl: true, alt: false, shift: false, key: 'c' }, // Copy
{ ctrl: true, alt: false, shift: false, key: 'v' }, // Paste
{ ctrl: true, alt: false, shift: false, key: 'x' }, // Cut
{ ctrl: true, alt: false, shift: false, key: 'z' }, // Undo
{ ctrl: true, alt: false, shift: false, key: 'a' }, // Select All
{ ctrl: true, alt: false, shift: false, key: 's' }, // Save
{ ctrl: true, alt: false, shift: false, key: 'w' }, // Close tab
{ ctrl: false, alt: true, shift: false, key: 'F4' }, // Close window
{ ctrl: false, alt: false, shift: false, key: 'F5' }, // Refresh
]
function getKeyDisplayName(keyCode: number, key: string): string {
if (KEY_DISPLAY_MAP[keyCode]) return KEY_DISPLAY_MAP[keyCode]
if (key.length === 1) return key.toUpperCase()
return key
}
function isModifierKey(keyCode: number): boolean {
return (keyCode >= 160 && keyCode <= 165) || keyCode === 91 || keyCode === 92
}
function isReservedCombo(ctrl: boolean, alt: boolean, shift: boolean, key: string): boolean {
return RESERVED_COMBOS.some(
(c) => c.ctrl === ctrl && c.alt === alt && c.shift === shift && c.key === key
)
}
interface HotkeyRecordModalProps {
open: boolean
onClose: () => void
onSave: (binding: HotkeyBinding) => void
currentBinding?: HotkeyBinding | null
title?: string
}
interface RecordedKey {
keyCode: number
displayName: string
isModifier: boolean
}
export function HotkeyRecordModal({
open,
onClose,
onSave,
currentBinding,
title = '단축키 설정',
}: HotkeyRecordModalProps): React.ReactElement {
const [recordedKeys, setRecordedKeys] = useState<RecordedKey[]>([])
const [isRecording, setIsRecording] = useState(false)
const [validationError, setValidationError] = useState<string | null>(null)
const keysRef = useRef<Set<number>>(new Set())
// Modal 열릴 때 녹화 시작
useEffect(() => {
if (open) {
setRecordedKeys([])
setValidationError(null)
setIsRecording(true)
keysRef.current.clear()
} else {
setIsRecording(false)
}
}, [open])
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!isRecording) return
e.preventDefault()
e.stopPropagation()
const keyCode = e.keyCode || e.which
if (keysRef.current.has(keyCode)) return
keysRef.current.add(keyCode)
const displayName = getKeyDisplayName(keyCode, e.key)
const isMod = isModifierKey(keyCode)
setRecordedKeys((prev) => {
const next = [...prev, { keyCode, displayName, isModifier: isMod }]
// modifier가 아닌 키가 들어오면 녹화 완료 (modifier + key 조합)
if (!isMod) {
setIsRecording(false)
}
return next
})
setValidationError(null)
},
[isRecording]
)
const handleKeyUp = useCallback(
(e: KeyboardEvent) => {
const keyCode = e.keyCode || e.which
keysRef.current.delete(keyCode)
// modifier만 눌렀다 놓은 경우: 단일 modifier 키 등록
if (isRecording && isModifierKey(keyCode) && keysRef.current.size === 0) {
setRecordedKeys((prev) => {
if (prev.length > 0 && prev.every((k) => k.isModifier)) {
setIsRecording(false)
return prev
}
return prev
})
}
},
[isRecording]
)
useEffect(() => {
if (!open) return
window.addEventListener('keydown', handleKeyDown, true)
window.addEventListener('keyup', handleKeyUp, true)
return () => {
window.removeEventListener('keydown', handleKeyDown, true)
window.removeEventListener('keyup', handleKeyUp, true)
}
}, [open, handleKeyDown, handleKeyUp])
const handleSave = () => {
if (recordedKeys.length === 0) {
setValidationError('키를 입력해주세요')
return
}
const hasModifier = recordedKeys.some((k) => k.isModifier)
const mainKey = recordedKeys.find((k) => !k.isModifier)
// 시스템 예약 단축키 체크
if (mainKey) {
const ctrl = recordedKeys.some((k) => k.keyCode === 162 || k.keyCode === 163)
const alt = recordedKeys.some((k) => k.keyCode === 164 || k.keyCode === 165)
const shift = recordedKeys.some((k) => k.keyCode === 160 || k.keyCode === 161)
if (isReservedCombo(ctrl, alt, shift, mainKey.displayName)) {
setValidationError('시스템 예약 단축키입니다')
resetRecording()
return
}
}
// HotkeyBinding 생성
const displayLabel = recordedKeys.map((k) => k.displayName).join(' + ')
const primaryKeyCode = mainKey?.keyCode ?? recordedKeys[0].keyCode
const binding: HotkeyBinding = {
keyCode: primaryKeyCode,
ctrl: recordedKeys.some((k) => k.keyCode === 162 || k.keyCode === 163),
alt: recordedKeys.some((k) => k.keyCode === 164 || k.keyCode === 165),
shift: recordedKeys.some((k) => k.keyCode === 160 || k.keyCode === 161),
meta: recordedKeys.some((k) => k.keyCode === 91 || k.keyCode === 92),
displayLabel,
}
// 단일 modifier만 입력한 경우 (예: Right Alt만)
if (!mainKey && hasModifier && recordedKeys.length === 1) {
const modKey = recordedKeys[0]
binding.keyCode = modKey.keyCode
binding.ctrl = modKey.keyCode === 162 || modKey.keyCode === 163
binding.alt = modKey.keyCode === 164 || modKey.keyCode === 165
binding.shift = modKey.keyCode === 160 || modKey.keyCode === 161
binding.meta = modKey.keyCode === 91 || modKey.keyCode === 92
}
onSave(binding)
onClose()
}
const resetRecording = () => {
setRecordedKeys([])
setIsRecording(true)
keysRef.current.clear()
}
const handleCancel = () => {
setRecordedKeys([])
setIsRecording(false)
keysRef.current.clear()
onClose()
}
return (
<Dialog open={open} onClose={handleCancel} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 700, fontSize: '16px' }}>{title}</DialogTitle>
<DialogContent>
{/* 녹화 영역 */}
<Box
sx={{
border: `2px solid ${
validationError
? d3roPalette.tag.red
: isRecording
? d3roPalette.accent.amber
: d3roPalette.border.strong
}`,
borderRadius: '12px',
p: 3,
minHeight: 80,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: d3roPalette.bg.inset,
transition: 'border-color 0.2s ease',
}}
>
{recordedKeys.length > 0 ? (
<Stack direction="row" spacing={1} flexWrap="wrap" justifyContent="center">
{recordedKeys.map((key, i) => (
<Chip
key={`${key.keyCode}-${i}`}
label={key.displayName}
sx={{
fontWeight: 700,
fontSize: '13px',
bgcolor: d3roPalette.bg.chassis,
color: d3roPalette.text.primary,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: '8px',
height: 36,
}}
/>
))}
</Stack>
) : (
<Typography
sx={{
color: isRecording ? d3roPalette.accent.amber : d3roPalette.text.inactive,
fontSize: '13px',
textAlign: 'center',
}}
>
{isRecording ? '키 조합을 눌러주세요...' : '키를 입력하세요'}
</Typography>
)}
</Box>
{/* 검증 오류 */}
{validationError && (
<Typography sx={{ color: d3roPalette.tag.red, fontSize: '12px', mt: 1 }}>
{validationError}
</Typography>
)}
{/* 현재 설정 표시 */}
{currentBinding && (
<Typography sx={{ color: d3roPalette.text.inactive, fontSize: '12px', mt: 2 }}>
: {currentBinding.displayLabel}
</Typography>
)}
{/* 안내 */}
<Typography sx={{ color: d3roPalette.text.muted, fontSize: '11px', mt: 1 }}>
(: Right Alt) (: Ctrl+Shift+Q)
</Typography>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={handleCancel} sx={{ color: d3roPalette.text.inactive }}>
</Button>
{!isRecording && recordedKeys.length > 0 && (
<Button onClick={resetRecording} sx={{ color: d3roPalette.text.inactive }}>
</Button>
)}
<Button
variant="contained"
onClick={handleSave}
disabled={isRecording || recordedKeys.length === 0}
>
</Button>
</DialogActions>
</Dialog>
)
}

View file

@ -1,7 +1,7 @@
// src/renderer/components/SettingsModal.tsx
// 설계서 03: Settings React Modal (일반/오디오/핫키 탭)
// 설계서 03: Settings React Modal — General(음성 모드+핫키)/Audio/STT/LLM 탭
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import {
Dialog,
DialogTitle,
@ -18,11 +18,18 @@ import {
IconButton,
Divider,
InputLabel,
FormControl
FormControl,
Button,
Chip,
Stack,
Paper,
} from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import KeyboardIcon from '@mui/icons-material/Keyboard'
import EditIcon from '@mui/icons-material/Edit'
import { d3roPalette } from '../theme'
import type { ThemeMode, AppConfig } from '@shared/types'
import { HotkeyRecordModal } from './HotkeyRecordModal'
import type { ThemeMode, AppConfig, HotkeyBinding } from '@shared/types'
interface SettingsModalProps {
open: boolean
@ -40,183 +47,454 @@ function TabPanel({ children, value, index }: TabPanelProps): React.ReactElement
return <Box sx={{ pt: 2 }}>{children}</Box>
}
// ── 핫키 표시 컴포넌트 ──────────────────────────────────
function HotkeyDisplay({
binding,
onEdit,
label,
}: {
binding: HotkeyBinding | null
onEdit: () => void
label: string
}): React.ReactElement {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, minWidth: 40 }}>
{label}
</Typography>
{binding ? (
<Stack direction="row" spacing={0.5} alignItems="center">
{binding.displayLabel.split(' + ').map((key) => (
<Chip
key={key}
label={key}
size="small"
sx={{
fontWeight: 700,
fontSize: '11px',
bgcolor: d3roPalette.bg.elevated,
color: d3roPalette.text.primary,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: '6px',
height: 28,
}}
/>
))}
</Stack>
) : (
<Typography variant="body2" sx={{ color: d3roPalette.text.disabled }}>
</Typography>
)}
<IconButton size="small" onClick={onEdit} sx={{ color: d3roPalette.text.inactive, ml: 'auto' }}>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
)
}
// ── 음성 모드 카드 ──────────────────────────────────────
function VoiceModeCard({
title,
description,
enabled,
onToggle,
disabled,
children,
}: {
title: string
description: string
enabled: boolean
onToggle: (enabled: boolean) => void
disabled?: boolean
children?: React.ReactNode
}): React.ReactElement {
return (
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.card,
borderRadius: '12px',
border: `1px solid ${d3roPalette.border.subtle}`,
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{title}
</Typography>
<FormControlLabel
control={
<Switch
checked={enabled}
onChange={(e) => onToggle(e.target.checked)}
disabled={disabled}
size="small"
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: d3roPalette.tag.green },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
backgroundColor: d3roPalette.tag.green,
},
}}
/>
}
label={
<Chip
label={enabled ? '활성화됨' : '비활성화'}
size="small"
sx={{
fontSize: '10px',
fontWeight: 700,
height: 20,
bgcolor: enabled ? d3roPalette.tag.greenBg : 'transparent',
color: enabled ? d3roPalette.tag.green : d3roPalette.text.disabled,
border: enabled ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
}}
/>
}
sx={{ ml: 0, mr: 0 }}
/>
</Box>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, fontSize: '12px' }}>
{description}
</Typography>
</Box>
</Box>
{children && <Box sx={{ mt: 1.5 }}>{children}</Box>}
</Paper>
)
}
export function SettingsModal({ open, onClose }: SettingsModalProps): React.ReactElement {
const [activeTab, setActiveTab] = useState(0)
const [config, setConfig] = useState<Partial<AppConfig>>({})
const [loading, setLoading] = useState(true)
// 음성 모드 상태
const [dictationEnabled, setDictationEnabled] = useState(true)
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
const [handsFreeEnabled, setHandsFreeEnabled] = useState(false)
const [handsFreeBinding, setHandsFreeBinding] = useState<HotkeyBinding | null>(null)
const [hotkeyGlobalEnabled, setHotkeyGlobalEnabled] = useState(true)
// 핫키 녹화 모달
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
const [hotkeyModalTarget, setHotkeyModalTarget] = useState<'dictation' | 'handsFree'>('dictation')
// 설정 로드
useEffect(() => {
if (!open) return
setLoading(true)
window.electronAPI.config
.getAll()
.then((result) => {
if (result.success) {
setConfig(result.data)
Promise.all([
window.electronAPI.config.getAll(),
window.electronAPI.hotkey.getDictationShortcut(),
window.electronAPI.hotkey.getHandsFreeShortcut(),
window.electronAPI.hotkey.isEnabled(),
])
.then(([configResult, dictResult, hfResult, enabledResult]) => {
if (configResult.success) setConfig(configResult.data)
if (dictResult.success && dictResult.data) setDictationBinding(dictResult.data)
if (hfResult.success && hfResult.data) setHandsFreeBinding(hfResult.data)
if (enabledResult.success) {
setHotkeyGlobalEnabled(enabledResult.data)
setDictationEnabled(enabledResult.data)
}
})
.finally(() => setLoading(false))
}, [open])
const updateConfig = (key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => {
const updateConfig = useCallback((key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => {
setConfig((prev) => ({ ...prev, [key]: value }))
window.electronAPI.config.set({ key, value })
}
}, [])
// 음성 모드 토글
const handleDictationToggle = useCallback(
(enabled: boolean) => {
setDictationEnabled(enabled)
window.electronAPI.hotkey.setEnabled({ enabled })
// 받아쓰기 비활성화 시 핸즈프리도 비활성화
if (!enabled && handsFreeEnabled) {
setHandsFreeEnabled(false)
}
},
[handsFreeEnabled]
)
const handleHandsFreeToggle = useCallback((enabled: boolean) => {
if (enabled && !handsFreeBinding) {
// 핫키 설정 없으면 녹화 모달 열기
setHotkeyModalTarget('handsFree')
setHotkeyModalOpen(true)
return
}
setHandsFreeEnabled(enabled)
}, [handsFreeBinding])
// 핫키 저장
const handleHotkeySave = useCallback(
(binding: HotkeyBinding) => {
if (hotkeyModalTarget === 'dictation') {
setDictationBinding(binding)
window.electronAPI.hotkey.setDictationShortcut({ binding })
setDictationEnabled(true)
window.electronAPI.hotkey.setEnabled({ enabled: true })
} else {
setHandsFreeBinding(binding)
window.electronAPI.hotkey.setHandsFreeShortcut({ binding })
setHandsFreeEnabled(true)
}
},
[hotkeyModalTarget]
)
const openHotkeyModal = useCallback((target: 'dictation' | 'handsFree') => {
setHotkeyModalTarget(target)
setHotkeyModalOpen(true)
}, [])
if (loading) return <Dialog open={open} onClose={onClose}><DialogContent /></Dialog>
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontWeight: 700 }}>
Settings
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.label }}>
<CloseIcon />
</IconButton>
</DialogTitle>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<DialogContent>
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)}>
<Tab label="General" />
<Tab label="Audio" />
<Tab label="STT" />
<Tab label="LLM" />
</Tabs>
<>
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontWeight: 700 }}>
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.label }}>
<CloseIcon />
</IconButton>
</DialogTitle>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<DialogContent>
<Tabs value={activeTab} onChange={(_, v: number) => setActiveTab(v)}>
<Tab label="일반" icon={<KeyboardIcon sx={{ fontSize: 16 }} />} iconPosition="start" />
<Tab label="오디오" />
<Tab label="STT" />
<Tab label="LLM" />
</Tabs>
{/* General */}
<TabPanel value={activeTab} index={0}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControl size="small">
<InputLabel>Theme</InputLabel>
<Select
label="Theme"
value={config.theme ?? 'auto'}
onChange={(e) => updateConfig('theme', e.target.value as ThemeMode)}
>
<MenuItem value="auto">System</MenuItem>
<MenuItem value="light">Light</MenuItem>
<MenuItem value="dark">Dark</MenuItem>
</Select>
</FormControl>
{/* ── 일반 탭 ─────────────────────────────── */}
<TabPanel value={activeTab} index={0}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* 단축키 섹션 */}
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
</Typography>
<FormControl size="small">
<InputLabel>Language</InputLabel>
<Select
label="Language"
value={config.language ?? 'ko'}
onChange={(e) => updateConfig('language', e.target.value)}
>
<MenuItem value="ko"></MenuItem>
<MenuItem value="en">English</MenuItem>
</Select>
</FormControl>
<Stack spacing={1.5}>
{/* 받아쓰기 모드 */}
<VoiceModeCard
title="받아쓰기"
description="누른 상태에서 말하기. 키를 놓으면 전사가 시작됩니다."
enabled={dictationEnabled}
onToggle={handleDictationToggle}
>
<HotkeyDisplay
binding={dictationBinding}
onEdit={() => openHotkeyModal('dictation')}
label="키"
/>
</VoiceModeCard>
<FormControlLabel
control={
<Switch
checked={config.closeToTray ?? true}
onChange={(e) => updateConfig('closeToTray', e.target.checked)}
{/* Agent 모드 (더블프레스) */}
<VoiceModeCard
title="Agent 모드"
description={
dictationBinding
? `${dictationBinding.displayLabel}를 두 번 클릭하면 Agent 모드에 진입합니다.`
: '받아쓰기 핫키를 먼저 설정하세요.'
}
enabled={dictationEnabled}
onToggle={handleDictationToggle}
disabled={!dictationEnabled}
/>
}
label="Close to tray"
/>
<FormControlLabel
control={
<Switch
checked={config.autoInsert ?? true}
onChange={(e) => updateConfig('autoInsert', e.target.checked)}
/>
}
label="Auto-insert text after transcription"
/>
{/* 원터치 모드 (핸즈프리) */}
<VoiceModeCard
title="원터치 모드"
description="눌러서 시작, 다시 눌러서 중지. 별도 단축키가 필요합니다."
enabled={handsFreeEnabled}
onToggle={handleHandsFreeToggle}
disabled={!dictationEnabled}
>
<HotkeyDisplay
binding={handsFreeBinding}
onEdit={() => openHotkeyModal('handsFree')}
label="키"
/>
</VoiceModeCard>
</Stack>
<FormControlLabel
control={
<Switch
checked={config.soundEnabled ?? true}
onChange={(e) => updateConfig('soundEnabled', e.target.checked)}
/>
}
label="Sound effects"
/>
</Box>
</TabPanel>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{/* Audio */}
<TabPanel value={activeTab} index={1}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="body2" color="text.secondary">
Microphone device selection will be available in a future update.
Currently using the system default microphone.
</Typography>
{/* UI 설정 */}
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
</Typography>
<FormControl size="small">
<InputLabel>Insert Method</InputLabel>
<Select
label="Insert Method"
value={config.insertMethod ?? 'clipboard'}
onChange={(e) =>
updateConfig('insertMethod', e.target.value as 'clipboard' | 'keyboard')
<FormControl size="small">
<InputLabel></InputLabel>
<Select
label="테마"
value={config.theme ?? 'auto'}
onChange={(e) => updateConfig('theme', e.target.value as ThemeMode)}
>
<MenuItem value="auto"></MenuItem>
<MenuItem value="light"></MenuItem>
<MenuItem value="dark"></MenuItem>
</Select>
</FormControl>
<FormControl size="small">
<InputLabel></InputLabel>
<Select
label="언어"
value={config.language ?? 'ko'}
onChange={(e) => updateConfig('language', e.target.value)}
>
<MenuItem value="ko"></MenuItem>
<MenuItem value="en">English</MenuItem>
</Select>
</FormControl>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{/* 앱 동작 */}
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
</Typography>
<FormControlLabel
control={
<Switch
checked={config.closeToTray ?? true}
onChange={(e) => updateConfig('closeToTray', e.target.checked)}
/>
}
>
<MenuItem value="clipboard">Clipboard (Ctrl+V)</MenuItem>
<MenuItem value="keyboard">Keyboard typing</MenuItem>
</Select>
</FormControl>
</Box>
</TabPanel>
label="트레이로 최소화"
/>
{/* STT */}
<TabPanel value={activeTab} index={2}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControl size="small">
<InputLabel>Whisper Model</InputLabel>
<Select
label="Whisper Model"
value={config.sttModelId ?? 'base'}
onChange={(e) => updateConfig('sttModelId', e.target.value)}
>
<MenuItem value="tiny">tiny (39 MB, fastest)</MenuItem>
<MenuItem value="base">base (74 MB, balanced)</MenuItem>
<MenuItem value="small">small (244 MB, better)</MenuItem>
<MenuItem value="medium">medium (769 MB, good)</MenuItem>
<MenuItem value="large-v3">large-v3 (1.5 GB, best)</MenuItem>
</Select>
</FormControl>
<FormControlLabel
control={
<Switch
checked={config.autoLaunch ?? false}
onChange={(e) => updateConfig('autoLaunch', e.target.checked)}
/>
}
label="시스템 시작 시 자동 실행"
/>
<FormControl size="small">
<InputLabel>Language</InputLabel>
<Select
label="Language"
value={config.sttLanguage ?? 'auto'}
onChange={(e) => updateConfig('sttLanguage', e.target.value)}
>
<MenuItem value="auto">Auto-detect</MenuItem>
<MenuItem value="ko"></MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="ja"></MenuItem>
<MenuItem value="zh"></MenuItem>
</Select>
</FormControl>
</Box>
</TabPanel>
<FormControlLabel
control={
<Switch
checked={config.autoInsert ?? true}
onChange={(e) => updateConfig('autoInsert', e.target.checked)}
/>
}
label="전사 후 자동 텍스트 삽입"
/>
{/* LLM */}
<TabPanel value={activeTab} index={3}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Ollama Server URL"
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
fullWidth
/>
<FormControlLabel
control={
<Switch
checked={config.soundEnabled ?? true}
onChange={(e) => updateConfig('soundEnabled', e.target.checked)}
/>
}
label="효과음"
/>
</Box>
</TabPanel>
<Typography variant="body2" color="text.secondary">
LLM model selection will be available after Ollama integration (Phase 4).
</Typography>
</Box>
</TabPanel>
</DialogContent>
</Dialog>
{/* ── 오디오 탭 ────────────────────────────── */}
<TabPanel value={activeTab} index={1}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="body2" color="text.secondary">
.
.
</Typography>
<FormControl size="small">
<InputLabel> </InputLabel>
<Select
label="삽입 방식"
value={config.insertMethod ?? 'clipboard'}
onChange={(e) =>
updateConfig('insertMethod', e.target.value as 'clipboard' | 'keyboard')
}
>
<MenuItem value="clipboard"> (Ctrl+V)</MenuItem>
<MenuItem value="keyboard"> </MenuItem>
</Select>
</FormControl>
</Box>
</TabPanel>
{/* ── STT 탭 ───────────────────────────────── */}
<TabPanel value={activeTab} index={2}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FormControl size="small">
<InputLabel>Whisper </InputLabel>
<Select
label="Whisper 모델"
value={config.sttModelId ?? 'base'}
onChange={(e) => updateConfig('sttModelId', e.target.value)}
>
<MenuItem value="tiny">tiny (39 MB, )</MenuItem>
<MenuItem value="base">base (74 MB, )</MenuItem>
<MenuItem value="small">small (244 MB, )</MenuItem>
<MenuItem value="medium">medium (769 MB, )</MenuItem>
<MenuItem value="large-v3">large-v3 (1.5 GB, )</MenuItem>
</Select>
</FormControl>
<FormControl size="small">
<InputLabel> </InputLabel>
<Select
label="인식 언어"
value={config.sttLanguage ?? 'auto'}
onChange={(e) => updateConfig('sttLanguage', e.target.value)}
>
<MenuItem value="auto"> </MenuItem>
<MenuItem value="ko"></MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="ja"></MenuItem>
<MenuItem value="zh"></MenuItem>
</Select>
</FormControl>
</Box>
</TabPanel>
{/* ── LLM 탭 ──────────────────────────────── */}
<TabPanel value={activeTab} index={3}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Ollama 서버 URL"
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
fullWidth
/>
<Typography variant="body2" color="text.secondary">
Ollama가 .
Ollama에서 pull하세요 (: ollama pull qwen3:4b).
</Typography>
</Box>
</TabPanel>
</DialogContent>
</Dialog>
{/* 핫키 녹화 모달 */}
<HotkeyRecordModal
open={hotkeyModalOpen}
onClose={() => setHotkeyModalOpen(false)}
onSave={handleHotkeySave}
currentBinding={hotkeyModalTarget === 'dictation' ? dictationBinding : handsFreeBinding}
title={hotkeyModalTarget === 'dictation' ? '받아쓰기 단축키 설정' : '원터치 모드 단축키 설정'}
/>
</>
)
}

View file

@ -4,10 +4,9 @@
import { useState, useEffect } from 'react'
import { Box, Typography } from '@mui/material'
import { Led } from './ds'
import { d3roPalette, d3roFontMono } from '../theme'
import type { LLMStatus } from '@shared/types'
const MONO = 'MONO_PLACEHOLDER'
export function StatusBar(): React.ReactElement {
const [llmStatus, setLlmStatus] = useState<LLMStatus | null>(null)
@ -36,27 +35,27 @@ export function StatusBar(): React.ReactElement {
gap: 2,
px: 2,
py: 0.5,
borderTop: '1px solid rgba(255,255,255,0.04)',
bgcolor: '#1e1f21',
borderTop: `1px solid ${d3roPalette.border.subtle}`,
bgcolor: d3roPalette.bg.sidebar,
minHeight: 28,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Led color={connected ? 'green' : 'red'} size={6} />
<Typography sx={{ fontFamily: MONO, fontSize: '9px', color: '#77797c', letterSpacing: '1px', fontWeight: 700 }}>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '9px', color: d3roPalette.text.inactive, letterSpacing: '1px', fontWeight: 700 }}>
{connected ? 'OLLAMA' : 'OFFLINE'}
</Typography>
</Box>
{llmStatus?.activeModel && (
<Typography sx={{ fontFamily: MONO, fontSize: '9px', color: '#5c2615', letterSpacing: '0.5px' }}>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '9px', color: d3roPalette.text.dimLabel, letterSpacing: '0.5px' }}>
{llmStatus.activeModel.toUpperCase()}
</Typography>
)}
<Box sx={{ flex: 1 }} />
<Typography sx={{ fontFamily: MONO, fontSize: '9px', color: '#3a3b3f', letterSpacing: '1px', fontWeight: 700 }}>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '9px', color: d3roPalette.text.muted, letterSpacing: '1px', fontWeight: 700 }}>
PRECISION DATA LINK
</Typography>
</Box>

View file

@ -3,6 +3,7 @@
import { useRef, useEffect, useCallback } from 'react'
import { Box } from '@mui/material'
import { d3roPalette, d3roFontMono } from '../../theme'
// ── WebGL 유틸 ─────────────────────────────────────────
@ -218,7 +219,7 @@ export function CrtDisplay({
sx={{
position: 'relative',
height,
bgcolor: '#1a1a1c',
bgcolor: d3roPalette.bg.crtBezel,
borderRadius: '8px',
boxShadow: '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)',
overflow: 'hidden',
@ -230,7 +231,7 @@ export function CrtDisplay({
position: 'absolute',
inset: '2px',
borderRadius: '6px',
bgcolor: '#050605',
bgcolor: d3roPalette.bg.crtGlass,
overflow: 'hidden',
boxShadow: 'inset 0 0 20px rgba(0,0,0,0.8)',
}}
@ -261,10 +262,10 @@ export function CrtDisplay({
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
color: '#f25b29',
color: d3roPalette.accent.amber,
textShadow: '0 0 6px rgba(242, 91, 41, 0.4)',
pointerEvents: 'none',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontFamily: d3roFontMono,
}}
>
{children}

View file

@ -2,6 +2,7 @@
// 시안 A: 메탈 섀시 컨테이너 — 노이즈 텍스처, 각인 텍스트, 물리적 존재감
import { Box, Typography } from '@mui/material'
import { d3roPalette, d3roFontMono } from '../../theme'
interface InstrumentPanelProps {
children: React.ReactNode
@ -23,11 +24,11 @@ export function InstrumentPanel({
<Box
sx={{
position: 'relative',
bgcolor: '#242528',
bgcolor: d3roPalette.bg.chassis,
borderRadius: '24px',
p: 3,
boxShadow:
'0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 #111112, 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)',
`0 60px 100px -20px rgba(0,0,0,0.8), 0 12px 0 ${d3roPalette.led.off}, 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)`,
// 메탈 노이즈는 CSS로 시뮬레이션
'&::before': {
content: '""',
@ -62,10 +63,10 @@ function Engraving({ children, sx }: { children: string; sx: Record<string, unkn
position: 'absolute',
fontSize: '9px',
letterSpacing: '1.5px',
color: '#1a1a1c',
color: d3roPalette.text.engraving,
textShadow: '0 1px 0 rgba(255,255,255,0.08)',
fontWeight: 700,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontFamily: d3roFontMono,
zIndex: 2,
userSelect: 'none',
...sx,

View file

@ -2,15 +2,16 @@
// 시안 A: LED 인디케이터 — 물리적 LED, 활성 시 glow + pulse
import { Box } from '@mui/material'
import { d3roPalette } from '../../theme'
type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'off'
const LED_COLORS: Record<LedColor, { bg: string; glow: string }> = {
amber: { bg: '#f25b29', glow: 'rgba(242, 91, 41, 0.6)' },
green: { bg: '#22c55e', glow: 'rgba(34, 197, 94, 0.6)' },
red: { bg: '#ef4444', glow: 'rgba(239, 68, 68, 0.6)' },
orange: { bg: '#f59e0b', glow: 'rgba(245, 158, 11, 0.6)' },
off: { bg: '#111', glow: 'transparent' },
amber: { bg: d3roPalette.accent.amber, glow: d3roPalette.accent.amberGlow },
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 },
off: { bg: d3roPalette.led.off, glow: 'transparent' },
}
interface LedProps {

View file

@ -2,6 +2,7 @@
// 시안 A+B 융합: 메탈 카드 컨테이너 — 섀시 느낌의 인셋 패널
import { Box } from '@mui/material'
import { d3roPalette } from '../../theme'
interface MetalCardProps {
children: React.ReactNode
@ -12,15 +13,15 @@ export function MetalCard({ children, inset = false }: MetalCardProps): React.Re
return (
<Box
sx={{
bgcolor: inset ? '#1b1c1e' : '#242427',
bgcolor: inset ? d3roPalette.bg.inset : d3roPalette.bg.card,
borderRadius: inset ? '12px' : '22px',
borderTop: inset ? 'none' : '1px solid rgba(255,255,255,0.04)',
borderTop: inset ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
boxShadow: inset
? 'inset 0 2px 6px rgba(0,0,0,0.6), 0 1px 1px rgba(255,255,255,0.05)'
: '0 8px 30px rgba(0,0,0,0.3)',
p: inset ? '6px' : 3,
transition: 'background-color 0.2s ease',
'&:hover': inset ? {} : { bgcolor: '#2a2a2d' },
'&:hover': inset ? {} : { bgcolor: d3roPalette.bg.cardHover },
}}
>
{children}

View file

@ -2,14 +2,15 @@
// 시안 A: 인광 텍스트 — 앰버 glow, 모노 폰트, CRT 느낌
import { Typography, type TypographyProps } from '@mui/material'
import { d3roPalette, d3roFontMono } from '../../theme'
type PhosphorVariant = 'hero' | 'value' | 'label' | 'dim'
const VARIANTS: Record<PhosphorVariant, { fontSize: string; color: string; glow: string; fontWeight: number }> = {
hero: { fontSize: '42px', color: '#f25b29', glow: 'rgba(242, 91, 41, 0.4)', fontWeight: 300 },
value: { fontSize: '20px', color: '#f25b29', glow: 'rgba(242, 91, 41, 0.3)', fontWeight: 400 },
label: { fontSize: '10px', color: '#5c2615', glow: 'none', fontWeight: 700 },
dim: { fontSize: '10px', color: '#77797c', glow: 'none', fontWeight: 400 },
hero: { fontSize: '42px', color: d3roPalette.accent.amber, glow: 'rgba(242, 91, 41, 0.4)', fontWeight: 300 },
value: { fontSize: '20px', color: d3roPalette.accent.amber, glow: 'rgba(242, 91, 41, 0.3)', fontWeight: 400 },
label: { fontSize: '10px', color: d3roPalette.text.dimLabel, glow: 'none', fontWeight: 700 },
dim: { fontSize: '10px', color: d3roPalette.text.inactive, glow: 'none', fontWeight: 400 },
}
interface PhosphorTextProps extends Omit<TypographyProps, 'variant'> {
@ -23,7 +24,7 @@ export function PhosphorText({ variant = 'value', sx, ...props }: PhosphorTextPr
<Typography
{...props}
sx={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
fontFamily: d3roFontMono,
fontSize: v.fontSize,
fontWeight: v.fontWeight,
color: v.color,

View file

@ -2,6 +2,7 @@
// 시안 A: 물리 버튼 — 돌출 그림자, 눌림 피드백, 선택 상태
import { Button, type ButtonProps } from '@mui/material'
import { d3roPalette, d3roFontMono } from '../../theme'
interface PhysicalButtonProps extends Omit<ButtonProps, 'variant'> {
selected?: boolean
@ -13,11 +14,11 @@ export function PhysicalButton({ selected = false, sx, ...props }: PhysicalButto
{...props}
sx={{
height: 44,
bgcolor: selected ? '#1a1a1c' : '#242528',
bgcolor: selected ? d3roPalette.bg.crtBezel : d3roPalette.bg.chassis,
border: 'none',
borderRadius: '6px',
color: selected ? '#f25b29' : '#77797c',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
color: selected ? d3roPalette.accent.amber : d3roPalette.text.inactive,
fontFamily: d3roFontMono,
fontSize: '12px',
fontWeight: 600,
cursor: 'pointer',
@ -31,7 +32,7 @@ export function PhysicalButton({ selected = false, sx, ...props }: PhysicalButto
boxShadow: '0 1px 2px rgba(0,0,0,0.4), inset 0 2px 4px rgba(0,0,0,0.3)',
},
'&:hover': {
bgcolor: selected ? '#1a1a1c' : '#2a2b2e',
bgcolor: selected ? d3roPalette.bg.crtBezel : d3roPalette.bg.cardHover,
},
textTransform: 'uppercase',
letterSpacing: '0.5px',

View file

@ -7,6 +7,7 @@ import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import { MetalCard, PhosphorText, Led } from '../components/ds'
import { d3roPalette } from '../theme'
import type { IPCResult } from '@shared/errors'
interface CustomInstruction {
@ -42,7 +43,7 @@ export function CommandsPage(): React.ReactElement {
return (
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<PhosphorText variant="label" sx={{ color: '#77797c' }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.inactive }}>
LLM INSTRUCTIONS {instructions.length} COMMANDS
</PhosphorText>
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd} size="small">ADD</Button>
@ -64,16 +65,16 @@ export function CommandsPage(): React.ReactElement {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flex: 1 }}>
<Led color={inst.isBuiltin ? 'amber' : 'green'} size={6} />
<Box>
<Box sx={{ fontSize: '14px', fontWeight: 600, color: '#fff' }}>{inst.name}</Box>
<Box sx={{ fontSize: '11px', color: '#77797c', mt: 0.25 }}>{inst.description}</Box>
<Box sx={{ fontSize: '14px', fontWeight: 600, color: d3roPalette.text.primary }}>{inst.name}</Box>
<Box sx={{ fontSize: '11px', color: d3roPalette.text.inactive, mt: 0.25 }}>{inst.description}</Box>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<IconButton size="small" onClick={() => openEdit(inst)} sx={{ color: '#77797c', '&:hover': { color: '#f25b29' } }}>
<IconButton size="small" onClick={() => openEdit(inst)} sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
{!inst.isBuiltin && (
<IconButton size="small" sx={{ color: '#77797c', '&:hover': { color: '#ef4444' } }}>
<IconButton size="small" sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
)}

View file

@ -1,182 +1,354 @@
// src/renderer/pages/DashboardPage.tsx
// 시안 A 인스트루먼트 패널: CRT 디스플레이 + LED 클러스터 + 물리 버튼 + 통계
// 기능 중심 대시보드: 통계 카드 + 서비스 상태 + 최근 히스토리 (정밀기기 비주얼)
import { useState, useEffect, useCallback } from 'react'
import { Box, Typography } from '@mui/material'
import { CrtDisplay, InstrumentPanel, Led, PhysicalButton, MetalCard, PhosphorText } from '../components/ds'
import type { StatsSummary } from '@shared/types'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Box, Typography, IconButton, Tooltip } from '@mui/material'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import { CrtDisplay, InstrumentPanel, Led, MetalCard, PhosphorText } from '../components/ds'
import { d3roPalette, d3roFontMono } from '../theme'
import type { StatsSummary, HistoryEntry, HistoryPage as HistoryPageData, HotkeyBinding } from '@shared/types'
function formatTime(ms: number): string {
const totalSec = Math.round(ms / 1000)
const h = Math.floor(totalSec / 3600)
const m = Math.floor((totalSec % 3600) / 60)
const s = totalSec % 60
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
// ── 유틸 ──────────────────────────────────────────────
function formatRecordingTime(ms: number): string {
const totalMin = Math.round(ms / 60000)
if (totalMin >= 60) {
const h = Math.floor(totalMin / 60)
const m = totalMin % 60
return `${h}:${m.toString().padStart(2, '0')}`
}
return `${totalMin}`
}
function formatRecordingTimeUnit(ms: number): string {
const totalMin = Math.round(ms / 60000)
return totalMin >= 60 ? '시간' : '분'
}
function formatNumber(n: number): string {
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`
return `${n}`
}
function formatDuration(sec: number): string {
const m = Math.floor(sec / 60)
const s = Math.round(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
type DisplayMode = 'stats' | 'voice' | 'sys'
function formatTime(ts: number): string {
return new Date(ts).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })
}
function getDateLabel(ts: number): string {
const d = new Date(ts)
d.setHours(0, 0, 0, 0)
const today = new Date()
today.setHours(0, 0, 0, 0)
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
if (d.getTime() === today.getTime()) return 'TODAY'
if (d.getTime() === yesterday.getTime()) return 'YESTERDAY'
return d.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' }).toUpperCase()
}
function getDateKey(ts: number): string {
const d = new Date(ts)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
// ── 통계 카드 ─────────────────────────────────────────
function StatCard({
label,
value,
unit,
}: {
label: string
value: string
unit?: string
}): React.ReactElement {
return (
<MetalCard>
<Box sx={{ textAlign: 'center', py: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>
{label}
</PhosphorText>
<Box sx={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 0.5 }}>
<PhosphorText variant="hero" sx={{ fontSize: '32px' }}>
{value}
</PhosphorText>
{unit && (
<PhosphorText variant="dim" sx={{ fontSize: '12px' }}>
{unit}
</PhosphorText>
)}
</Box>
</Box>
</MetalCard>
)
}
// ── 메인 컴포넌트 ─────────────────────────────────────
export function DashboardPage(): React.ReactElement {
const [stats, setStats] = useState<StatsSummary | null>(null)
const [history, setHistory] = useState<HistoryEntry[]>([])
const [ollamaConnected, setOllamaConnected] = useState(false)
const [displayMode, setDisplayMode] = useState<DisplayMode>('stats')
const [glitchTrigger, setGlitchTrigger] = useState(0)
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
const loadStats = useCallback(() => {
window.electronAPI.stats.getSummary().then((result) => {
if (result.success) setStats(result.data)
const loadData = useCallback(() => {
window.electronAPI.stats.getSummary().then((r) => {
if (r.success) setStats(r.data)
})
window.electronAPI.llm.getStatus().then((result) => {
if (result.success) setOllamaConnected(result.data.connectionState === 'connected')
window.electronAPI.llm.getStatus().then((r) => {
if (r.success) setOllamaConnected(r.data.connectionState === 'connected')
})
window.electronAPI.history.getAll({ page: 0, pageSize: 10, sortOrder: 'desc' }).then((r) => {
if (r.success) setHistory(r.data.entries)
})
window.electronAPI.hotkey.getDictationShortcut().then((r) => {
if (r.success && r.data) setDictationBinding(r.data)
})
}, [])
useEffect(() => {
loadStats()
const interval = setInterval(loadStats, 30000)
loadData()
const interval = setInterval(loadData, 30000)
return () => clearInterval(interval)
}, [loadStats])
}, [loadData])
const switchMode = (mode: DisplayMode) => {
setDisplayMode(mode)
setGlitchTrigger((prev) => prev + 1)
}
// 날짜별 그룹핑
const groupedHistory = useMemo(() => {
const groups: Record<string, { label: string; entries: HistoryEntry[] }> = {}
for (const entry of history) {
const key = getDateKey(entry.createdAt)
if (!groups[key]) {
groups[key] = { label: getDateLabel(entry.createdAt), entries: [] }
}
groups[key].entries.push(entry)
}
return Object.values(groups)
}, [history])
// 서비스 상태 목록
const services = [
{ name: 'STT ENGINE', status: 'READY', ok: true },
{ name: 'OLLAMA LLM', status: ollamaConnected ? 'CONNECTED' : 'OFFLINE', ok: ollamaConnected },
{ name: 'HOTKEY HOOK', status: 'ACTIVE', ok: true },
{ name: 'AUDIO INPUT', status: 'STANDBY', ok: true },
]
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100%', p: 3 }}>
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4, pb: 8 }}>
{/* ── 1. HERO 영역 ──────────────────────────── */}
<InstrumentPanel
engravingLeft="D3RO-VOICE SYS."
engravingRight="MOD-01 / TERMINAL"
engravingLeft="D3RO-VOICE"
engravingRight="v1.0.0"
engravingBottom="LOCAL AI VOICE ASSISTANT"
>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 140px', gap: 3, minHeight: 320 }}>
{/* ── 좌측: CRT 디스플레이 ─────────────────── */}
<CrtDisplay
amplitude={displayMode === 'voice' ? 0.4 : 0.1}
frequency={displayMode === 'voice' ? 15 : 8}
glitchTrigger={glitchTrigger}
height={320}
>
{displayMode === 'stats' && (
<>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<PhosphorText variant="label">TOTAL SESSIONS</PhosphorText>
<PhosphorText variant="label">D3RO</PhosphorText>
</Box>
<Box sx={{ mt: 'auto', mb: 3 }}>
<PhosphorText variant="hero">
{stats?.totalSessionCount ?? 0}
</PhosphorText>
<PhosphorText variant="label" sx={{ mt: 1 }}>
{formatTime(stats?.totalRecordingTimeMs ?? 0)} RECORDED
</PhosphorText>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Box>
<PhosphorText variant="label">WORDS</PhosphorText>
<PhosphorText variant="value">{stats?.totalWordCount ?? 0}</PhosphorText>
</Box>
<Box>
<PhosphorText variant="label">TODAY</PhosphorText>
<PhosphorText variant="value">{stats?.todaySessionCount ?? 0}</PhosphorText>
</Box>
<Box sx={{ textAlign: 'right' }}>
<PhosphorText variant="label">STREAK</PhosphorText>
<PhosphorText variant="value">
{stats?.streakDays ?? 0}<PhosphorText variant="dim" component="span" sx={{ ml: 0.5 }}>D</PhosphorText>
</PhosphorText>
</Box>
</Box>
</>
)}
{displayMode === 'voice' && (
<>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<PhosphorText variant="label">VOICE MODE</PhosphorText>
<PhosphorText variant="label">STANDBY</PhosphorText>
</Box>
<Box sx={{ mt: 'auto', mb: 3, textAlign: 'center' }}>
<PhosphorText variant="hero">IDLE</PhosphorText>
<PhosphorText variant="label" sx={{ mt: 1 }}>
PRESS RIGHT ALT TO DICTATE
</PhosphorText>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Box>
<PhosphorText variant="label">MODE</PhosphorText>
<PhosphorText variant="value">DICT</PhosphorText>
</Box>
<Box sx={{ textAlign: 'right' }}>
<PhosphorText variant="label">HOTKEY</PhosphorText>
<PhosphorText variant="value">R.ALT</PhosphorText>
</Box>
</Box>
</>
)}
{displayMode === 'sys' && (
<>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<PhosphorText variant="label">SYSTEM STATUS</PhosphorText>
<PhosphorText variant="label">DIAG</PhosphorText>
</Box>
<Box sx={{ mt: 3 }}>
{[
{ name: 'STT ENGINE', status: 'READY' as const },
{ name: 'OLLAMA LLM', status: ollamaConnected ? 'CONNECTED' as const : 'OFFLINE' as const },
{ name: 'HOTKEY HOOK', status: 'ACTIVE' as const },
{ name: 'AUDIO INPUT', status: 'STANDBY' as const },
].map((item) => (
<Box key={item.name} sx={{ display: 'flex', justifyContent: 'space-between', mb: 1.5 }}>
<PhosphorText variant="label">{item.name}</PhosphorText>
<PhosphorText
variant="value"
sx={{
fontSize: '12px',
color: item.status === 'OFFLINE' ? '#ef4444' : '#f25b29',
}}
>
{item.status}
</PhosphorText>
</Box>
))}
</Box>
<Box sx={{ mt: 'auto' }}>
<PhosphorText variant="label">VERSION</PhosphorText>
<PhosphorText variant="value" sx={{ fontSize: '12px' }}>v1.0.0</PhosphorText>
</Box>
</>
)}
</CrtDisplay>
{/* ── 우측: 컨트롤 패널 ────────────────────── */}
<Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
{/* LED 상태 클러스터 */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, pt: 1 }}>
<Led color="green" pulse />
<Led color={ollamaConnected ? 'green' : 'red'} />
<Led color="amber" pulse />
<Box sx={{ py: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="amber" pulse size={8} />
<PhosphorText variant="value" sx={{ fontSize: '16px' }}>
, D3RO-VOICE만으로
</PhosphorText>
</Box>
</Box>
{/* 모드 버튼 그룹 */}
<MetalCard inset>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<PhysicalButton selected={displayMode === 'stats'} onClick={() => switchMode('stats')} fullWidth>
STAT
</PhysicalButton>
<PhysicalButton selected={displayMode === 'voice'} onClick={() => switchMode('voice')} fullWidth>
VOICE
</PhysicalButton>
<PhysicalButton selected={displayMode === 'sys'} onClick={() => switchMode('sys')} fullWidth>
SYS
</PhysicalButton>
{/* 핫키 표시 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
{dictationBinding ? (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
px: 1.5,
py: 0.5,
borderRadius: '8px',
border: `1px solid ${d3roPalette.border.default}`,
bgcolor: d3roPalette.bg.inset,
}}
>
{dictationBinding.displayLabel.split(' + ').map((key) => (
<Typography
key={key}
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
fontWeight: 700,
color: d3roPalette.text.primary,
px: 0.75,
py: 0.25,
borderRadius: '4px',
bgcolor: d3roPalette.bg.chassis,
border: `1px solid ${d3roPalette.border.subtle}`,
}}
>
{key}
</Typography>
))}
</Box>
</MetalCard>
) : (
<PhosphorText variant="dim"> </PhosphorText>
)}
<PhosphorText variant="dim">
. Agent .
</PhosphorText>
</Box>
</Box>
</InstrumentPanel>
{/* ── 2. 통계 카드 ──────────────────────────── */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 2,
mt: 3,
}}
>
<StatCard
label="RECORDING"
value={formatRecordingTime(stats?.totalRecordingTimeMs ?? 0)}
unit={formatRecordingTimeUnit(stats?.totalRecordingTimeMs ?? 0)}
/>
<StatCard
label="WORDS"
value={formatNumber(stats?.totalWordCount ?? 0)}
unit="단어"
/>
<StatCard
label="TODAY"
value={`${stats?.todaySessionCount ?? 0}`}
unit="세션"
/>
<StatCard
label="STREAK"
value={`${stats?.streakDays ?? 0}`}
unit="일"
/>
</Box>
{/* ── 3. 서비스 상태 (CRT 컴팩트) ────────────── */}
<Box sx={{ mt: 3 }}>
<CrtDisplay amplitude={0.05} frequency={6} height={120}>
<Box
sx={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 1,
px: 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: '11px',
color: svc.ok ? d3roPalette.accent.amber : d3roPalette.tag.red,
}}
>
{svc.status}
</PhosphorText>
</Box>
))}
</Box>
</CrtDisplay>
</Box>
{/* ── 4. 최근 히스토리 ──────────────────────── */}
<Box sx={{ mt: 4 }}>
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: d3roPalette.text.inactive }}>
RECENT TRANSCRIPTIONS
</PhosphorText>
{history.length === 0 ? (
<MetalCard>
<Box sx={{ py: 4, textAlign: 'center' }}>
<PhosphorText variant="dim">
</PhosphorText>
</Box>
</MetalCard>
) : (
groupedHistory.map((group) => (
<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' }}>
{group.label}
</PhosphorText>
<Box sx={{ flex: 1, height: '1px', bgcolor: d3roPalette.border.subtle }} />
<PhosphorText variant="dim" sx={{ fontSize: '10px' }}>
</PhosphorText>
</Box>
{/* 히스토리 항목 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{group.entries.map((entry) => (
<MetalCard key={entry.id}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<Led color={entry.status === 'completed' ? 'green' : 'red'} size={6} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
fontSize: '13px',
color: d3roPalette.text.primary,
lineHeight: 1.5,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{entry.polishedText || entry.originalText}
</Box>
<Box
sx={{
display: 'flex',
gap: 2,
mt: 1,
fontFamily: d3roFontMono,
fontSize: '10px',
color: d3roPalette.text.dimLabel,
letterSpacing: '0.5px',
}}
>
<span>{formatTime(entry.createdAt)}</span>
<span>{formatDuration(entry.duration)}</span>
{entry.detectedLanguage && <span>{entry.detectedLanguage.toUpperCase()}</span>}
<span>{entry.mode.toUpperCase()}</span>
</Box>
</Box>
<Tooltip title="복사" arrow>
<IconButton
size="small"
onClick={() => navigator.clipboard.writeText(entry.polishedText || entry.originalText)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}
>
<ContentCopyIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Box>
</MetalCard>
))}
</Box>
</Box>
))
)}
</Box>
</Box>
)
}

View file

@ -7,10 +7,10 @@ import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete'
import SearchIcon from '@mui/icons-material/Search'
import { MetalCard, PhosphorText, PhysicalButton } from '../components/ds'
import { d3roPalette, d3roFontMono } from '../theme'
import type { DictionaryEntry, DictionaryPage as DictPageData } from '@shared/types'
const PAGE_SIZE = 50
const MONO = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'
export function DictionaryPage(): React.ReactElement {
const [data, setData] = useState<DictPageData | null>(null)
@ -40,7 +40,7 @@ export function DictionaryPage(): React.ReactElement {
return (
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<PhosphorText variant="label" sx={{ color: '#77797c' }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.inactive }}>
CUSTOM DICTIONARY {data?.total ?? 0} WORDS
</PhosphorText>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setAddOpen(true)} size="small">
@ -53,8 +53,8 @@ export function DictionaryPage(): React.ReactElement {
value={search}
onChange={(e) => setSearch(e.target.value)}
fullWidth
sx={{ mb: 3, '& .MuiInputBase-input': { fontFamily: MONO, fontSize: '12px', letterSpacing: '0.5px' } }}
slotProps={{ input: { startAdornment: <InputAdornment position="start"><SearchIcon sx={{ color: '#77797c', fontSize: 18 }} /></InputAdornment> } }}
sx={{ mb: 3, '& .MuiInputBase-input': { fontFamily: d3roFontMono, fontSize: '12px', letterSpacing: '0.5px' } }}
slotProps={{ input: { startAdornment: <InputAdornment position="start"><SearchIcon sx={{ color: d3roPalette.text.inactive, fontSize: 18 }} /></InputAdornment> } }}
/>
{loading ? (
@ -72,17 +72,17 @@ export function DictionaryPage(): React.ReactElement {
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1.5 }}>
<Box sx={{ fontSize: '14px', fontWeight: 600, color: '#fff' }}>{entry.word}</Box>
<Box sx={{ fontSize: '14px', fontWeight: 600, color: d3roPalette.text.primary }}>{entry.word}</Box>
{entry.pronunciation && (
<Box sx={{ fontFamily: MONO, fontSize: '11px', color: '#5c2615' }}>[{entry.pronunciation}]</Box>
<Box sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.text.dimLabel }}>[{entry.pronunciation}]</Box>
)}
</Box>
<Box sx={{ fontFamily: MONO, fontSize: '10px', color: '#77797c', mt: 0.5, letterSpacing: '0.5px' }}>
<Box sx={{ fontFamily: d3roFontMono, fontSize: '10px', color: d3roPalette.text.inactive, mt: 0.5, letterSpacing: '0.5px' }}>
{entry.category.toUpperCase()} · {entry.usageCount}× USED
</Box>
</Box>
<IconButton size="small" onClick={() => { window.electronAPI.dictionary.delete({ id: entry.id }); loadData() }}
sx={{ color: '#77797c', '&:hover': { color: '#ef4444' } }}>
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>

View file

@ -7,10 +7,10 @@ import SearchIcon from '@mui/icons-material/Search'
import DeleteIcon from '@mui/icons-material/Delete'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import { MetalCard, PhosphorText, Led } from '../components/ds'
import { d3roPalette, d3roFontMono } from '../theme'
import type { HistoryEntry, HistoryPage as HistoryPageData } from '@shared/types'
const PAGE_SIZE = 20
const MONO = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'
function formatDate(ts: number): string {
return new Date(ts).toLocaleString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
@ -40,7 +40,7 @@ export function HistoryPage(): React.ReactElement {
return (
<Box sx={{ maxWidth: 900, mx: 'auto', p: 4 }}>
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: '#77797c' }}>
<PhosphorText variant="label" sx={{ mb: 2, display: 'block', color: d3roPalette.text.inactive }}>
TRANSCRIPTION LOG {data?.total ?? 0} ENTRIES
</PhosphorText>
@ -51,11 +51,11 @@ export function HistoryPage(): React.ReactElement {
fullWidth
sx={{
mb: 3,
'& .MuiInputBase-input': { fontFamily: MONO, fontSize: '12px', letterSpacing: '0.5px' },
'& .MuiInputBase-input': { fontFamily: d3roFontMono, fontSize: '12px', letterSpacing: '0.5px' },
}}
slotProps={{
input: {
startAdornment: <InputAdornment position="start"><SearchIcon sx={{ color: '#77797c', fontSize: 18 }} /></InputAdornment>,
startAdornment: <InputAdornment position="start"><SearchIcon sx={{ color: d3roPalette.text.inactive, fontSize: 18 }} /></InputAdornment>,
},
}}
/>
@ -76,13 +76,13 @@ export function HistoryPage(): React.ReactElement {
<Led color={entry.status === 'completed' ? 'green' : 'red'} size={6} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{
fontSize: '13px', color: '#fff', lineHeight: 1.5,
fontSize: '13px', color: d3roPalette.text.primary, lineHeight: 1.5,
overflow: 'hidden', textOverflow: 'ellipsis',
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
}}>
{entry.polishedText || entry.originalText}
</Box>
<Box sx={{ display: 'flex', gap: 2, mt: 1, fontFamily: MONO, fontSize: '10px', color: '#5c2615', letterSpacing: '0.5px' }}>
<Box sx={{ display: 'flex', gap: 2, mt: 1, fontFamily: d3roFontMono, fontSize: '10px', color: d3roPalette.text.dimLabel, letterSpacing: '0.5px' }}>
<span>{formatDate(entry.createdAt)}</span>
<span>{formatDuration(entry.duration)}</span>
{entry.detectedLanguage && <span>{entry.detectedLanguage.toUpperCase()}</span>}
@ -91,11 +91,11 @@ export function HistoryPage(): React.ReactElement {
</Box>
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
<IconButton size="small" onClick={() => navigator.clipboard.writeText(entry.polishedText || entry.originalText)}
sx={{ color: '#77797c', '&:hover': { color: '#f25b29' } }}>
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}>
<ContentCopyIcon sx={{ fontSize: 16 }} />
</IconButton>
<IconButton size="small" onClick={() => { window.electronAPI.history.delete({ id: entry.id }); loadData() }}
sx={{ color: '#77797c', '&:hover': { color: '#ef4444' } }}>
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>

View file

@ -40,7 +40,7 @@ body {
.wave-bar {
width: 3px;
background: #1F5DF2;
background: #f25b29; /* d3roPalette.accent.amber */
border-radius: 1.5px;
transition: height 100ms ease-out;
min-height: 2px;
@ -64,7 +64,7 @@ body {
.progress-bar {
height: 3px;
background: #1F5DF2;
background: #f25b29; /* d3roPalette.accent.amber */
border-radius: 1.5px;
width: 0%;
transition: width 100ms linear;

View file

@ -12,6 +12,11 @@ export const d3roPalette = {
cardHover: '#2a2a2d',
elevated: '#2e2e32',
input: '#1e1e21',
sidebar: '#1e1f21',
inset: '#1b1c1e',
chassis: '#242528',
crtBezel: '#1a1a1c',
crtGlass: '#050605',
},
accent: {
amber: '#f25b29',
@ -27,12 +32,20 @@ export const d3roPalette = {
redBg: 'rgba(239, 68, 68, 0.12)',
green: '#22c55e',
greenBg: 'rgba(34, 197, 94, 0.12)',
greenGlow: 'rgba(34, 197, 94, 0.6)',
redGlow: 'rgba(239, 68, 68, 0.6)',
orangeGlow: 'rgba(245, 158, 11, 0.6)',
},
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)',
@ -45,6 +58,9 @@ export const d3roPalette = {
scanline: 'rgba(0, 0, 0, 0.15)',
bg: '#242528',
},
led: {
off: '#111111',
},
} as const
export const d3roFontSans = [
@ -59,20 +75,20 @@ export const d3roFontMono = [
// ── 모드별 변동 팔레트 ────────────────────────────────────
interface ModePalette {
bg: { app: string; card: string; cardHover: string; elevated: string; input: string }
text: { primary: string; secondary: string; label: string; disabled: string }
bg: { app: string; card: string; cardHover: string; elevated: string; input: string; sidebar: string; inset: string; chassis: string; crtBezel: string; crtGlass: string }
text: { primary: string; secondary: string; label: string; disabled: string; engraving: string; inactive: string; dimLabel: string; muted: string; hover: string }
border: { subtle: string; default: string; strong: string }
}
const darkPalette: ModePalette = {
bg: { app: '#19191b', card: '#242427', cardHover: '#2a2a2d', elevated: '#2e2e32', input: '#1e1e21' },
text: { primary: '#ffffff', secondary: '#8e8e93', label: '#7c7c82', disabled: '#4a4a4e' },
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)' },
}
const lightPalette: ModePalette = {
bg: { app: '#f5f5f7', card: '#ffffff', cardHover: '#fafafa', elevated: '#f0f0f2', input: '#ffffff' },
text: { primary: '#1a1a1c', secondary: '#6e6e73', label: '#8e8e93', disabled: '#c7c7cc' },
bg: { app: '#f5f5f7', card: '#ffffff', cardHover: '#fafafa', elevated: '#f0f0f2', input: '#ffffff', sidebar: '#eeeef0', inset: '#e8e8ea', chassis: '#e0e0e3', crtBezel: '#d0d0d3', crtGlass: '#f8f8fa' },
text: { primary: '#1a1a1c', secondary: '#6e6e73', label: '#8e8e93', disabled: '#c7c7cc', engraving: '#d0d0d3', inactive: '#8e8e93', dimLabel: '#c4845a', muted: '#b0b0b4', hover: '#555555' },
border: { subtle: 'rgba(0,0,0,0.04)', default: 'rgba(0,0,0,0.08)', strong: 'rgba(0,0,0,0.12)' },
}