diff --git a/CLAUDE.md b/CLAUDE.md index 2c7365b..3c3e0ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,31 +77,11 @@ npm run typecheck # tsc --noEmit 6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션 ## 현재 상태 -Phase: 7.5 완료 + UI WIP (Phase 1~7.5 + 3.5 기능 완료, UI 미완성) -마지막 작업: 인스트루먼트 UI 시도 (DS 컴포넌트 + CRT 셰이더) — WIP -다음 작업: **UI 디자인 근본 재설계** (아래 "디자인 재설계 TODO" 참조) +Phase: 8 진행 중 (Phase 1~7.5 + 3.5 완료, Phase 8 UI+기능 보강 진행) +마지막 작업: Phase 8 — SSOT 리팩토링 + HotkeyRecordModal + Dashboard 재작성 +다음 작업: **녹음→전사 파이프라인 E2E 테스트** (핫키 변경 후 실제 사이클 확인) 차단 이슈: @nut-tree-fork/nut-js 포크 사용 -### ⚠️ 디자인 재설계 TODO (다음 세션 최우선) -시안 A (Meteorological Instrument) 정체성을 앱 전체에 충실히 반영해야 함. -현재 WIP 상태에서 해결해야 할 문제: - -1. **SSOT 위반**: 색상/폰트 매직넘버 41곳 → 전부 d3roPalette/d3roFontMono 참조로 교체 -2. **시안 A 정체성 부족**: CRT+버튼은 만들었으나 "정밀 기기" 느낌이 안 살아남 - - 메탈 다이얼 컴포넌트 미구현 - - 서브페이지(History/Dict/Commands)가 여전히 평범한 리스트 -3. **모달/팝업 방치**: Settings 모달, 히스토리 팝업(Ctrl+Shift+V) 스타일 미적용 -4. **08-design-system.md 정비 필요**: 문서와 실제 구현의 괴리 해소 -5. **테마 시스템**: dark+light+auto 유지하되, 나중에 커스텀 테마 추가 가능 구조 - -순서: 08 문서 정비 → DS 토큰/컴포넌트 정비 → Dashboard → 서브페이지 → 모달/팝업 - -참조 파일: -- 시안 A HTML: docs/design-refs/instrument-panel.html (또는 유저가 준 HTML) -- 디자인 시스템: docs/design/08-design-system.md -- DS 컴포넌트: src/renderer/components/ds/ -- 메모리: memory/ui_design_lessons.md - ### DS 컴포넌트 현황 (src/renderer/components/ds/) - CrtDisplay.tsx: WebGL CRT 셰이더 (파형+스캔라인+비네팅+글리치) ✅ - InstrumentPanel.tsx: 메탈 섀시 컨테이너 (각인 텍스트, 노이즈) ✅ @@ -111,12 +91,16 @@ Phase: 7.5 완료 + UI WIP (Phase 1~7.5 + 3.5 기능 완료, UI 미완성) - PhosphorText.tsx: 인광 텍스트 (hero/value/label/dim) ✅ - ❌ MetalDial: 미구현 (시안 A의 핵심 요소) -### Phase 8 구현 내용 (WIP) -- theme.ts: d3roPalette(SSOT), createD3ROTheme('dark'|'light'), getTheme(mode, prefersDark) -- DS 컴포넌트 6개 생성 (CrtDisplay, InstrumentPanel, Led, PhysicalButton, MetalCard, PhosphorText) -- Dashboard: CRT WebGL + 물리 버튼 + LED — 시안 A 느낌 시도 중 -- 사이드바: 72px 미니 네비게이션 (물리 버튼 느낌) -- 문제: 매직넘버 41곳, 시안 A 정체성 부족, 모달/팝업 방치 +### Phase 8 구현 내용 (진행 중) +- **SSOT 완료**: d3roPalette에 11개 토큰 추가 (sidebar, chassis, inactive, dimLabel 등) +- **매직넘버 제거**: tsx/ts 파일 매직넘버 0개 달성 (theme.ts 제외) +- **DS 컴포넌트 SSOT**: 6개 모두 d3roPalette 참조로 교체 +- **HotkeyRecordModal**: 신규 — 커스텀 핫키 녹화 모달 (키 조합 입력→Chip 표시→저장) +- **SettingsModal 재작성**: 음성 모드 3개(받아쓰기/Agent/원터치) 토글+핫키 표시+변경 UI +- **DashboardPage 재작성**: 기능 중심 — Hero(핫키 표시) + 통계 4카드 + CRT 서비스 상태 + 최근 히스토리(날짜 그룹핑) +- **recording-tip 수정**: 웨이브 바/프로그레스 바 색상 #1F5DF2(파란)→#f25b29(앰버) +- **페이지 SSOT**: AppLayout, StatusBar, HistoryPage, DictionaryPage, CommandsPage 모두 완료 +- **설계 문서**: docs/phases/phase-8.md, docs/re-findings/speakly-settings-ui.md 작성 ### Phase 7.5 구현 내용 - SoundEffectService: WAV 프리로드, fire-and-forget, PowerShell SoundPlayer로 재생 diff --git a/docs/phases/phase-8.md b/docs/phases/phase-8.md new file mode 100644 index 0000000..edbebfe --- /dev/null +++ b/docs/phases/phase-8.md @@ -0,0 +1,213 @@ +# Phase 8: 핵심 기능 보강 + UI 디자인 근본 재설계 + +## 목표 +1. **핫키 변경 UI 구현** (HotkeyRecordModal) — 앱 테스트를 위한 차단 해제 +2. **Settings에 음성 모드 토글 3개 추가** (받아쓰기/Agent/원터치) +3. **d3roPalette SSOT 확장 및 매직넘버 ~40곳 제거** +4. **DashboardPage 기능 중심 재작성** (통계+히스토리+상태) +5. **HistoryPage 날짜 그룹핑** +6. **recording-tip 색상 버그 수정** (파란색 `#1F5DF2` → 앰버 `#f25b29`) + +## 전제 조건 +Phase 7.5 완료 (SoundEffect, AutoLaunch, HotkeyService, VoiceModeService 모두 동작) + +--- + +## 태스크 + +### 8.1 HotkeyRecordModal 구현 +- **파일**: `src/renderer/components/HotkeyRecordModal.tsx` +- Speakly HotkeyRecordModal 패턴 차용: + 1. Modal 열림 → `document.addEventListener('keydown')` 리스너 등록 + 2. 키 조합 감지 → `HotkeyBinding` 형태로 Chip/태그 표시 + 3. 유효성 검증: 시스템 예약키 블랙리스트 (Ctrl+C, Ctrl+V, Alt+F4, Ctrl+Alt+Del 등) + 4. 중복 핫키 충돌 감지 → 경고 메시지 표시 + 5. "저장" 클릭 → IPC `hotkey:setDictationShortcut` / `hotkey:setHandsFreeShortcut` 호출 + 6. "취소" 또는 ESC → 모달 닫기, 변경 없음 +- 키 표시: platform-specific 라벨 (`Ctrl` → Windows, 키코드 → 표시 문자열 매핑) +- HotkeyBinding 구조: + ```typescript + interface HotkeyBinding { + keyCode: number; + ctrl: boolean; + alt: boolean; + shift: boolean; + meta: boolean; + displayLabel: string; // 예: "Right Alt", "Ctrl+Shift+F5" + } + ``` +- **IPC 채널**: 기존 `hotkey:setDictationShortcut`, `hotkey:setHandsFreeShortcut`, `hotkey:setCommandShortcut` 활용 +- **시스템 예약키 블랙리스트** (`src/shared/constants.ts`에 추가): + ```typescript + export const RESERVED_HOTKEYS: Set = new Set([ + 'Ctrl+C', 'Ctrl+V', 'Ctrl+X', 'Ctrl+Z', 'Ctrl+A', 'Ctrl+S', + 'Alt+F4', 'Alt+Tab', 'Ctrl+Alt+Delete', 'Win+L', 'Win+D', + 'Ctrl+Shift+Escape', 'PrintScreen', + ]); + ``` + +### 8.2 Settings 핫키 섹션 UI +- **파일**: `src/renderer/components/SettingsModal.tsx` +- General 탭에 "Shortcuts" 섹션 추가 (기존 토글 설정 아래): + - 받아쓰기(Dictation) 핫키: 현재 바인딩 표시 + "변경" 버튼 → HotkeyRecordModal 열기 + - 핸즈프리(Hands-free) 핫키: 동일 패턴 + - Agent Mode 핫키: 더블프레스 기반이므로 "받아쓰기 키 2회 누름" 안내 텍스트만 표시 +- 각 핫키 옆에 현재 바인딩을 `Chip` 컴포넌트로 표시 (예: `Right Alt`) +- 핫키 변경 성공 시 → `Chip` 갱신 + 토스트 알림 +- IPC 호출: `hotkey:getDictationShortcut`, `hotkey:getHandsFreeShortcut`로 초기값 로드 + +### 8.3 음성 모드 토글 3개 (Settings 연동) +- **파일**: `src/renderer/components/SettingsModal.tsx` (General 탭) +- Speakly Settings의 음성 모드 섹션 패턴: + - **Dictation Mode**: `Switch` 토글 (기본 ON) + 핫키 Chip + "Hold-to-talk" 설명 + - **Agent Mode**: `Switch` 토글 (기본 OFF) + "Dictation 키 더블프레스" 설명 + - Dictation이 OFF이면 Agent도 비활성 (의존 관계 표시) + - **Hands-free (One-touch)**: `Switch` 토글 (기본 OFF) + 핫키 Chip + "Toggle on/off" 설명 +- **IPC / ConfigService 연동**: + - `config:set({ key: 'dictationEnabled', value: boolean })` + - `config:set({ key: 'agentModeEnabled', value: boolean })` + - `config:set({ key: 'handsFreeEnabled', value: boolean })` + - AppConfig에 3개 필드 추가 (`src/shared/types.ts`) +- **HotkeyService 연동**: 모드가 비활성이면 해당 핫키 이벤트 무시 + +### 8.4 d3roPalette SSOT 확장 +- **파일**: `src/renderer/theme.ts` +- 현재 `d3roPalette`에 누락된 시맨틱 토큰 추가: + ```typescript + export const d3roPalette = { + // ...기존 유지... + interactive: { + /** 비활성 아이콘/라벨 (현재 '#77797c' 매직넘버로 사용 중) */ + muted: '#77797c', + /** 호버 시 앰버로 전환할 때 사용 */ + hoverAccent: '#f25b29', + /** 호버 시 위험 색상 */ + hoverDanger: '#ef4444', + }, + metadata: { + /** 보조 정보 텍스트 (현재 '#5c2615' 매직넘버) */ + dim: '#5c2615', + }, + sidebar: { + bg: '#1e1f21', + activeBtn: '#242528', + activeBtnPressed: '#1a1a1c', + }, + shadow: { + /** 물리 버튼 돌출 */ + buttonRaised: '0 2px 4px rgba(0,0,0,0.3), inset 0 1px 1px rgba(255,255,255,0.06)', + /** 물리 버튼 눌림 */ + buttonPressed: 'inset 0 2px 4px rgba(0,0,0,0.6)', + /** 인셋 패널 */ + inset: 'inset 0 2px 6px rgba(0,0,0,0.6), 0 1px 1px rgba(255,255,255,0.05)', + }, + } as const; + ``` +- 목표: 모든 컴포넌트에서 `'#77797c'`, `'#5c2615'`, `'#1e1f21'`, `'#242528'` 등 매직넘버를 `d3roPalette.*` 참조로 교체 + +### 8.5 매직넘버 ~40곳 제거 +- **대상 파일** (grep 결과 기준): + - `src/renderer/components/AppLayout.tsx` (~15곳) + - `src/renderer/pages/HistoryPage.tsx` (~6곳) + - `src/renderer/pages/DictionaryPage.tsx` (~5곳) + - `src/renderer/pages/CommandsPage.tsx` (~5곳) + - `src/renderer/components/StatusBar.tsx` (~5곳) + - `src/renderer/components/ds/PhysicalButton.tsx` (~5곳) + - `src/renderer/components/ds/MetalCard.tsx` (~4곳) + - `src/renderer/components/ds/CrtDisplay.tsx` (~5곳) + - `src/renderer/components/ds/Led.tsx` (~4곳) +- **규칙**: `theme.ts`에서 `d3roPalette` / `d3roFontMono` export한 값만 사용 +- **예외**: DS 컴포넌트 내부의 WebGL 셰이더 uniform 값은 매직넘버 허용 (JS→GLSL 변환 복잡) +- **검증**: `grep -rn '#[0-9a-fA-F]{6}' src/renderer/ --include='*.tsx'` 결과에서 `theme.ts` 외 0건 + +### 8.6 DashboardPage 기능 중심 재작성 +- **파일**: `src/renderer/pages/DashboardPage.tsx` +- 현재 CRT+LED 인스트루먼트 패널 → **기능 중심 레이아웃**으로 교체: + 1. **상단: 통계 카드 4개** (MetalCard 사용) + - 총 세션 수 / 오늘 세션 수 / 총 녹음 시간 / 연속 사용 일수 + - PhosphorText로 값 표시, 각 카드에 Led 인디케이터 + 2. **중단: 최근 히스토리** (5~10건) + - 날짜 그룹핑 (오늘/어제/이번 주) + - 원본 텍스트 + 모드 Chip + 녹음 시간 + - 클릭 시 히스토리 상세 or 복사 + 3. **하단: 시스템 상태 패널** + - Ollama 연결 상태 (Led green/red) + - STT 엔진 상태 (모델명 + ready/loading) + - 현재 핫키 바인딩 표시 +- **IPC 호출**: `stats:getSummary`, `history:getAll({ page: 0, pageSize: 10 })`, `llm:getStatus`, `stt:getStatus` +- CRT 디스플레이는 제거하지 않고 **축소하여 시스템 상태 영역에 배치** (옵션) + +### 8.7 HistoryPage 날짜 그룹핑 +- **파일**: `src/renderer/pages/HistoryPage.tsx` +- 히스토리 목록을 날짜별 섹션으로 그룹핑: + - "오늘" / "어제" / "이번 주" / "이번 달" / "YYYY년 M월" + - 각 섹션 헤더: PhosphorText variant="label" + 항목 수 +- 그룹핑 유틸 함수: + ```typescript + function groupByDate(entries: HistoryEntry[]): Map { + // createdAt 기준으로 그룹핑 + // 오늘/어제/이번주/이번달/그외 구분 + } + ``` +- 날짜 섹션 사이 시각적 구분선 (Divider + 앰버 강조) +- 스크롤 시 현재 날짜 섹션 sticky header (옵션) + +### 8.8 recording-tip 색상 버그 수정 +- **파일**: `src/renderer/popups/recording-tip/style.css` +- **문제**: 웨이브 바와 프로그레스 바 색상이 Speakly 기본값 `#1F5DF2` (파란색)으로 하드코딩됨 +- **수정**: D3RO 앰버 악센트 `#f25b29`로 변경 + ```css + .wave-bar { + background: #f25b29; /* was: #1F5DF2 */ + } + .progress-bar { + background: #f25b29; /* was: #1F5DF2 */ + } + ``` +- **에러 아이콘**: 기존 `#D32F2F` → `#ef4444` (d3roPalette.tag.red)로 통일 + +### 8.9 AppConfig 타입 확장 +- **파일**: `src/shared/types.ts` +- 음성 모드 토글 3개 필드 추가: + ```typescript + export interface AppConfig { + // ...기존 필드 유지... + /** 받아쓰기 모드 활성화 (hold-to-talk) */ + dictationEnabled: boolean; + /** Agent 모드 활성화 (더블프레스, dictation 의존) */ + agentModeEnabled: boolean; + /** 핸즈프리 모드 활성화 (토글) */ + handsFreeEnabled: boolean; + } + ``` +- ConfigService 기본값 설정: `dictationEnabled: true`, `agentModeEnabled: false`, `handsFreeEnabled: false` + +### 8.10 DS 컴포넌트 d3roPalette 참조 전환 +- **대상 파일**: + - `src/renderer/components/ds/Led.tsx`: 색상 맵을 `d3roPalette.accent.*`, `d3roPalette.tag.*`에서 가져오기 + - `src/renderer/components/ds/PhysicalButton.tsx`: `#242528`, `#1a1a1c`, `#f25b29`, `#77797c` → 팔레트 참조 + - `src/renderer/components/ds/MetalCard.tsx`: `#1b1c1e`, `#242427`, `#2a2a2d` → 팔레트 참조 + - `src/renderer/components/ds/CrtDisplay.tsx`: `#1a1a1c`, `#050605`, `#f25b29` → 팔레트 참조 (셰이더 uniform 제외) +- DS 컴포넌트가 직접 hex 리터럴을 갖지 않고, 반드시 `d3roPalette` 또는 `theme.palette`에서 읽도록 변경 +- `import { d3roPalette } from '../../theme'` 패턴 통일 + +--- + +## Speakly RE 참조 +- **Settings.js**: General 탭에 음성 모드 토글 3개 (Dictation/Agent/Hands-free), 각 모드별 핫키 Chip, 마이크 테스트 +- **HotkeyRecordModal.js**: keydown 리스너 → 조합키 Chip 표시, 시스템 예약키 블랙리스트, platform-specific 키 라벨 +- **CustomInstructionPage.js**: 커스텀 명령어별 핫키 바인딩 UI (재활용 패턴) +- **Dashboard.js**: 통계 카드 4개, 최근 히스토리 날짜 그룹핑 (오늘/어제/이전) +- **HotkeyConfig.js**: VK 코드 → 표시 문자열 매핑 테이블, 시스템 예약키 감지 + +## 완료 조건 +- [ ] Settings에서 핫키 변경 가능 (HotkeyRecordModal 작동) +- [ ] 변경된 핫키로 녹음 → 전사 → 삽입 사이클 테스트 가능 +- [ ] Settings에 음성 모드 토글 3개 표시 + 설정 저장/로드 +- [ ] Dictation OFF → Agent 자동 비활성 연동 작동 +- [ ] DashboardPage에 통계 + 최근 히스토리 + 시스템 상태 표시 +- [ ] HistoryPage 날짜 그룹핑 (오늘/어제/이번 주 등) +- [ ] recording-tip 웨이브 바/프로그레스 바 색상이 앰버(`#f25b29`) +- [ ] `grep '#[0-9a-fA-F]{6}' src/renderer/**/*.tsx` 결과에서 `theme.ts` 외 매직넘버 0건 +- [ ] `npm run typecheck` 통과 +- [ ] 모든 DS 컴포넌트가 `d3roPalette` 참조 (hex 리터럴 직접 사용 없음) diff --git a/docs/re-findings/speakly-settings-ui.md b/docs/re-findings/speakly-settings-ui.md new file mode 100644 index 0000000..82b0152 --- /dev/null +++ b/docs/re-findings/speakly-settings-ui.md @@ -0,0 +1,472 @@ +# Speakly Settings & Hotkey UI 분석 + +> Speakly의 Settings Modal 및 HotkeyRecordModal 리버스엔지니어링 결과. +> D3RO-VOICE Phase 8 구현 시 참조 자료. + +--- + +## 1. Settings Modal 구조 + +### 1.1 전체 레이아웃 + +``` +Dialog (fullWidth, maxWidth='sm') + ├── DialogTitle: "Settings" + 닫기(X) 아이콘 + ├── Tabs: [Account | General | About] + └── TabPanels + ├── Account: 로그인 정보, 사용량, 구독 (D3RO 제거) + ├── General: 핵심 설정 전체 + └── About: 버전, 라이선스, 피드백 링크 +``` + +D3RO-VOICE 적용: Account 탭 제거, General 탭만 유지 (또는 탭 분할 확장: General / Audio / STT / LLM). + +### 1.2 General 탭 세부 섹션 + +General 탭은 상단부터 순서대로 다음 섹션으로 구성된다: + +``` +General Tab + ├── 🎤 Voice Modes (음성 모드) + │ ├── Dictation Mode — Switch + Hotkey Chip + │ ├── Agent Mode — Switch + "Dictation 더블프레스" 설명 + │ └── Hands-free (One-touch) — Switch + Hotkey Chip + │ + ├── 🔊 Audio + │ ├── Microphone — Select (디바이스 목록) + │ ├── Mic Test — Button + Level Meter + │ ├── Sound effects — Switch + │ └── Mute audio when dictating — Switch + │ + ├── 🌐 Language & Appearance + │ ├── UI Language — Select (ko/en) + │ ├── Theme — Select (light/dark/system) + │ └── Start minimized — Switch + │ + └── ⚙️ System + ├── Launch at startup — Switch + ├── Close to tray — Switch + └── Reset all settings — Button (확인 다이얼로그) +``` + +### 1.3 음성 모드 섹션 UI 패턴 + +각 모드는 동일한 행(row) 레이아웃을 따른다: + +``` +┌──────────────────────────────────────────────────────────────┐ +│ [Switch] Dictation Mode [Right Alt] Chip │ +│ Hold to talk – press and hold the shortcut key │ +│ to start recording │ +├──────────────────────────────────────────────────────────────┤ +│ [Switch] Agent Mode │ +│ Double-press the dictation shortcut to activate │ +│ (requires Dictation Mode enabled) │ +├──────────────────────────────────────────────────────────────┤ +│ [Switch] Hands-free (One-touch) [Ctrl+\] Chip │ +│ Toggle on/off – press once to start, again to stop │ +└──────────────────────────────────────────────────────────────┘ +``` + +**핵심 동작:** +- Dictation Switch OFF → Agent Mode Switch 자동 비활성 (disabled + 툴팁 "Dictation 필요") +- Hotkey Chip 클릭 → HotkeyRecordModal 열림 +- Agent Mode에는 별도 핫키가 없음 (Dictation 핫키 더블프레스로 트리거) + +### 1.4 마이크 선택 + 테스트 UI + +``` +┌──────────────────────────────────────────────────┐ +│ Microphone │ +│ [Select: System Default (Realtek Audio)] │ +│ │ +│ [🎤 Test Microphone] ▓▓▓▓▓░░░░░ Level: 0.45 │ +│ ← real-time RMS bar → │ +└──────────────────────────────────────────────────┘ +``` + +- 테스트 버튼 클릭 → `audio:testDevice` IPC (2초간 캡처) +- 캡처 중 실시간 RMS 레벨 바 표시 (100ms 간격 갱신) +- 완료 후 평균/피크 레벨 표시 +- 오디오 없으면 경고: "마이크에서 소리가 감지되지 않습니다" + +--- + +## 2. HotkeyRecordModal 구현 + +### 2.1 Modal 구조 + +``` +Dialog (maxWidth='xs') + ├── DialogTitle: "Record Shortcut" + ├── DialogContent + │ ├── 안내 텍스트: "Press the key combination you want to use" + │ ├── 키 표시 영역: [Chip][Chip][+][Chip] (예: Ctrl + Shift + F5) + │ ├── 유효성 메시지 (성공/경고/에러) + │ └── 현재 바인딩 표시: "Current: Right Alt" + └── DialogActions + ├── Cancel — 변경 없이 닫기 + └── Save — 새 바인딩 저장 +``` + +### 2.2 키 감지 로직 + +```typescript +// Modal mount 시 리스너 등록 +useEffect(() => { + if (!open) return; + + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const binding: HotkeyBinding = { + keyCode: e.keyCode, // deprecated지만 uiohook 호환용 + ctrl: e.ctrlKey, + alt: e.altKey, + shift: e.shiftKey, + meta: e.metaKey, + displayLabel: buildDisplayLabel(e), + }; + + // 수정자 키만 눌린 경우 → 아직 완성 안 됨 (Chip은 표시하되 Save 비활성) + if (isModifierOnly(e)) { + setPendingBinding(binding); + setIsComplete(false); + return; + } + + // 일반 키 + 수정자 조합 → 완성 + setPendingBinding(binding); + setIsComplete(true); + validate(binding); + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); +}, [open]); +``` + +### 2.3 키 표시 라벨 매핑 + +Speakly는 platform-specific 키 라벨을 사용한다. Windows 기준: + +```typescript +const KEY_LABELS: Record = { + // 수정자 키 + 0xA0: 'Left Shift', 0xA1: 'Right Shift', + 0xA2: 'Left Ctrl', 0xA3: 'Right Ctrl', + 0xA4: 'Left Alt', 0xA5: 'Right Alt', + 0x5B: 'Left Win', 0x5C: 'Right Win', + + // 기능 키 + 0x70: 'F1', 0x71: 'F2', 0x72: 'F3', 0x73: 'F4', + 0x74: 'F5', 0x75: 'F6', 0x76: 'F7', 0x77: 'F8', + 0x78: 'F9', 0x79: 'F10', 0x7A: 'F11', 0x7B: 'F12', + + // 특수 키 + 0x1B: 'Escape', 0x09: 'Tab', 0x14: 'CapsLock', + 0x20: 'Space', 0x0D: 'Enter', 0x08: 'Backspace', + 0x2D: 'Insert', 0x2E: 'Delete', 0x24: 'Home', + 0x23: 'End', 0x21: 'PageUp', 0x22: 'PageDown', + + // 방향키 + 0x25: '←', 0x26: '↑', 0x27: '→', 0x28: '↓', + + // 숫자패드 + 0x90: 'NumLock', 0x6F: 'Num/', 0x6A: 'Num*', + 0x6D: 'Num-', 0x6B: 'Num+', 0x6E: 'Num.', +}; + +function buildDisplayLabel(e: KeyboardEvent): string { + const parts: string[] = []; + if (e.ctrlKey) parts.push('Ctrl'); + if (e.altKey) parts.push('Alt'); + if (e.shiftKey) parts.push('Shift'); + if (e.metaKey) parts.push('Win'); + + // 수정자 키 자체는 중복 추가하지 않음 + if (!isModifierOnly(e)) { + const label = KEY_LABELS[e.keyCode] ?? e.key?.toUpperCase() ?? `Key${e.keyCode}`; + parts.push(label); + } + + return parts.join(' + '); +} +``` + +### 2.4 유효성 검증 + +```typescript +interface ValidationResult { + valid: boolean; + message: string; + severity: 'success' | 'warning' | 'error'; +} + +function validate(binding: HotkeyBinding): ValidationResult { + const label = binding.displayLabel; + + // 1. 시스템 예약키 체크 + if (RESERVED_HOTKEYS.has(label)) { + return { + valid: false, + message: `${label} is reserved by the system`, + severity: 'error', + }; + } + + // 2. 다른 모드와 중복 체크 + const existing = findConflict(binding); + if (existing) { + return { + valid: false, + message: `Already used by "${existing.modeName}"`, + severity: 'warning', + }; + } + + // 3. 단일 수정자 키 경고 (Right Alt 등은 허용하지만 경고) + if (isModifierOnly({ keyCode: binding.keyCode })) { + return { + valid: true, + message: 'Modifier-only shortcuts may conflict with other apps', + severity: 'warning', + }; + } + + return { valid: true, message: 'Shortcut available', severity: 'success' }; +} +``` + +### 2.5 MUI Chip 표시 + +```tsx +// 키 조합을 개별 Chip으로 표시 +function KeyChips({ label }: { label: string }) { + const parts = label.split(' + '); + return ( + + {parts.map((part, i) => ( + + {i > 0 && +} + + + ))} + + ); +} +``` + +--- + +## 3. 음성 모드 3가지 상세 분석 + +### 3.1 Dictation (받아쓰기) + +| 항목 | 값 | +|------|-----| +| 트리거 | 핫키 press (hold) | +| 녹음 방식 | hold-to-talk: 키 누르는 동안 녹음, 놓으면 종료 | +| 기본 핫키 | Right Alt (0xA5) | +| 더블프레스 | Agent Mode 트리거로 사용 (300ms 이내) | +| 최소 시간 | 700ms 이하 → accidentalPress 취소 | +| 후처리 | 기본: 원본 삽입, 설정에 따라 polish/translate | + +``` +키 press ─→ startRecording ─→ [holding] ─→ 키 release ─→ stopRecording + 700ms↑ ↓ + accidentalPress? STT → [LLM] → insert +``` + +### 3.2 Agent Mode (에이전트) + +| 항목 | 값 | +|------|-----| +| 트리거 | Dictation 핫키 더블프레스 (300ms 이내) | +| 녹음 방식 | Dictation과 동일 (hold-to-talk) | +| 의존성 | Dictation Mode가 활성이어야 함 | +| 후처리 | 항상 LLM 처리 (커스텀 명령어 또는 기본 polish) | +| 별도 핫키 | 없음 (Dictation 핫키의 더블프레스) | + +``` +키 press ─→ 300ms 이내 재press ─→ "Agent Mode" 활성 + │ + hold-to-talk (녹음) + │ + release → STT → LLM(agent) → insert +``` + +**HotkeyService 내부 처리:** +```typescript +// 더블프레스 감지 +private onKeyDown(hotkeyId: string, config: HotkeyConfig): void { + const now = Date.now(); + const lastPress = this.lastPressTime.get(hotkeyId) ?? 0; + + if (config.doublePressEnabled && (now - lastPress) < TIMING.DOUBLE_PRESS_DURATION) { + // 더블프레스 → Agent Mode + this.emit('double-press', { config, intervalMs: now - lastPress, timestamp: now }); + this.lastPressTime.delete(hotkeyId); + return; + } + + this.lastPressTime.set(hotkeyId, now); + this.emit('hotkey-pressed', { config, timestamp: now }); +} +``` + +### 3.3 Hands-free / One-touch (원터치) + +| 항목 | 값 | +|------|-----| +| 트리거 | 별도 핫키 press (toggle) | +| 녹음 방식 | 토글: 1회 누르면 녹음 시작, 다시 누르면 종료 | +| 기본 핫키 | 미설정 (사용자가 직접 바인딩) | +| 의존성 | 독립적 (Dictation과 무관) | +| 후처리 | 설정에 따라 분기 | +| VAD | 선택적 (침묵 감지 시 자동 종료 옵션) | + +``` +키 press(1회) ─→ startRecording ─→ [recording...] ─→ 키 press(2회) ─→ stopRecording + ↓ + STT → [LLM] → insert +``` + +**Speakly 구현 특징:** +- `holdMode: false`로 HotkeyConfig 등록 +- 내부 `isRecording` 상태 토글 +- ESC 키로 녹음 취소 (별도 리스너) +- UI에 녹음 중 상태 표시 (RecordingTip 지속 표시) + +--- + +## 4. D3RO-VOICE 적용 방안 + +### 4.1 HotkeyRecordModal 구현 계획 + +Speakly의 HotkeyRecordModal을 D3RO-VOICE의 IPC 채널에 맞게 재구현한다: + +``` +렌더러 (React) 메인 프로세스 + │ │ + │── hotkey:getDictationShortcut ────────►│ + │◄── HotkeyBinding ────────────────────│ + │ │ + │ [사용자가 키 조합 누름] │ + │ → 로컬 keydown 리스너로 감지 │ + │ → validate() 수행 │ + │ → UI에 Chip 표시 │ + │ │ + │── hotkey:setDictationShortcut ────────►│ + │ { binding: HotkeyBinding } │── HotkeyService.updateConfig() + │◄── { success: true } ─────────────────│── uiohook 핫키 재등록 +``` + +### 4.2 음성 모드 Settings UI 구현 + +SettingsModal의 General 탭에 음성 모드 섹션을 추가한다: + +```tsx +// Settings General 탭 — 음성 모드 섹션 +VOICE MODES + +{/* Dictation */} + + + + Dictation Mode + + Hold to talk — press and hold the shortcut key + + + openHotkeyRecord('dictation')} + clickable + /> + + +{/* Agent */} + + + Agent Mode + + Double-press the dictation shortcut + {!dictationEnabled && ' (requires Dictation Mode)'} + + + +{/* Hands-free */} + + + Hands-free (One-touch) + openHotkeyRecord('handsFree')} + clickable + /> + +``` + +### 4.3 IPC 채널 매핑 + +| Speakly 패턴 | D3RO-VOICE IPC 채널 | 비고 | +|-------------|---------------------|------| +| getHotkeyConfig('dictation') | `hotkey:getDictationShortcut` | 기존 | +| setHotkeyConfig('dictation', binding) | `hotkey:setDictationShortcut` | 기존 | +| getHotkeyConfig('handsFree') | `hotkey:getHandsFreeShortcut` | 기존 | +| setHotkeyConfig('handsFree', binding) | `hotkey:setHandsFreeShortcut` | 기존 | +| isVoiceModeEnabled('dictation') | `config:get({ key: 'dictationEnabled' })` | 신규 | +| setVoiceModeEnabled('dictation', bool) | `config:set({ key: 'dictationEnabled', value })` | 신규 | +| isVoiceModeEnabled('agent') | `config:get({ key: 'agentModeEnabled' })` | 신규 | +| isVoiceModeEnabled('handsFree') | `config:get({ key: 'handsFreeEnabled' })` | 신규 | + +### 4.4 키코드 호환성 + +D3RO-VOICE는 두 가지 키코드 시스템을 사용한다: + +| 컨텍스트 | 키코드 시스템 | 예시 (Right Alt) | +|---------|-------------|-----------------| +| ConfigService (저장) | Windows VK 코드 | `0xA5` (165) | +| uiohook-napi (런타임) | uiohook 키코드 | `UiohookKey.AltRight` (56) | +| KeyboardEvent (렌더러) | DOM keyCode | `18` (Alt) | + +HotkeyRecordModal에서 DOM keyCode를 수신하여 Windows VK 코드로 변환 후 저장한다. +HotkeyService에서는 VK 코드를 uiohook 키코드로 변환하여 매칭한다 (`vkToUiohook` 맵 기존 구현 참조). + +### 4.5 시스템 예약키 블랙리스트 + +D3RO-VOICE에서 차단할 키 조합: + +```typescript +// Windows 시스템 예약키 +const RESERVED = [ + 'Ctrl+C', 'Ctrl+V', 'Ctrl+X', 'Ctrl+Z', 'Ctrl+Y', // 편집 + 'Ctrl+A', 'Ctrl+S', 'Ctrl+P', 'Ctrl+F', // 일반 + 'Alt+F4', 'Alt+Tab', 'Alt+Escape', // 윈도우 관리 + 'Ctrl+Alt+Delete', 'Ctrl+Shift+Escape', // 시스템 + 'Win+L', 'Win+D', 'Win+E', 'Win+R', 'Win+Tab', // Win 단축키 + 'PrintScreen', 'Ctrl+PrintScreen', // 캡처 + 'F1', // 도움말 +]; +``` + +--- + +*끝.* diff --git a/src/renderer/components/AppLayout.tsx b/src/renderer/components/AppLayout.tsx index 0c39c7d..edda4cd 100644 --- a/src/renderer/components/AppLayout.tsx +++ b/src/renderer/components/AppLayout.tsx @@ -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 ( - + {/* ── 사이드바: 인스트루먼트 섀시 스타일 ─── */} @@ -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 { @@ -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' && } diff --git a/src/renderer/components/HotkeyRecordModal.tsx b/src/renderer/components/HotkeyRecordModal.tsx new file mode 100644 index 0000000..c10fdef --- /dev/null +++ b/src/renderer/components/HotkeyRecordModal.tsx @@ -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 = { + // 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([]) + const [isRecording, setIsRecording] = useState(false) + const [validationError, setValidationError] = useState(null) + const keysRef = useRef>(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 ( + + {title} + + {/* 녹화 영역 */} + + {recordedKeys.length > 0 ? ( + + {recordedKeys.map((key, i) => ( + + ))} + + ) : ( + + {isRecording ? '키 조합을 눌러주세요...' : '키를 입력하세요'} + + )} + + + {/* 검증 오류 */} + {validationError && ( + + {validationError} + + )} + + {/* 현재 설정 표시 */} + {currentBinding && ( + + 현재: {currentBinding.displayLabel} + + )} + + {/* 안내 */} + + 단일 키(예: Right Alt) 또는 조합키(예: Ctrl+Shift+Q)를 입력하세요 + + + + + {!isRecording && recordedKeys.length > 0 && ( + + )} + + + + ) +} diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index 465461b..c78113c 100644 --- a/src/renderer/components/SettingsModal.tsx +++ b/src/renderer/components/SettingsModal.tsx @@ -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 {children} } +// ── 핫키 표시 컴포넌트 ────────────────────────────────── +function HotkeyDisplay({ + binding, + onEdit, + label, +}: { + binding: HotkeyBinding | null + onEdit: () => void + label: string +}): React.ReactElement { + return ( + + + {label} + + {binding ? ( + + {binding.displayLabel.split(' + ').map((key) => ( + + ))} + + ) : ( + + 미설정 + + )} + + + + + ) +} + +// ── 음성 모드 카드 ────────────────────────────────────── +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 ( + + + + + + {title} + + 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={ + + } + sx={{ ml: 0, mr: 0 }} + /> + + + {description} + + + + {children && {children}} + + ) +} + export function SettingsModal({ open, onClose }: SettingsModalProps): React.ReactElement { const [activeTab, setActiveTab] = useState(0) const [config, setConfig] = useState>({}) const [loading, setLoading] = useState(true) + // 음성 모드 상태 + const [dictationEnabled, setDictationEnabled] = useState(true) + const [dictationBinding, setDictationBinding] = useState(null) + const [handsFreeEnabled, setHandsFreeEnabled] = useState(false) + const [handsFreeBinding, setHandsFreeBinding] = useState(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 return ( - - - Settings - - - - - - - setActiveTab(v)}> - - - - - + <> + + + 설정 + + + + + + + setActiveTab(v)}> + } iconPosition="start" /> + + + + - {/* General */} - - - - Theme - - + {/* ── 일반 탭 ─────────────────────────────── */} + + + {/* 단축키 섹션 */} + + 단축키 + - - Language - - + + {/* 받아쓰기 모드 */} + + openHotkeyModal('dictation')} + label="키" + /> + - updateConfig('closeToTray', e.target.checked)} + {/* Agent 모드 (더블프레스) */} + - } - label="Close to tray" - /> - updateConfig('autoInsert', e.target.checked)} - /> - } - label="Auto-insert text after transcription" - /> + {/* 원터치 모드 (핸즈프리) */} + + openHotkeyModal('handsFree')} + label="키" + /> + + - updateConfig('soundEnabled', e.target.checked)} - /> - } - label="Sound effects" - /> - - + - {/* Audio */} - - - - Microphone device selection will be available in a future update. - Currently using the system default microphone. - + {/* UI 설정 */} + + 인터페이스 + - - Insert Method - updateConfig('theme', e.target.value as ThemeMode)} + > + 시스템 + 라이트 + 다크 + + + + + 언어 + + + + + + {/* 앱 동작 */} + + 앱 동작 + + + updateConfig('closeToTray', e.target.checked)} + /> } - > - Clipboard (Ctrl+V) - Keyboard typing - - - - + label="트레이로 최소화" + /> - {/* STT */} - - - - Whisper Model - - + updateConfig('autoLaunch', e.target.checked)} + /> + } + label="시스템 시작 시 자동 실행" + /> - - Language - - - - + updateConfig('autoInsert', e.target.checked)} + /> + } + label="전사 후 자동 텍스트 삽입" + /> - {/* LLM */} - - - updateConfig('ollamaServerUrl', e.target.value)} - fullWidth - /> + updateConfig('soundEnabled', e.target.checked)} + /> + } + label="효과음" + /> + + - - LLM model selection will be available after Ollama integration (Phase 4). - - - - - + {/* ── 오디오 탭 ────────────────────────────── */} + + + + 현재 시스템 기본 마이크를 사용합니다. + 마이크 선택 및 테스트 기능은 추후 업데이트에서 제공됩니다. + + + + 삽입 방식 + + + + + + {/* ── STT 탭 ───────────────────────────────── */} + + + + Whisper 모델 + + + + + 인식 언어 + + + + + + {/* ── LLM 탭 ──────────────────────────────── */} + + + updateConfig('ollamaServerUrl', e.target.value)} + fullWidth + /> + + + Ollama가 실행 중이면 자동으로 연결됩니다. + 모델은 Ollama에서 직접 pull하세요 (예: ollama pull qwen3:4b). + + + + + + + {/* 핫키 녹화 모달 */} + setHotkeyModalOpen(false)} + onSave={handleHotkeySave} + currentBinding={hotkeyModalTarget === 'dictation' ? dictationBinding : handsFreeBinding} + title={hotkeyModalTarget === 'dictation' ? '받아쓰기 단축키 설정' : '원터치 모드 단축키 설정'} + /> + ) } diff --git a/src/renderer/components/StatusBar.tsx b/src/renderer/components/StatusBar.tsx index e8b4dee..7947d4e 100644 --- a/src/renderer/components/StatusBar.tsx +++ b/src/renderer/components/StatusBar.tsx @@ -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(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, }} > - + {connected ? 'OLLAMA' : 'OFFLINE'} {llmStatus?.activeModel && ( - + {llmStatus.activeModel.toUpperCase()} )} - + PRECISION DATA LINK diff --git a/src/renderer/components/ds/CrtDisplay.tsx b/src/renderer/components/ds/CrtDisplay.tsx index 8b2a6b2..80000f2 100644 --- a/src/renderer/components/ds/CrtDisplay.tsx +++ b/src/renderer/components/ds/CrtDisplay.tsx @@ -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} diff --git a/src/renderer/components/ds/InstrumentPanel.tsx b/src/renderer/components/ds/InstrumentPanel.tsx index 0b66069..5e379bb 100644 --- a/src/renderer/components/ds/InstrumentPanel.tsx +++ b/src/renderer/components/ds/InstrumentPanel.tsx @@ -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({ = { - 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 { diff --git a/src/renderer/components/ds/MetalCard.tsx b/src/renderer/components/ds/MetalCard.tsx index c72bf79..b506cf7 100644 --- a/src/renderer/components/ds/MetalCard.tsx +++ b/src/renderer/components/ds/MetalCard.tsx @@ -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 ( {children} diff --git a/src/renderer/components/ds/PhosphorText.tsx b/src/renderer/components/ds/PhosphorText.tsx index c65f276..2f12109 100644 --- a/src/renderer/components/ds/PhosphorText.tsx +++ b/src/renderer/components/ds/PhosphorText.tsx @@ -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 = { - 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 { @@ -23,7 +24,7 @@ export function PhosphorText({ variant = 'value', sx, ...props }: PhosphorTextPr { 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', diff --git a/src/renderer/pages/CommandsPage.tsx b/src/renderer/pages/CommandsPage.tsx index c5c7aa2..dc8d2fa 100644 --- a/src/renderer/pages/CommandsPage.tsx +++ b/src/renderer/pages/CommandsPage.tsx @@ -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 ( - + LLM INSTRUCTIONS — {instructions.length} COMMANDS @@ -64,16 +65,16 @@ export function CommandsPage(): React.ReactElement { - {inst.name} - {inst.description} + {inst.name} + {inst.description} - openEdit(inst)} sx={{ color: '#77797c', '&:hover': { color: '#f25b29' } }}> + openEdit(inst)} sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }}> {!inst.isBuiltin && ( - + )} diff --git a/src/renderer/pages/DashboardPage.tsx b/src/renderer/pages/DashboardPage.tsx index cbfd26d..47f7609 100644 --- a/src/renderer/pages/DashboardPage.tsx +++ b/src/renderer/pages/DashboardPage.tsx @@ -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 ( + + + + {label} + + + + {value} + + {unit && ( + + {unit} + + )} + + + + ) +} + +// ── 메인 컴포넌트 ───────────────────────────────────── export function DashboardPage(): React.ReactElement { const [stats, setStats] = useState(null) + const [history, setHistory] = useState([]) const [ollamaConnected, setOllamaConnected] = useState(false) - const [displayMode, setDisplayMode] = useState('stats') - const [glitchTrigger, setGlitchTrigger] = useState(0) + const [dictationBinding, setDictationBinding] = useState(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 = {} + 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 ( - + + {/* ── 1. HERO 영역 ──────────────────────────── */} - - {/* ── 좌측: CRT 디스플레이 ─────────────────── */} - - {displayMode === 'stats' && ( - <> - - TOTAL SESSIONS - D3RO - - - - {stats?.totalSessionCount ?? 0} - - - {formatTime(stats?.totalRecordingTimeMs ?? 0)} RECORDED - - - - - WORDS - {stats?.totalWordCount ?? 0} - - - TODAY - {stats?.todaySessionCount ?? 0} - - - STREAK - - {stats?.streakDays ?? 0}D - - - - - )} - - {displayMode === 'voice' && ( - <> - - VOICE MODE - STANDBY - - - IDLE - - PRESS RIGHT ALT TO DICTATE - - - - - MODE - DICT - - - HOTKEY - R.ALT - - - - )} - - {displayMode === 'sys' && ( - <> - - SYSTEM STATUS - DIAG - - - {[ - { 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) => ( - - {item.name} - - {item.status} - - - ))} - - - VERSION - v1.0.0 - - - )} - - - {/* ── 우측: 컨트롤 패널 ────────────────────── */} - - {/* LED 상태 클러스터 */} - - - - + + + + + + 타이핑 없이, D3RO-VOICE만으로 + + - {/* 모드 버튼 그룹 */} - - - switchMode('stats')} fullWidth> - STAT - - switchMode('voice')} fullWidth> - VOICE - - switchMode('sys')} fullWidth> - SYS - + {/* 핫키 표시 */} + + {dictationBinding ? ( + + {dictationBinding.displayLabel.split(' + ').map((key) => ( + + {key} + + ))} - + ) : ( + 핫키 미설정 + )} + + 키를 누른 상태에서 받아쓰기. 더블클릭하면 Agent 모드. + + + {/* ── 2. 통계 카드 ──────────────────────────── */} + + + + + + + + {/* ── 3. 서비스 상태 (CRT 컴팩트) ────────────── */} + + + + {services.map((svc) => ( + + + + {svc.name} + + + {svc.status} + + + ))} + + + + + {/* ── 4. 최근 히스토리 ──────────────────────── */} + + + RECENT TRANSCRIPTIONS + + + {history.length === 0 ? ( + + + + 히스토리 없음 — 핫키를 눌러 녹음을 시작하세요 + + + + ) : ( + groupedHistory.map((group) => ( + + {/* 날짜 구분자 */} + + + {group.label} + + + + 더 보기 + + + + {/* 히스토리 항목 */} + + {group.entries.map((entry) => ( + + + + + + {entry.polishedText || entry.originalText} + + + {formatTime(entry.createdAt)} + {formatDuration(entry.duration)} + {entry.detectedLanguage && {entry.detectedLanguage.toUpperCase()}} + {entry.mode.toUpperCase()} + + + + navigator.clipboard.writeText(entry.polishedText || entry.originalText)} + sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }} + > + + + + + + ))} + + + )) + )} + ) } diff --git a/src/renderer/pages/DictionaryPage.tsx b/src/renderer/pages/DictionaryPage.tsx index bf592f3..64f7ead 100644 --- a/src/renderer/pages/DictionaryPage.tsx +++ b/src/renderer/pages/DictionaryPage.tsx @@ -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(null) @@ -40,7 +40,7 @@ export function DictionaryPage(): React.ReactElement { return ( - + CUSTOM DICTIONARY — {data?.total ?? 0} WORDS