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',