d3ro-voice/docs/design/03-db-and-ui.md
Yun Chan e24bb8378c 초기 프로젝트 설정: 하네스 시스템 + 설계서 + RE 노하우
- CLAUDE.md: 프로젝트 규칙, 기술 스택, 코딩 규칙, 페이즈 로드맵
- .claude/settings.json: 권한, 강제 훅 (매 프롬프트 설계서 규칙 주입)
- .claude/skills/: implement-phase, review-phase, scaffold, test-commit, debug
- .claude/agents/: electron-architect, voice-pipeline-expert, ui-specialist
- docs/design/00-09: 마스터 아키텍처, 서비스 명세(16개), IPC(113채널),
  DB스키마, UI컴포넌트, 검증리포트, 외부엔진연동, 갭분석,
  VoiceMode패턴, 디자인시스템, 히스토리팝업
- docs/phases/1-7+3.5: 전체 구현 페이즈 문서
- docs/re-findings/: Speakly RE 노하우 5개 문서
2026-04-05 01:03:03 +09:00

1131 lines
35 KiB
Markdown

# 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<typeof drizzle>;
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<string, unknown>;
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
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div id="root">
<div id="container" class="recording-tip">
<!-- recording 상태 -->
<div id="recording-view" class="view">
<div id="wave-bars" class="wave-bars">
<!-- 9개 바: JS로 동적 생성 -->
</div>
<span id="duration-text" class="duration">0:00</span>
</div>
<!-- thinking 상태 -->
<div id="thinking-view" class="view hidden">
<div class="progress-container">
<div id="progress-bar" class="progress-bar"></div>
</div>
<span id="thinking-text" class="thinking-label">처리 중...</span>
</div>
<!-- error 상태 -->
<div id="error-view" class="view hidden">
<span id="error-icon" class="error-icon">!</span>
<span id="error-text" class="error-label"></span>
</div>
</div>
</div>
<script src="./script.js"></script>
</body>
</html>
```
#### 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
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div id="root">
<div id="container" class="result-popup">
<div id="result-text" class="result-text"></div>
<div id="actions" class="actions">
<button id="copy-btn" class="action-btn" title="복사">
<svg id="copy-icon"><!-- clipboard icon --></svg>
<svg id="check-icon" class="hidden"><!-- check icon --></svg>
</button>
<button id="retry-btn" class="action-btn" title="재시도">
<svg><!-- refresh icon --></svg>
</button>
</div>
</div>
</div>
<script src="./script.js"></script>
</body>
</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<string, number> = {
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)
);
```