# 03. DB 스키마 & UI 컴포넌트 명세서 ## 1. SQLite 데이터베이스 스키마 D3RO-VOICE는 better-sqlite3 + drizzle-orm을 사용한다. Speakly의 `genspark-flow.db` 스키마를 참조하되, 클라우드 관련 컬럼은 제거하고 로컬 전용 필드를 추가한다. ### 1.1 history 테이블 세션(녹음 → 전사 → 다듬기) 단위로 한 행씩 저장한다. ```sql CREATE TABLE history ( id TEXT PRIMARY KEY, -- nanoid, sessionId와 동일 original_text TEXT NOT NULL, -- STT 원본 전사 텍스트 polished_text TEXT, -- LLM 다듬기/번역 결과 (NULL = 미사용) focused_app TEXT, -- 포커스 앱 실행 경로 focused_app_name TEXT, -- 포커스 앱 이름 (예: 'Code') focused_app_window_title TEXT, -- 포커스 윈도우 타이틀 mode TEXT NOT NULL DEFAULT 'dictation', -- 'dictation' | 'translate' | 'command' status TEXT NOT NULL DEFAULT 'completed', -- 'completed' | 'cancelled' | 'error' error_code TEXT, -- 실패 시 에러 코드 (D3ROError 코드) audio_local_path TEXT, -- 녹음 파일 경로 (NULL = 저장 안 함) duration REAL NOT NULL, -- 녹음 시간(초) detected_language TEXT, -- Whisper 감지 언어 코드 (예: 'ko', 'en') mic_device TEXT, -- 마이크 디바이스 ID word_count INTEGER NOT NULL DEFAULT 0, -- 단어 수 stt_model TEXT, -- 사용된 Whisper 모델명 llm_model TEXT, -- 사용된 Ollama 모델명 stt_latency_ms INTEGER, -- STT 처리 시간(ms) llm_latency_ms INTEGER, -- LLM 처리 시간(ms) created_at INTEGER NOT NULL, -- Unix timestamp (ms) updated_at INTEGER NOT NULL, -- Unix timestamp (ms) app_version TEXT NOT NULL DEFAULT '1.0.0' ); CREATE INDEX idx_history_created_at ON history(created_at DESC); CREATE INDEX idx_history_status ON history(status); CREATE INDEX idx_history_detected_language ON history(detected_language); CREATE INDEX idx_history_focused_app_name ON history(focused_app_name); CREATE INDEX idx_history_mode ON history(mode); ``` **Speakly 대비 변경점:** - 제거: `user_id`, `focused_app_bundle_id` (macOS 전용), `window_web_title/domain/url`, `audio_metadata`, `mic_device_info`, `selected_text` - 추가: `error_code`, `stt_model`, `llm_model`, `stt_latency_ms`, `llm_latency_ms` - 인덱스: `user_id` 복합 인덱스 → 단일 컬럼 인덱스로 단순화 (로컬 단일 사용자) ### 1.2 dictionary 테이블 사용자 커스텀 단어 사전. STT 정확도 향상 및 자동 교정에 사용한다. ```sql CREATE TABLE dictionary ( id TEXT PRIMARY KEY, -- nanoid word TEXT NOT NULL, -- 단어/구문 pronunciation TEXT, -- 발음 힌트 (선택) category TEXT NOT NULL DEFAULT 'user', -- 'user' | 'auto' | 'technical' usage_count INTEGER NOT NULL DEFAULT 0, -- 사용 횟수 last_used_at INTEGER, -- 마지막 사용 시각 (Unix timestamp ms) created_at INTEGER NOT NULL, -- Unix timestamp (ms) updated_at INTEGER NOT NULL -- Unix timestamp (ms) ); CREATE UNIQUE INDEX idx_dictionary_word_category ON dictionary(word, category); CREATE INDEX idx_dictionary_created_at ON dictionary(created_at); CREATE INDEX idx_dictionary_usage_count ON dictionary(usage_count DESC); ``` **Speakly 대비 변경점:** - 제거: `user_id`, `dict_type` → `category`로 통합 (로컬 전용이므로) - 제거: `dict_sync_meta` 테이블 전체 (클라우드 동기화 불필요) - 추가: `idx_dictionary_usage_count` 인덱스 (자주 쓰는 단어 우선) ### 1.3 stats 테이블 전역 통계. 싱글턴 row (id=1)로 운영한다. ```sql CREATE TABLE stats ( id INTEGER PRIMARY KEY CHECK (id = 1), -- 항상 1 total_duration REAL NOT NULL DEFAULT 0, -- 누적 녹음 시간(초) total_words INTEGER NOT NULL DEFAULT 0, -- 누적 단어 수 session_count INTEGER NOT NULL DEFAULT 0, -- 누적 세션 수 streak_days INTEGER NOT NULL DEFAULT 0, -- 연속 사용 일수 last_session_at INTEGER, -- 마지막 세션 시각 last_updated INTEGER NOT NULL -- Unix timestamp (ms) ); ``` **Speakly 대비 변경점:** - 추가: `streak_days`, `last_session_at` (대시보드 통계용) - `CHECK (id = 1)` 제약으로 싱글턴 보장 ### 1.4 config 테이블 키-값 저장소. electron-store의 JSON 파일 대안으로 사용하지 않고, 이 프로젝트에서는 electron-store를 그대로 사용한다 (CLAUDE.md 기술 스택). config 테이블은 **정의하지 않는다**. > **참고**: 설정은 `electron-store`로 관리한다. DB에 별도 config 테이블을 두지 않는 이유: > - electron-store는 JSON 기반으로 중첩 구조를 자연스럽게 지원 > - 스키마 검증, 기본값, 마이그레이션을 라이브러리가 처리 > - 설정 파일은 사용자가 직접 편집 가능 (config.json) --- ### 1.5 drizzle-orm TypeScript 스키마 ```typescript // src/main/db/schema.ts import { sqliteTable, text, integer, real, index, uniqueIndex } from 'drizzle-orm/sqlite-core'; import { sql } from 'drizzle-orm'; // ── history ────────────────────────────────────────────── export const history = sqliteTable('history', { id: text('id').primaryKey(), originalText: text('original_text').notNull(), polishedText: text('polished_text'), focusedApp: text('focused_app'), focusedAppName: text('focused_app_name'), focusedAppWindowTitle: text('focused_app_window_title'), mode: text('mode', { enum: ['dictation', 'translate', 'command'] }).notNull().default('dictation'), status: text('status', { enum: ['completed', 'cancelled', 'error'] }).notNull().default('completed'), errorCode: text('error_code'), audioLocalPath: text('audio_local_path'), duration: real('duration').notNull(), detectedLanguage: text('detected_language'), micDevice: text('mic_device'), wordCount: integer('word_count').notNull().default(0), sttModel: text('stt_model'), llmModel: text('llm_model'), sttLatencyMs: integer('stt_latency_ms'), llmLatencyMs: integer('llm_latency_ms'), createdAt: integer('created_at').notNull(), updatedAt: integer('updated_at').notNull(), appVersion: text('app_version').notNull().default('1.0.0'), }, (table) => [ index('idx_history_created_at').on(table.createdAt), index('idx_history_status').on(table.status), index('idx_history_detected_language').on(table.detectedLanguage), index('idx_history_focused_app_name').on(table.focusedAppName), index('idx_history_mode').on(table.mode), ]); // ── dictionary ─────────────────────────────────────────── export const dictionary = sqliteTable('dictionary', { id: text('id').primaryKey(), word: text('word').notNull(), pronunciation: text('pronunciation'), category: text('category', { enum: ['user', 'auto', 'technical'] }).notNull().default('user'), usageCount: integer('usage_count').notNull().default(0), lastUsedAt: integer('last_used_at'), createdAt: integer('created_at').notNull(), updatedAt: integer('updated_at').notNull(), }, (table) => [ uniqueIndex('idx_dictionary_word_category').on(table.word, table.category), index('idx_dictionary_created_at').on(table.createdAt), index('idx_dictionary_usage_count').on(table.usageCount), ]); // ── stats ──────────────────────────────────────────────── export const stats = sqliteTable('stats', { id: integer('id').primaryKey(), totalDuration: real('total_duration').notNull().default(0), totalWords: integer('total_words').notNull().default(0), sessionCount: integer('session_count').notNull().default(0), streakDays: integer('streak_days').notNull().default(0), lastSessionAt: integer('last_session_at'), lastUpdated: integer('last_updated').notNull(), }); // ── 타입 추출 ──────────────────────────────────────────── export type History = typeof history.$inferSelect; export type NewHistory = typeof history.$inferInsert; export type Dictionary = typeof dictionary.$inferSelect; export type NewDictionary = typeof dictionary.$inferInsert; export type Stats = typeof stats.$inferSelect; ``` ### 1.6 DB 초기화 코드 ```typescript // src/main/db/index.ts import Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; import { app } from 'electron'; import path from 'path'; import * as schema from './schema'; let db: ReturnType; export function initDatabase(): typeof db { const dbPath = path.join(app.getPath('userData'), 'd3ro-voice.db'); const sqlite = new Database(dbPath); sqlite.pragma('journal_mode = WAL'); sqlite.pragma('foreign_keys = ON'); sqlite.pragma('busy_timeout = 5000'); db = drizzle(sqlite, { schema }); migrate(db, { migrationsFolder: path.join(__dirname, 'migrations') }); // stats 싱글턴 초기화 sqlite.exec(` INSERT OR IGNORE INTO stats (id, total_duration, total_words, session_count, streak_days, last_updated) VALUES (1, 0, 0, 0, 0, ${Date.now()}) `); return db; } export function getDatabase(): typeof db { return db; } ``` --- ## 2. React 컴포넌트 명세 ### 2.1 컴포넌트 트리 ``` App ├── ThemeProvider (light/dark/auto) │ ├── CssBaseline │ └── AppLayout │ ├── AppDrawer (240px, permanent variant) │ │ ├── DrawerHeader (로고 + 앱 이름) │ │ ├── NavItems │ │ │ ├── NavItem[Dashboard] │ │ │ ├── NavItem[History] │ │ │ ├── NavItem[Dictionary] │ │ │ └── NavItem[CustomCommand] │ │ └── DrawerBottomBar │ │ └── SettingsButton (→ Settings Modal) │ └── ContentArea │ ├── Dashboard │ │ ├── StatsCard (총 녹음시간, 총 단어수, 세션수, 연속일) │ │ ├── RecentSessions (최근 5개) │ │ └── QuickActions (녹음 시작 버튼) │ ├── HistoryPage │ │ ├── SearchBar │ │ ├── FilterBar (mode, status, language) │ │ ├── HistoryList │ │ │ └── HistoryItem (원본/다듬기 텍스트, 메타데이터) │ │ └── Pagination │ ├── DictionaryPage │ │ ├── SearchBar │ │ ├── AddWordDialog │ │ └── WordList │ │ └── WordItem (단어, 발음, 카테고리, 사용횟수) │ ├── CustomCommandPage │ │ ├── CommandList │ │ └── CommandEditor │ └── SettingsModal │ ├── GeneralTab (테마, 언어, 자동실행) │ ├── AudioTab (마이크 선택, 녹음 설정) │ ├── SttTab (Whisper 모델 선택, 언어) │ ├── LlmTab (Ollama 엔드포인트, 모델) │ ├── TtsTab (Kokoro TTS 음성 선택, 속도, edge-tts 폴백) │ ├── HotkeyTab (단축키 설정) │ └── AboutTab (버전, 라이선스) ``` ### 2.2 컴포넌트 상세 명세 #### App (루트) ```typescript // src/renderer/components/App.tsx interface AppState { currentRoute: 'dashboard' | 'history' | 'dictionary' | 'customCommand'; settingsOpen: boolean; theme: 'light' | 'dark' | 'auto'; } ``` | 항목 | 내용 | |------|------| | Props | 없음 (루트) | | 내부 상태 | `currentRoute`, `settingsOpen`, `theme` | | IPC 호출 | `config:getTheme` (초기화 시) | | 이벤트 구독 | `config:themeChanged` | | 렌더링 조건 | `currentRoute`로 switch 분기 (React Router 미사용) | | MUI 컴포넌트 | `ThemeProvider`, `CssBaseline`, `Box` | #### AppDrawer ```typescript interface AppDrawerProps { currentRoute: string; onNavigate: (route: string) => void; onSettingsOpen: () => void; } ``` | 항목 | 내용 | |------|------| | Props | `currentRoute`, `onNavigate`, `onSettingsOpen` | | 내부 상태 | 없음 | | IPC 호출 | 없음 | | 이벤트 구독 | 없음 | | MUI 컴포넌트 | `Drawer` (permanent, 240px), `List`, `ListItemButton`, `ListItemIcon`, `ListItemText`, `Divider` | #### Dashboard ```typescript interface DashboardProps { // 없음 } interface DashboardState { stats: Stats | null; recentSessions: History[]; isLoading: boolean; } ``` | 항목 | 내용 | |------|------| | Props | 없음 | | 내부 상태 | `stats`, `recentSessions`, `isLoading` | | IPC 호출 | `db:getStats`, `db:getRecentHistory(5)` | | 이벤트 구독 | `voice:sessionCompleted` (실시간 갱신) | | 렌더링 조건 | `isLoading` → Skeleton, `stats === null` → 초기화 안내 | | MUI 컴포넌트 | `Card`, `CardContent`, `Typography`, `Grid2`, `Skeleton`, `Button`, `Chip` | #### HistoryPage ```typescript interface HistoryPageState { items: History[]; total: number; page: number; pageSize: number; search: string; filters: { mode: string | null; status: string | null; language: string | null; }; isLoading: boolean; } ``` | 항목 | 내용 | |------|------| | Props | 없음 | | 내부 상태 | `items`, `total`, `page`, `pageSize`, `search`, `filters`, `isLoading` | | IPC 호출 | `db:searchHistory({ search, filters, page, pageSize })`, `db:deleteHistory(id)`, `db:retrySession(id)` | | 이벤트 구독 | `voice:sessionCompleted` (리스트 갱신) | | 렌더링 조건 | `isLoading` → Skeleton, `items.length === 0` → EmptyState | | MUI 컴포넌트 | `TextField` (검색), `Select`/`MenuItem` (필터), `List`, `ListItem`, `IconButton`, `Pagination`, `Tooltip` | #### HistoryItem ```typescript interface HistoryItemProps { item: History; onDelete: (id: string) => void; onRetry: (id: string) => void; onCopy: (text: string) => void; } ``` | 항목 | 내용 | |------|------| | Props | `item`, `onDelete`, `onRetry`, `onCopy` | | 내부 상태 | `expanded: boolean`, `copied: boolean` | | IPC 호출 | 없음 (부모 위임) | | 렌더링 조건 | `item.polishedText` 존재 시 원본/다듬기 둘 다 표시, `item.status === 'error'` → 에러 칩 + 재시도 버튼 | | MUI 컴포넌트 | `ListItem`, `ListItemText`, `Collapse`, `Chip`, `IconButton`, `Typography` | #### DictionaryPage ```typescript interface DictionaryPageState { words: Dictionary[]; search: string; addDialogOpen: boolean; isLoading: boolean; } ``` | 항목 | 내용 | |------|------| | Props | 없음 | | 내부 상태 | `words`, `search`, `addDialogOpen`, `isLoading` | | IPC 호출 | `db:getDictionary({ search })`, `db:addWord(word)`, `db:deleteWord(id)`, `db:updateWord(id, data)` | | 이벤트 구독 | 없음 | | MUI 컴포넌트 | `TextField`, `Button`, `Dialog`, `DialogTitle`, `DialogContent`, `DialogActions`, `List`, `ListItem`, `IconButton` | #### SettingsModal ```typescript interface SettingsModalProps { open: boolean; onClose: () => void; } interface SettingsModalState { activeTab: 'general' | 'audio' | 'stt' | 'llm' | 'tts' | 'hotkey' | 'about'; config: Record; isDirty: boolean; } ``` | 항목 | 내용 | |------|------| | Props | `open`, `onClose` | | 내부 상태 | `activeTab`, `config`, `isDirty` | | IPC 호출 | `config:getAll`, `config:set(key, value)`, `audio:getDevices`, `stt:getModels`, `llm:getModels`, `tts:getVoices` | | 이벤트 구독 | `audio:devicesChanged` | | 렌더링 조건 | `activeTab`으로 탭 패널 분기 | | MUI 컴포넌트 | `Dialog` (fullWidth, maxWidth='md'), `Tabs`, `Tab`, `TabPanel` (커스텀), `TextField`, `Select`, `Switch`, `Slider`, `Button` | --- ## 3. Vanilla JS 팝업 명세 팝업은 React 번들을 로드하지 않는다. 개별 HTML + 순수 JS로 빠른 로딩을 보장한다. ### 3.1 RecordingTip 팝업 녹음 중 커서 근처에 표시되는 소형 팝업. 웨이브 바 애니메이션으로 녹음 상태를 시각적으로 표현한다. **파일 위치**: `src/renderer/popups/recording-tip/` #### HTML 구조 ```html
0:00
``` #### DOM 이벤트 핸들러 | 이벤트 | 대상 | 동작 | |--------|------|------| | `DOMContentLoaded` | `window` | 웨이브 바 9개 생성, IPC 리스너 등록 | | `click` | `#container` | 녹음 취소 IPC 전송 | #### IPC 통신 채널 | 채널 | 방향 | 데이터 | 용도 | |------|------|--------|------| | `tip:prepare` | main→renderer | `{ state, params }` | 상태 전환 준비 (숨겨진 상태에서 측정) | | `tip:show` | main→renderer | `{ state }` | 리사이즈 완료 후 표시 | | `tip:hide` | main→renderer | — | 팝업 숨기기 | | `tip:audioLevel` | main→renderer | `{ level: number }` | 오디오 레벨 (0.0~1.0) | | `tip:measured` | renderer→main | `{ width, height }` | 측정된 콘텐츠 크기 전달 | | `voice:cancel` | renderer→main | — | 녹음 취소 요청 | #### 상태별 렌더링 ``` recording → #recording-view 표시, 웨이브 바 애니메이션 활성, 경과 시간 카운터 thinking → #thinking-view 표시, 프로그레스 바 애니메이션 error → #error-view 표시, 에러 메시지, 3초 후 자동 숨김 ``` #### 웨이브 바 애니메이션 상세 ```javascript // 파라미터 const BAR_COUNT = 9; const UPDATE_INTERVAL = 100; // ms const MIN_HEIGHT = 2; // px const MAX_HEIGHT = 28; // px const SMOOTHING = 0.5; // 보간 계수 const RANDOM_FACTOR = 0.35; // ±35% 변동 // 코사인 분포 가중치 (중앙이 가장 높음) const weights = Array.from({ length: BAR_COUNT }, (_, n) => { const center = (BAR_COUNT - 1) / 2; // 4 const normalized = (n - center) / center; // -1 ~ +1 return Math.cos(normalized * Math.PI / 2); // 결과: [0, 0.383, 0.707, 0.924, 1, 0.924, 0.707, 0.383, 0] }); // 매 100ms 마다 실행 let currentHeights = new Array(BAR_COUNT).fill(MIN_HEIGHT); function updateBars(audioLevel) { for (let i = 0; i < BAR_COUNT; i++) { const baseTarget = audioLevel * MAX_HEIGHT * weights[i]; const randomized = baseTarget * (1 + (Math.random() - 0.5) * 2 * RANDOM_FACTOR); const target = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, randomized)); // 스무딩 보간 currentHeights[i] += (target - currentHeights[i]) * SMOOTHING; bars[i].style.height = `${currentHeights[i]}px`; } } // setInterval(updateBars, UPDATE_INTERVAL) — tip:audioLevel 이벤트 수신 시 audioLevel 갱신 ``` #### Thinking 프로그레스 바 ```javascript // 시간 기반 점근 수렴: 95%에서 정체 let thinkingStartTime = 0; function startThinking() { thinkingStartTime = performance.now(); requestAnimationFrame(updateThinkingProgress); } function updateThinkingProgress() { const elapsed = (performance.now() - thinkingStartTime) / 1000; // 초 const progress = Math.min(95, (1 - 1 / (1 + 1.5 * elapsed)) * 100); progressBar.style.width = `${progress}%`; if (progress < 95) { requestAnimationFrame(updateThinkingProgress); } } // 완료 시 → 100%로 빠르게 채운 뒤 hide function completeThinking() { progressBar.style.transition = 'width 200ms ease-out'; progressBar.style.width = '100%'; setTimeout(() => hide(), 300); } ``` #### CSS 애니메이션 스펙 ```css .recording-tip { background: rgba(0, 0, 0, 0.85); border-radius: 8px; padding: 8px 12px; display: flex; align-items: center; gap: 8px; backdrop-filter: blur(10px); transition: opacity 150ms ease-in-out; } .wave-bars { display: flex; align-items: center; gap: 2px; height: 32px; } .wave-bar { width: 3px; background: #1F5DF2; /* primary color */ border-radius: 1.5px; transition: height 100ms ease-out; } .progress-bar { height: 3px; background: #1F5DF2; border-radius: 1.5px; transition: width 100ms linear; } .view { display: flex; align-items: center; gap: 8px; } .view.hidden { display: none; } ``` ### 3.2 ResultPopup 팝업 전사/다듬기 결과를 표시하는 팝업. 커서 근처에 나타나며 자동으로 사라진다. **파일 위치**: `src/renderer/popups/result-popup/` #### HTML 구조 ```html
``` #### DOM 이벤트 핸들러 | 이벤트 | 대상 | 동작 | |--------|------|------| | `DOMContentLoaded` | `window` | IPC 리스너 등록 | | `click` | `#copy-btn` | 텍스트 클립보드 복사, `.copied` 클래스 2초 | | `click` | `#retry-btn` | `voice:retry` IPC 전송 | | `mouseenter` | `#container` | auto-close 타이머 일시정지 | | `mouseleave` | `#container` | auto-close 타이머 재개 | #### IPC 통신 채널 | 채널 | 방향 | 데이터 | 용도 | |------|------|--------|------| | `result:prepare` | main→renderer | `{ text, mode }` | 결과 텍스트 세팅 + 크기 측정 | | `result:show` | main→renderer | — | 리사이즈 완료 후 표시 | | `result:hide` | main→renderer | — | 팝업 숨기기 | | `result:measured` | renderer→main | `{ width, height }` | 측정된 크기 전달 | | `voice:retry` | renderer→main | — | 재시도 요청 | #### 2-Phase 리사이즈 + 높이 측정 ```javascript // Phase 1: prepare — 숨겨진 상태에서 콘텐츠 렌더링 후 크기 측정 window.electronAPI.on('result:prepare', ({ text, mode }) => { resultText.textContent = text; container.className = `result-popup ${mode}`; // requestAnimationFrame으로 레이아웃 완료 대기 후 측정 requestAnimationFrame(() => { requestAnimationFrame(() => { const rect = container.getBoundingClientRect(); window.electronAPI.send('result:measured', { width: Math.ceil(rect.width), height: Math.ceil(rect.height), }); }); }); }); // Phase 2: show — 메인 프로세스가 윈도우 리사이즈 완료 후 호출 window.electronAPI.on('result:show', () => { container.classList.add('visible'); startAutoCloseTimer(5000); // 5초 후 자동 닫기 }); ``` #### 복사 버튼 + `.copied` 클래스 ```javascript copyBtn.addEventListener('click', () => { navigator.clipboard.writeText(resultText.textContent); copyBtn.classList.add('copied'); copyIcon.classList.add('hidden'); checkIcon.classList.remove('hidden'); setTimeout(() => { copyBtn.classList.remove('copied'); copyIcon.classList.remove('hidden'); checkIcon.classList.add('hidden'); }, 2000); }); ``` #### Auto-close 제어 ```javascript let autoCloseTimer = null; let remainingTime = 0; let lastTick = 0; function startAutoCloseTimer(ms) { remainingTime = ms; lastTick = Date.now(); autoCloseTimer = setInterval(() => { remainingTime -= (Date.now() - lastTick); lastTick = Date.now(); if (remainingTime <= 0) { clearInterval(autoCloseTimer); window.electronAPI.send('result:hide'); } }, 100); } container.addEventListener('mouseenter', () => { clearInterval(autoCloseTimer); // 호버 중 타이머 정지 }); container.addEventListener('mouseleave', () => { startAutoCloseTimer(remainingTime > 0 ? remainingTime : 2000); // 남은 시간 또는 2초 }); ``` #### CSS 애니메이션 ```css .result-popup { background: #FFFFFF; border: 1px solid rgba(0, 0, 0, 0.08); border-radius: 12px; padding: 12px 16px; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12); opacity: 0; transform: translateY(4px); transition: opacity 200ms ease-out, transform 200ms ease-out; max-width: 400px; } .result-popup.visible { opacity: 1; transform: translateY(0); } .action-btn { /* ... */ } .action-btn.copied { color: #4CAF50; transition: color 200ms ease; } /* 다크모드 */ @media (prefers-color-scheme: dark) { .result-popup { background: #1E1E1E; border-color: rgba(255, 255, 255, 0.08); } } ``` --- ## 4. MUI 테마 전체 정의 Speakly 테마 분석 결과를 기반으로 한 완전한 createTheme() 코드. ```typescript // src/renderer/theme.ts import { createTheme, type ThemeOptions } from '@mui/material/styles'; const commonOptions: ThemeOptions = { typography: { fontFamily: [ '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif', ].join(','), h4: { fontWeight: 600, fontSize: '1.5rem' }, h5: { fontWeight: 600, fontSize: '1.25rem' }, h6: { fontWeight: 600, fontSize: '1rem' }, subtitle1: { fontWeight: 500 }, body1: { fontSize: '0.9375rem' }, body2: { fontSize: '0.8125rem' }, button: { textTransform: 'none' as const, fontWeight: 500 }, }, shape: { borderRadius: 12, }, components: { MuiButton: { defaultProps: { disableElevation: true, }, styleOverrides: { root: { textTransform: 'none', fontWeight: 500, borderRadius: 8, padding: '8px 16px', }, containedPrimary: { '&:hover': { boxShadow: '0 2px 8px rgba(31, 93, 242, 0.3)' }, }, }, }, MuiCard: { defaultProps: { elevation: 0, }, styleOverrides: { root: { borderRadius: 12, border: '1px solid', }, }, }, MuiDrawer: { styleOverrides: { paper: { width: 240, borderRight: 'none', }, }, }, MuiListItemButton: { styleOverrides: { root: { borderRadius: 8, marginLeft: 8, marginRight: 8, '&.Mui-selected': { fontWeight: 600, }, }, }, }, MuiDialog: { styleOverrides: { paper: { borderRadius: 16, }, }, }, MuiTextField: { defaultProps: { size: 'small', variant: 'outlined', }, }, MuiChip: { styleOverrides: { root: { borderRadius: 6, fontWeight: 500, }, }, }, MuiTooltip: { defaultProps: { arrow: true, }, }, }, }; export const lightTheme = createTheme({ ...commonOptions, palette: { mode: 'light', primary: { main: 'rgb(31, 93, 242)', // #1F5DF2 light: 'rgb(71, 133, 255)', dark: 'rgb(20, 65, 180)', contrastText: '#FFFFFF', }, secondary: { main: 'rgb(108, 117, 125)', light: 'rgb(173, 181, 189)', dark: 'rgb(73, 80, 87)', }, background: { default: '#F9F9F9', paper: '#FFFFFF', }, text: { primary: 'rgba(0, 0, 0, 0.87)', secondary: 'rgba(0, 0, 0, 0.6)', }, divider: 'rgba(0, 0, 0, 0.08)', error: { main: '#D32F2F', light: '#EF5350', }, success: { main: '#2E7D32', light: '#4CAF50', }, warning: { main: '#ED6C02', }, }, components: { ...commonOptions.components, MuiCard: { ...commonOptions.components?.MuiCard, styleOverrides: { root: { borderRadius: 12, border: '1px solid rgba(0, 0, 0, 0.08)', backgroundColor: '#FFFFFF', }, }, }, }, }); export const darkTheme = createTheme({ ...commonOptions, palette: { mode: 'dark', primary: { main: 'rgb(71, 133, 255)', // 밝은 블루 (다크모드에서 가독성) light: 'rgb(120, 170, 255)', dark: 'rgb(31, 93, 242)', contrastText: '#FFFFFF', }, secondary: { main: 'rgb(173, 181, 189)', light: 'rgb(206, 212, 218)', dark: 'rgb(108, 117, 125)', }, background: { default: '#121212', paper: '#1E1E1E', }, text: { primary: 'rgba(255, 255, 255, 0.87)', secondary: 'rgba(255, 255, 255, 0.6)', }, divider: 'rgba(255, 255, 255, 0.08)', error: { main: '#EF5350', light: '#FF7961', }, success: { main: '#4CAF50', light: '#66BB6A', }, warning: { main: '#FFA726', }, }, components: { ...commonOptions.components, MuiCard: { ...commonOptions.components?.MuiCard, styleOverrides: { root: { borderRadius: 12, border: '1px solid rgba(255, 255, 255, 0.08)', backgroundColor: '#1E1E1E', }, }, }, }, }); // 테마 선택 유틸 export function getTheme(mode: 'light' | 'dark'): typeof lightTheme { return mode === 'dark' ? darkTheme : lightTheme; } ``` --- ## 5. 에러 UI 패턴 ### 5.1 에러 분류 | 카테고리 | 에러 코드 | 재시도 가능 | UI 표시 위치 | |----------|-----------|------------|-------------| | STT | `STT_MODEL_NOT_FOUND` | X | SettingsModal 유도 | | STT | `STT_PROCESS_CRASHED` | O | RecordingTip → error 상태 | | STT | `STT_TIMEOUT` | O | RecordingTip → error 상태 | | LLM | `LLM_CONNECTION_FAILED` | O | RecordingTip → error 상태 | | LLM | `LLM_MODEL_NOT_FOUND` | X | SettingsModal 유도 | | LLM | `LLM_TIMEOUT` | O | RecordingTip → error 상태 | | Audio | `AUDIO_DEVICE_NOT_FOUND` | X | 시스템 토스트 | | Audio | `AUDIO_PERMISSION_DENIED` | X | 시스템 토스트 + 권한 안내 | | Audio | `AUDIO_TOO_SHORT` | X | RecordingTip → 무시 (자동 숨김) | | Hotkey | `HOTKEY_REGISTER_FAILED` | X | SettingsModal 유도 | | TextInsert | `INSERT_FAILED` | O | ResultPopup → 에러 상태 | | DB | `DB_WRITE_FAILED` | X | 백그라운드 로그만 | ### 5.2 RecordingTip 에러 표시 ```javascript function showError(errorCode, message) { hideAllViews(); errorView.classList.remove('hidden'); errorText.textContent = message; const isRetryable = RETRYABLE_ERRORS.has(errorCode); const hideDelay = isRetryable ? 10000 : 3000; if (isRetryable) { errorText.textContent += ' (다시 시도해주세요)'; } setTimeout(() => { window.electronAPI.send('tip:hide'); }, hideDelay); } const RETRYABLE_ERRORS = new Set([ 'STT_PROCESS_CRASHED', 'STT_TIMEOUT', 'LLM_CONNECTION_FAILED', 'LLM_TIMEOUT', 'INSERT_FAILED', ]); ``` ### 5.3 메인 앱 에러 표시 (React) ```typescript // Snackbar 기반 토스트 알림 interface ErrorToastProps { error: { code: string; message: string } | null; onClose: () => void; } // 자동 숨김 타이밍 const AUTO_HIDE_DURATION: Record = { default: 3000, // 일반 에러: 3초 retryable: 10000, // 재시도 가능: 10초 critical: null as never, // 치명적: 수동 닫기만 }; ``` ### 5.4 에러 흐름 요약 ``` 에러 발생 (main process) ├── RecordingTip 활성 중? │ ├── YES → tip:prepare({ state: 'error', params: { code, message } }) │ │ → 3초/10초 후 자동 숨김 │ └── NO → webContents.send('app:error', { code, message }) │ → React Snackbar 표시 │ ├── 설정 유도 필요? │ └── YES → 에러 메시지에 "설정 열기" 액션 버튼 포함 │ → 클릭 시 SettingsModal 해당 탭으로 이동 │ └── 로그 기록 (항상) → logger.error({ code, message, stack }) ``` --- ## 부록: Speakly 원본 스키마 참조 아래는 Speakly `genspark-flow.db`에서 추출한 원본 스키마이다. D3RO-VOICE 스키마 설계 시 참조 자료로 사용했다. ```sql -- Speakly 원본 (참조용, D3RO-VOICE에서 직접 사용하지 않음) CREATE TABLE history ( id TEXT PRIMARY KEY, original_text TEXT NOT NULL, polished_text TEXT, focused_app TEXT, focused_app_name TEXT, focused_app_bundle_id TEXT, focused_app_window_title TEXT, window_web_title TEXT, window_web_domain TEXT, window_web_url TEXT, mode TEXT NOT NULL DEFAULT 'dictation', status TEXT NOT NULL DEFAULT 'completed', audio_local_path TEXT, audio_metadata TEXT, duration REAL NOT NULL, detected_language TEXT, mic_device TEXT, mic_device_info TEXT, word_count INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, user_id TEXT, app_version TEXT NOT NULL DEFAULT '1.0.0', selected_text TEXT ); CREATE TABLE dictionary ( id TEXT PRIMARY KEY, word TEXT NOT NULL, pronunciation TEXT, dict_type TEXT NOT NULL DEFAULT 'user', user_id TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, usage_count INTEGER NOT NULL DEFAULT 0, last_used_at INTEGER ); CREATE TABLE stats ( id INTEGER PRIMARY KEY, total_duration REAL NOT NULL DEFAULT 0, total_words INTEGER NOT NULL DEFAULT 0, session_count INTEGER NOT NULL DEFAULT 0, last_updated INTEGER NOT NULL ); CREATE TABLE dict_sync_meta ( user_id TEXT NOT NULL, dict_type TEXT NOT NULL, cloud_mtime TEXT, PRIMARY KEY (user_id, dict_type) ); ```