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

35 KiB

03. DB 스키마 & UI 컴포넌트 명세서

1. SQLite 데이터베이스 스키마

D3RO-VOICE는 better-sqlite3 + drizzle-orm을 사용한다. Speakly의 genspark-flow.db 스키마를 참조하되, 클라우드 관련 컬럼은 제거하고 로컬 전용 필드를 추가한다.

1.1 history 테이블

세션(녹음 → 전사 → 다듬기) 단위로 한 행씩 저장한다.

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 정확도 향상 및 자동 교정에 사용한다.

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_typecategory로 통합 (로컬 전용이므로)
  • 제거: dict_sync_meta 테이블 전체 (클라우드 동기화 불필요)
  • 추가: idx_dictionary_usage_count 인덱스 (자주 쓰는 단어 우선)

1.3 stats 테이블

전역 통계. 싱글턴 row (id=1)로 운영한다.

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 스키마

// 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 초기화 코드

// 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 (루트)

// 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

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

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

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

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

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

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 구조

<!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초 후 자동 숨김

웨이브 바 애니메이션 상세

// 파라미터
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 프로그레스 바

// 시간 기반 점근 수렴: 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 애니메이션 스펙

.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 구조

<!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 리사이즈 + 높이 측정

// 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 클래스

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 제어

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 애니메이션

.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() 코드.

// 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 에러 표시

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)

// 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 스키마 설계 시 참조 자료로 사용했다.

-- 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)
);