feat(desktop): honest, unified dictation popups with Esc to cancel

Wave 3 of the 2026-09-28 design overhaul.

- Esc (no modifiers) cancels an in-progress dictation, recording or
  processing: VoiceModeService.requestEscape() queues the dead 'escape'
  action. Clicking the capsule cancels only while recording, so a stray
  click during processing no longer throws the text away.
- Errors show in the user's language: bootstrap now passes error.code to
  the tip (the translated branch never ran), and one core classifier
  (voice-error-message) feeds both the tip and the main-window alert.
- Capsule: bars stay flat without input (no fake "alive" wobble before the
  mic opens), an indeterminate bar replaces the made-up percentage, the
  partial transcript stays visible while processing, an Esc hint, and a
  brief "Inserted" state so success is visible.
- All six popups share popups/_shared (Pretendard, one panel radius,
  selection, kbd hints, reduced motion). History/command popups get
  titles, listbox roles and window sizes from popup-list-geometry (empty
  states were clipped); the command popup's "0" key is registered;
  result popup explains why it appeared and can be closed; captions keep
  a theme-independent dark backing on purpose.
- Popup themes follow the OS when set to system and the document lang
  follows the app language.
- Site hero demo mirrors the new capsule (done state, indeterminate bar,
  Esc hint; 10 locales).
This commit is contained in:
Yun Chan 2026-09-28 20:46:24 +09:00
parent f064c93197
commit 7b1210b5d1
41 changed files with 1565 additions and 1143 deletions

View file

@ -1,6 +1,6 @@
// src/main/bootstrap.ts — 초기화 시퀀스
import { app, dialog, globalShortcut, ipcMain as ipcMainRef } from 'electron'
import { app, dialog, globalShortcut, ipcMain as ipcMainRef, nativeTheme } from 'electron'
import { uIOhook, type UiohookKeyboardEvent } from 'uiohook-napi'
import { VK } from '@d3ro/core/keybinding'
import { initLoggerService, getLogger } from './services/LoggerService'
@ -21,7 +21,7 @@ import { getTextInsertService } from './services/TextInsertService'
import { getCustomInstructionService } from './services/CustomInstructionService'
import { getVoiceCommandService } from './services/VoiceCommandService'
import { getInputTelemetryService } from './services/InputTelemetryService'
import { getSuggestionService } from './services/SuggestionService'
import { getSuggestionService, hasInputContextConsent } from './services/SuggestionService'
import { getSoundEffectService } from './services/SoundEffectService'
import { getAutoLaunchService } from './services/AutoLaunchService'
import { getAudioCaptureService } from './services/AudioCaptureService'
@ -161,6 +161,8 @@ async function initConfigEffects(): Promise<void> {
effects.register('autoLaunch', (value) => getAutoLaunchService().setEnabled(value === true))
// 미리 로드된 팝업(녹음 팁·기록·명령·제안·자막)의 테마 CSS
effects.register('theme', () => reapplyThemeToAllPopups())
// 팝업 문서 lang 도 같은 재주입에서 맞춘다
effects.register('language', () => reapplyThemeToAllPopups())
effects.start()
getShutdownRegistry().register({ name: 'config-effects', dispose: () => effects.dispose() })
}
@ -260,11 +262,12 @@ async function initInputIntelligence(): Promise<void> {
// 제안이 켜져 있으면 켜진 시점부터 모델을 미리 올려 둔다 — 토글 시에만 워밍업하면
// 앱을 새로 켤 때마다 첫 제안이 콜드 로딩으로 타임아웃된다.
if (configGet('suggestionEnabled')) void suggestion.warmUp()
// 입력 수집 동의가 없으면 입력 맥락이 오지 않아 제안이 뜰 수 없다 — 모델을 데우지 않는다.
if (configGet('suggestionEnabled') && hasInputContextConsent()) void suggestion.warmUp()
// Ollama 서버가 나중에 뜨는 경우(느린 부팅 등) 가용해지는 시점에도 워밍업한다.
getLocalLLMService().on('availability-changed', ({ available }) => {
if (available && configGet('suggestionEnabled')) void suggestion.warmUp()
if (available && configGet('suggestionEnabled') && hasInputContextConsent()) void suggestion.warmUp()
})
telemetry.start()
@ -311,6 +314,19 @@ async function initAutoUpdate(): Promise<void> {
async function initPopupWindows(): Promise<void> {
preloadPopupWindows()
// 시스템 테마를 따르는 설정이면 OS 다크/라이트 전환 때 팝업도 다시 칠한다(언어가 바뀌면 lang 도 함께)
const onNativeThemeUpdated = (): void => {
const theme = configGet('theme')
if (theme === 'auto') reapplyThemeToAllPopups()
}
nativeTheme.on('updated', onNativeThemeUpdated)
getShutdownRegistry().register({
name: 'popup-native-theme',
dispose: () => {
nativeTheme.removeListener('updated', onNativeThemeUpdated)
},
})
// 오디오 디바이스 미리 캐싱 (Settings 열 때 즉시 반환)
getAudioCaptureService().getDevices().catch(() => { /* 실패해도 무시 */ })
@ -395,22 +411,35 @@ async function initVoiceMode(): Promise<void> {
voiceMode.on('partial-transcript', ({ text }) => tip.onPartialTranscript(text))
voiceMode.on('insert-failed', ({ text }) => tip.onInsertFailed(text))
// 수정자 없는 Esc 로 진행 중인 받아쓰기(녹음·처리)를 취소한다. 녹음 팁은 포커스를 받지 않는 창이라
// 전역 keydown 을 직접 듣는다(제안 오버레이의 Esc 와 같은 방식). 세션이 없으면 아무 일도 하지 않는다.
// uiohook 은 수동 감청이라 Esc 는 앞에 있는 앱에도 그대로 전달된다.
const releaseEscapeHook = acquireGlobalInputHook()
getShutdownRegistry().register({ name: 'voice-escape-hook', dispose: () => releaseEscapeHook() })
uIOhook.on('keydown', (event: UiohookKeyboardEvent) => {
if (uiohookCodeToVk(event.keycode) !== VK.Escape) return
if (event.ctrlKey || event.altKey || event.shiftKey || event.metaKey) return
voiceMode.requestEscape()
})
// 효과음 연동
voiceMode.on('session-started', () => {
tip.onSessionStarted()
soundEffect.play('recording-start')
})
voiceMode.on('session-completed', ({ session, finalText }) => {
tip.onSessionEnded()
voiceMode.on('session-completed', ({ session, finalText, audioPath }) => {
tip.onSessionCompleted()
soundEffect.play('recording-stop')
// 녹음은 완료 이벤트 전에 저장됐다 — 실제 저장 경로(없으면 null)만 히스토리에 남긴다.
persistCompletedVoiceSessionSafe(
(error) => {
voiceMode.emit('error', { error, session })
},
session,
finalText,
audioPath,
)
notifyRenderer(IPC_CHANNELS.APP.DATA_CHANGED, { type: 'session-completed' })
@ -426,7 +455,8 @@ async function initVoiceMode(): Promise<void> {
voiceMode.on('error', ({ error, severity }) => {
// 'warning'(예: Ollama 미가용 → 원문 삽입)은 세션을 막지 않는 알림이다 — 에러음·에러 팁 없음
if (severity !== 'warning') soundEffect.play('error')
tip.onError(error.message, severity)
// 코드를 넘겨야 팁이 사용자 언어 문구를 고른다 — 빠져 있어 영문 로그 문구가 그대로 보였다(2026-09-28)
tip.onError(error.message, severity, error.code)
})
}

View file

@ -16,10 +16,8 @@
import { EventEmitter } from 'events'
import { randomUUID } from 'crypto'
import { writeFile, mkdir } from 'fs/promises'
import { join } from 'path'
import { app } from 'electron'
import { getLogger } from './LoggerService'
import { createFsRecordingStore, type RecordingStore } from './recording/recording-store'
import { getAudioCaptureService } from './AudioCaptureService'
import { getSTTManager } from './stt/STTManager'
import type { TranscriptionResult } from './stt/types'
@ -85,7 +83,8 @@ export interface VoiceModeEvents {
current: AudioState
}) => void
'transcription-update': (payload: { text: string; isFinal: boolean }) => void
'session-completed': (payload: { session: VoiceSession; finalText: string }) => void
/** audioPath: 저장된 녹음 WAV 경로(완료 이벤트 전에 저장됨). 오디오가 없거나 저장 실패면 null */
'session-completed': (payload: { session: VoiceSession; finalText: string; audioPath: string | null }) => void
'session-cancelled': (payload: {
session: VoiceSession
reason: 'user' | 'timeout' | 'too-short'
@ -196,6 +195,11 @@ const NOTHING_PENDING: StopOutcome = { finished: Promise.resolve() }
// ============================================================
class VoiceModeService extends EventEmitter {
/** 녹음 저장 포트 — 경로·fs·WAV 인코딩은 어댑터가 맡는다 (DIP) */
constructor(private readonly _recordings: RecordingStore = createFsRecordingStore()) {
super()
}
private _session: VoiceSession | null = null
private _run: SessionRun | null = null
private _recognitionState = RecognitionState.IDLE
@ -472,6 +476,22 @@ class VoiceModeService extends EventEmitter {
this._cancelSession('user')
}
/**
* 수정자 없는 Esc — 녹음·처리 중인 세션을 취소한다(2026-09-28: 녹음 팁의 "Esc 취소").
* 액션 큐로 넣어 누름/뗌과 순서를 지킨다. 진행 중인 세션이 없으면 아무것도 하지 않고 false.
*/
requestEscape(): boolean {
if (!this.isActive) return false
this._enqueueAction({
type: 'escape',
timestamp: Date.now(),
mode: 'dictation',
actionId: 'dictation',
holdMode: false,
})
return true
}
// ── 상태 머신 ──────────────────────────────────────────
private _isInTerminalState(): boolean {
@ -916,7 +936,7 @@ class VoiceModeService extends EventEmitter {
severity: 'warning',
})
}
void this._completeSession(run, effectiveText)
await this._completeSession(run, effectiveText)
} else {
await this._processWithLLM(run, effectiveText, route)
}
@ -985,7 +1005,7 @@ class VoiceModeService extends EventEmitter {
const chainResult = await getChainService().execute(route.chainId, userText)
if (!this._isCurrent(run)) return
if (this._session) this._session.processedText = chainResult.finalText
void this._completeSession(run, chainResult.finalText)
await this._completeSession(run, chainResult.finalText)
} catch (error) {
if (!this._isCurrent(run)) return
logger.warn(`Chain execution failed: ${describe(error)}`)
@ -1010,7 +1030,7 @@ class VoiceModeService extends EventEmitter {
this._session.processedText = processedText
}
void this._completeSession(run, processedText)
await this._completeSession(run, processedText)
} catch (error) {
if (!this._isCurrent(run)) return
this._handleError(
@ -1066,71 +1086,48 @@ class VoiceModeService extends EventEmitter {
// ── 세션 완료/취소 ─────────────────────────────────────
/**
* 세션 완료 — 녹음을 먼저 저장하고(실제 경로를 이벤트에 싣는다) 완료를 알린 뒤, 삽입은 따로 돈다.
*
* 예전엔 완료 이벤트(히스토리 생성 + history_audio 동기화 예약)가 먼저 나가고 WAV 는 텍스트 삽입이
* 끝난 뒤에 써졌다 — 그사이 flush 가 돌면 "파일 없음"을 성공으로 처리해 녹음이 영영 올라가지 않았다.
*/
private async _completeSession(run: SessionRun, finalText: string): Promise<void> {
if (!this._isCurrent(run) || !this._session) return
const audioPath = await this._recordings.save(this._session.id, run.lastAudio)
// 저장하는 사이 취소·새 세션이 시작됐으면 완료를 알리지 않는다.
if (!this._isCurrent(run) || !this._session) return
const session = { ...this._session }
const lastAudio = run.lastAudio
const selectionCapture = run.selectionCapture
logger.info(`Session completed: "${finalText.substring(0, 50)}${finalText.length > 50 ? '...' : ''}"`)
// Speakly 패턴: 먼저 IDLE 복귀 (다음 핫키 즉시 사용 가능). 표시 계층이 완료 이벤트로 팁을 숨긴다.
this._setRecognitionState(RecognitionState.COMPLETED)
this._setAudioState(AudioState.STOPPED)
this.emit('session-completed', { session, finalText })
this.emit('session-completed', { session, finalText, audioPath })
this._endRun(run)
// 텍스트 삽입은 세션과 무관하게 비동기 실행 (세션 블로킹 방지)
if (configGet('autoInsert') && finalText.length > 0) {
try {
// 선택 텍스트 캡처가 클립보드를 쓰는 중이면 끝날 때까지 기다린다 (클립보드 경합 방지)
if (selectionCapture) await selectionCapture
const insertMethod = configGet('insertMethod')
await getTextInsertService().insertText(finalText, insertMethod)
} catch (error) {
logger.warn(`Text insert failed: ${describe(error)}`)
// 삽입 실패 시 표시 계층이 ResultPopup에 텍스트를 보여 준다
this.emit('insert-failed', { text: finalText, sessionId: session.id })
}
}
// 녹음 오디오 WAV 파일 저장 (비동기, 실패해도 무시)
void this._saveAudioFile(session.id, lastAudio)
void this._insertCompletedText(session.id, finalText, selectionCapture)
}
/** PCM 버퍼를 WAV 파일로 저장 */
private async _saveAudioFile(sessionId: string, pcmData: Buffer | null): Promise<void> {
if (!pcmData || pcmData.length === 0) return
private async _insertCompletedText(
sessionId: string,
finalText: string,
selectionCapture: Promise<unknown> | null,
): Promise<void> {
if (!configGet('autoInsert') || finalText.length === 0) return
try {
const recordingsDir = join(app.getPath('userData'), 'recordings')
await mkdir(recordingsDir, { recursive: true })
const wavPath = join(recordingsDir, `${sessionId}.wav`)
// WAV 헤더 생성 (16kHz, 16bit, mono)
const header = Buffer.alloc(44)
const dataSize = pcmData.length
const fileSize = dataSize + 36
header.write('RIFF', 0)
header.writeUInt32LE(fileSize, 4)
header.write('WAVE', 8)
header.write('fmt ', 12)
header.writeUInt32LE(16, 16) // fmt chunk size
header.writeUInt16LE(1, 20) // PCM format
header.writeUInt16LE(1, 22) // mono
header.writeUInt32LE(16000, 24) // sample rate
header.writeUInt32LE(32000, 28) // byte rate (16000 * 2)
header.writeUInt16LE(2, 32) // block align
header.writeUInt16LE(16, 34) // bits per sample
header.write('data', 36)
header.writeUInt32LE(dataSize, 40)
await writeFile(wavPath, Buffer.concat([header, pcmData]))
logger.info(`Audio saved: ${wavPath} (${Math.round(dataSize / 1024)}KB)`)
// 선택 텍스트 캡처가 클립보드를 쓰는 중이면 끝날 때까지 기다린다 (클립보드 경합 방지)
if (selectionCapture) await selectionCapture
const insertMethod = configGet('insertMethod')
await getTextInsertService().insertText(finalText, insertMethod)
} catch (error) {
logger.warn(`Audio save failed: ${describe(error)}`)
logger.warn(`Text insert failed: ${describe(error)}`)
// 삽입 실패 시 표시 계층이 ResultPopup에 텍스트를 보여 준다
this.emit('insert-failed', { text: finalText, sessionId })
}
}
@ -1353,6 +1350,13 @@ export function getVoiceModeService(): VoiceModeService {
return instance
}
/** 테스트 전용: 가짜 녹음 저장소를 주입한 싱글톤을 만든다. */
export function createVoiceModeServiceForTests(recordings: RecordingStore): VoiceModeService {
resetVoiceModeServiceForTests()
instance = new VoiceModeService(recordings)
return instance
}
export function resetVoiceModeServiceForTests(): void {
if (instance) {
instance.removeAllListeners()

View file

@ -27,6 +27,7 @@ import {
import { getLogger } from '../services/LoggerService'
import { getIsQuitting, setIsQuitting } from '../lifecycle'
import { hardenWebContents } from './web-contents-hardening'
import { commandPopupSize, historyPopupSize } from './popup-list-geometry'
import { configGet, configSet } from '../services/ConfigService'
import { getI18n } from '@d3ro/i18n'
import {
@ -36,6 +37,7 @@ import {
sendToPopupWindow
} from './popup-window-factory'
import { createElectronScreenGeometry } from './screen-geometry'
import { installApplicationMenu } from './app-menu'
import {
SUGGESTION_OVERLAY_MAX_HEIGHT,
SUGGESTION_OVERLAY_WIDTH,
@ -48,30 +50,42 @@ const logger = getLogger('WindowManager')
/**
* 팝업에 주입할 i18n 문자열 맵을 반환.
*/
function getPopupI18nStrings(): Record<string, string> {
/** 현재 설정 언어의 번역 함수 — 팝업(Vanilla JS)에 넘길 문구를 main 에서 만들 때 쓴다 */
export function getPopupTranslator(): ReturnType<typeof getI18n>['t'] {
const locale = (configGet('language') as string) || 'ko'
const { t } = getI18n(locale as 'ko' | 'en' | 'ja' | 'zh' | 'zh-TW' | 'es' | 'fr' | 'de' | 'pt' | 'ru' | 'vi' | 'th')
return getI18n(locale as 'ko' | 'en' | 'ja' | 'zh' | 'zh-TW' | 'es' | 'fr' | 'de' | 'pt' | 'ru' | 'vi' | 'th').t
}
function getPopupI18nStrings(): Record<string, string> {
const t = getPopupTranslator()
return {
// command-popup
commandSelect: t('popup.commandSelect'),
hintsSelect: t('popup.hints.select'),
hintsApply: t('popup.hints.apply'),
hintsClear: t('popup.hints.clear'),
hintsClose: t('popup.hints.close'),
noCommands: t('popup.noCommands'),
noCommand: t('popup.noCommand'),
// history-popup
hintsSelectEn: t('popup.hints.selectEn'),
hintsPaste: t('popup.hints.paste'),
noHistory: t('popup.noHistory'),
// history-popup 상대 시간
timeJustNow: t('popup.time.justNow'),
timeMinutesAgo: t('popup.time.minutesAgo'),
timeHoursAgo: t('popup.time.hoursAgo'),
timeDaysAgo: t('popup.time.daysAgo'),
// result-popup
copy: t('popup.copy'),
copied: t('popup.copied'),
resultTitle: t('popup.result.title'),
// 목록 팝업 공통 (v4: 키는 kbd 칩, 설명은 동사)
hintMove: t('popup.hints.move'),
hintPaste: t('popup.hints.paste2'),
hintApply: t('popup.hints.apply2'),
hintClose: t('popup.hints.close2'),
hintPick: t('popup.hints.pick'),
historyTitle: t('popup.history.title'),
historyEmpty: t('popup.history.empty'),
commandTitle: t('popup.command.title'),
commandEmpty: t('popup.command.empty'),
commandNone: t('popup.command.none'),
// recording-tip
errorDefault: t('popup.error.default'),
tipProcessing: t('popup.tip.processing'),
tipInserted: t('popup.tip.inserted'),
tipCancelHint: t('popup.tip.cancelHint'),
tipRecording: t('popup.tip.recordingLabel'),
close: t('popup.close'),
// caption-overlay
captionLoading: t('popup.caption.loading'),
captionWaiting: t('popup.caption.waiting'),
@ -83,6 +97,8 @@ function getPopupI18nStrings(): Record<string, string> {
suggestionWarming: t('popup.suggestion.warming'),
suggestionHintGenerating: t('popup.suggestion.hintGenerating'),
suggestionLoading: t('popup.suggestion.loading'),
// 경과 초가 붙는 "생성 중 {{s}}초" — 빠져 있어 script.js 가 loading 으로 떨어졌다(2026-09-28)
suggestionGenerating: t('popup.suggestion.generating'),
suggestionSourceModel: t('popup.suggestion.sourceModel'),
suggestionSourceMemory: t('popup.suggestion.sourceMemory'),
suggestionContinuations: t('popup.suggestion.continuations'),
@ -259,9 +275,10 @@ export function createMainWindow(): BrowserWindow {
mainWindowCreated = true
mainWindow.setSkipTaskbar(false)
// Alt 키로 메뉴 활성화 방지: 메뉴 완전 제거
// Win/Linux: Alt 키로 메뉴 활성화 방지(메뉴 완전 제거). macOS: 편집 단축키(Cmd+C/V/X/A/Z)와
// Cmd+Q 는 애플리케이션 메뉴의 role 항목으로만 동작하므로 최소 메뉴를 유지한다.
mainWindow.setMenu(null)
Menu.setApplicationMenu(null)
installApplicationMenu(Menu, process.platform)
mainWindow.on('ready-to-show', () => {
mainWindow?.show()
@ -368,6 +385,20 @@ export function showRecordingTip(
logger.debug(`RecordingTip presented: state=${state} visible=${win.isVisible()} loading=${win.webContents.isLoading()}`)
}
/**
* 오류를 RecordingTip 에 그리고 팁을 띄운다.
* 쿼터 초과·자막 사용 중·STT 모델 미설치 오류는 'recording' phase 전에 세션을 끝내므로 팁이 숨겨진
* 상태다 — 상태 IPC 만 보내면 보이지 않는 창에 그려지고 오류음만 남는다. 이미 떠 있는 팁(녹음 도중
* 난 오류)은 자리를 옮기지 않고 상태만 바꾼다.
*/
export function showRecordingTipError(errorMessage: string): void {
if (recordingTipWindow && !recordingTipWindow.isDestroyed() && recordingTipWindow.isVisible()) {
updateRecordingTipState('error', { errorMessage })
return
}
showRecordingTip('error', { errorMessage })
}
export function hideRecordingTip(): void {
if (recordingTipWindow && !recordingTipWindow.isDestroyed()) {
recordingTipWindow.hide()
@ -537,7 +568,7 @@ export function showHistoryPopup(entries: Array<Record<string, unknown>>): void
const win = getHistoryPopupWindow()
// 커서 위치에 즉시 배치+표시 (2-phase 제거 — 숨겨진 윈도우 렌더러 비활성 문제 방지)
placePopupNearCursor(win, { width: 380, height: Math.min(60 + entries.length * 44, 500) })
placePopupNearCursor(win, historyPopupSize(entries.length))
sendToPopupWindow(win, IPC_CHANNELS.POPUP_HISTORY.SHOW_ITEMS, { entries, _i18n: getPopupI18nStrings() })
@ -572,7 +603,7 @@ export function isHistoryPopupVisible(): boolean {
function createCommandPopupWindow(): BrowserWindow {
return createPopupWindow({
name: 'command-popup',
bounds: { width: 340, height: 300 },
bounds: commandPopupSize(0),
onClosed: () => {
commandPopupWindow = null
}
@ -588,7 +619,7 @@ export function getCommandPopupWindow(): BrowserWindow {
export function showCommandPopup(commands: Array<Record<string, unknown>>, activeId: string | null): void {
const win = getCommandPopupWindow()
placePopupNearCursor(win, { width: 340, height: Math.min(80 + commands.length * 48, 400) })
placePopupNearCursor(win, commandPopupSize(commands.length))
sendToPopupWindow(win, IPC_CHANNELS.POPUP_COMMAND.SHOW_ITEMS, { commands, activeId, _i18n: getPopupI18nStrings() })
presentPopup(win)

View file

@ -0,0 +1,34 @@
// src/main/windows/popup-list-geometry.ts
// 기록·명령 팝업(목록형) 창 크기 — CSS 치수와 같은 상수로 계산한다(순수 함수).
//
// 이전 공식(기록 60+44n, 명령 80+48n)은 CSS 와 따로 놀아 빈 상태에서 창이 내용보다 작아 잘렸고
// (기록 60px ← 필요 약 116px), 항목이 많으면 남는 투명 영역이 클릭을 삼켰다(2026-09-28 감사).
// 아래 값은 popups/_shared/popup-base.css · history-popup/style.css · command-popup/style.css 와 짝이다 —
// 한쪽을 바꾸면 다른 쪽도 바꾼다.
/** 목록 한 줄 높이(px) — .d3-list-item height */
export const POPUP_LIST_ROW = 40
/** 제목 줄(34) + 힌트 줄(30) + 판 위아래 패딩(6+6) + 테두리(1+1) */
export const POPUP_LIST_CHROME = 34 + 30 + 12 + 2
/** 빈 상태 한 줄 높이 */
export const POPUP_LIST_EMPTY = 56
export const HISTORY_POPUP_WIDTH = 380
export const HISTORY_POPUP_MAX_HEIGHT = 500
export const COMMAND_POPUP_WIDTH = 340
export const COMMAND_POPUP_MAX_HEIGHT = 420
export interface PopupSize {
width: number
height: number
}
export function historyPopupSize(entryCount: number): PopupSize {
const body = entryCount === 0 ? POPUP_LIST_EMPTY : entryCount * POPUP_LIST_ROW
return { width: HISTORY_POPUP_WIDTH, height: Math.min(POPUP_LIST_CHROME + body, HISTORY_POPUP_MAX_HEIGHT) }
}
/** 명령 팝업은 맨 위에 "명령 없이 원문 넣기" 줄이 항상 있다 */
export function commandPopupSize(commandCount: number): PopupSize {
const body = POPUP_LIST_ROW + (commandCount === 0 ? POPUP_LIST_EMPTY : commandCount * POPUP_LIST_ROW)
return { width: COMMAND_POPUP_WIDTH, height: Math.min(POPUP_LIST_CHROME + body, COMMAND_POPUP_MAX_HEIGHT) }
}

View file

@ -45,6 +45,8 @@ export const POPUP_NAV_KEYS: ReadonlyArray<{ accel: string; key: string }> = [
{ accel: '1', key: '1' }, { accel: '2', key: '2' }, { accel: '3', key: '3' },
{ accel: '4', key: '4' }, { accel: '5', key: '5' }, { accel: '6', key: '6' },
{ accel: '7', key: '7' }, { accel: '8', key: '8' }, { accel: '9', key: '9' },
// 0 = 명령 팝업의 "명령 없이 원문 넣기"(힌트만 있고 등록이 빠져 있었다 — 2026-09-28). 기록 팝업은 무시한다.
{ accel: '0', key: '0' },
]
/** 히스토리 팝업이 사라진 뒤 삽입까지 기다리는 시간(포커스 복귀 대기) */

View file

@ -7,7 +7,7 @@
// 처리를 각자 복붙하던 시절 옵션이 조용히 갈라졌다 — ResultPopup 에만 backgroundThrottling
// 이 빠져 숨김 상태에서 2-phase 측정이 멈췄다. 창 종류는 이제 PopupWindowSpec 선언만 다르다.
import { BrowserWindow } from 'electron'
import { BrowserWindow, nativeTheme } from 'electron'
import { join } from 'path'
import { is } from '@electron-toolkit/utils'
import { buildPopupThemeCss } from '@d3ro/ui/theme-vars'
@ -87,12 +87,16 @@ export function createPopupWindow(spec: PopupWindowSpec): BrowserWindow {
*/
export function injectPopupTheme(win: BrowserWindow): void {
const theme = configGet('theme') as string
// 'system'은 'dark'로 폴백 (팝업은 시스템 다크모드 감지 불가)
const resolvedTheme = theme === 'system' ? 'dark' : theme
const css = buildPopupThemeCss(resolvedTheme)
// 시스템/자동 테마는 main 이 아는 OS 다크 모드로 푼다(이전: 늘 dark — 라이트 OS 에서 팝업만 어두웠다)
const css = buildPopupThemeCss(theme, nativeTheme.shouldUseDarkColors)
win.webContents.insertCSS(css).catch((err: unknown) => {
logger.warn('팝업 테마 CSS 주입 실패', err)
})
// 팝업 HTML 은 lang="ko" 로 고정돼 있다 — 앱 언어로 맞춰야 중·일·태국어 글리프 선택과 줄바꿈이 맞다
const locale = (configGet('language') as string) || 'ko'
win.webContents
.executeJavaScript(`document.documentElement.lang = ${JSON.stringify(locale)}`)
.catch((err: unknown) => logger.warn('팝업 lang 설정 실패', err))
}
// ── 수명주기 · 로드 전 IPC 보류 ─────────────────────────────

View file

@ -0,0 +1,35 @@
// src/main/windows/recording-tip-error-message.ts
// RecordingTip 에 그릴 오류 문구 정책 — 코드별 i18n 문자열로 매핑한다(순수 함수).
//
// VoiceModeService 의 D3ROError.message 는 로그용 영문 문구("STT model not installed: ...")라
// 그대로 팁에 그리면 사용자 언어와 무관한 개발자 문구가 보인다. 코드 분류는 @d3ro/core 의
// classifyVoiceError 한 곳이 정하고(렌더러 알림도 같은 분류를 쓴다), 여기서는 번역 키만 고른다.
// 분류되지 않는 코드는 기존 동작대로 원문을 그린다.
import { classifyVoiceError, type VoiceErrorMessageId } from '@d3ro/core/voice-error-message'
import type { TranslationKey } from '@d3ro/i18n'
export type RecordingTipTranslate = (key: TranslationKey, params?: Record<string, string | number>) => string
const MESSAGE_KEY: Record<Exclude<VoiceErrorMessageId, 'quota'>, TranslationKey> = {
modelMissing: 'voice.error.modelMissing',
engine: 'voice.error.engine',
micOpen: 'voice.error.micOpen',
micLost: 'voice.error.micLost',
noSpeech: 'voice.error.noSpeech',
tooShort: 'voice.error.tooShort',
transcription: 'voice.error.transcription',
captionActive: 'voice.error.captionActive',
llmFailed: 'voice.error.llmFailed',
}
export function resolveRecordingTipErrorMessage(
code: number | undefined,
fallbackMessage: string,
t: RecordingTipTranslate,
): string {
const id = classifyVoiceError(code)
if (id === null) return fallbackMessage
if (id === 'quota') return t('license.quotaExceeded.title', { feature: t('license.feature.dictation') })
return t(MESSAGE_KEY[id])
}

View file

@ -9,19 +9,29 @@
import {
showRecordingTip,
showRecordingTipError,
hideRecordingTip,
updateRecordingTipState,
sendAudioLevelToTip,
sendPartialTranscriptToTip,
showResultPopup,
getPopupTranslator,
} from './WindowManager'
import { resolveRecordingTipErrorMessage } from './recording-tip-error-message'
/** 프레젠터가 그리는 대상(창) 포트 — 테스트에서는 가짜로 바꾼다 */
export interface RecordingTipView {
showRecording(): void
hide(): void
showThinking(): void
showError(message: string): void
/** 받아쓴 글을 넣었다 — 짧게 확인 표시를 보인다 */
showDone(): void
/**
* 오류를 그리고 팁을 띄운다. 쿼터·자막 사용 중·모델 미설치처럼 'recording' phase 전에 끝난
* 세션의 오류도 보여야 하므로, 숨겨진 팁이면 창을 띄워야 한다(상태 IPC 만으로는 부족).
* code 가 있으면 사용자 언어 문구로 바꿔 그린다.
*/
showError(message: string, code?: number): void
sendAudioLevel(level: number): void
sendPartialTranscript(text: string): void
showResult(text: string, durationMs: number): void
@ -37,6 +47,8 @@ export interface RecordingTipPresenterOptions {
errorHideMs?: number
/** 자동 삽입 실패 시 결과 팝업 표시 시간(ms). 기본 10000 */
resultPopupMs?: number
/** 완료 확인 표시 후 팁을 숨기기까지(ms). 기본 700 */
doneHideMs?: number
timers?: RecordingTipTimers
}
@ -47,6 +59,7 @@ export class RecordingTipPresenter {
private _hideTimer: ReturnType<typeof setTimeout> | null = null
private readonly _errorHideMs: number
private readonly _resultPopupMs: number
private readonly _doneHideMs: number
private readonly _timers: RecordingTipTimers
constructor(
@ -55,6 +68,7 @@ export class RecordingTipPresenter {
) {
this._errorHideMs = options.errorHideMs ?? 3000
this._resultPopupMs = options.resultPopupMs ?? 10000
this._doneHideMs = options.doneHideMs ?? 700
this._timers = options.timers ?? {
setTimeout: (fn, ms) => setTimeout(fn, ms),
clearTimeout: (handle) => clearTimeout(handle),
@ -86,10 +100,11 @@ export class RecordingTipPresenter {
/**
* 에러 표시 후 한 번만 숨김을 예약한다(이전 예약은 교체).
* 'warning' 은 세션을 막지 않는 알림이므로 팁을 건드리지 않는다.
* phase 와 무관하게 표시된다 — 녹음 팁이 뜨기 전에 난 오류도 보여야 한다.
*/
onError(message: string, severity: RecordingTipSeverity = 'error'): void {
onError(message: string, severity: RecordingTipSeverity = 'error', code?: number): void {
if (severity === 'warning') return
this._view.showError(message)
this._view.showError(message, code)
this._cancelHide()
this._hideTimer = this._timers.setTimeout(() => {
this._hideTimer = null
@ -97,12 +112,25 @@ export class RecordingTipPresenter {
}, this._errorHideMs)
}
/** 세션 완료/취소 — 팁을 즉시 숨긴다 */
/** 세션 취소 — 팁을 즉시 숨긴다 */
onSessionEnded(): void {
this._cancelHide()
this._view.hide()
}
/**
* 세션 완료 — "입력함"을 잠깐 보이고 숨긴다. 이전에는 즉시 사라져 성공했는지 알 수 없었다(2026-09-28).
* 숨김 예약은 오류와 같은 핸들 하나 — 곧바로 새 세션이 시작되면 onSessionStarted 가 취소한다.
*/
onSessionCompleted(): void {
this._cancelHide()
this._view.showDone()
this._hideTimer = this._timers.setTimeout(() => {
this._hideTimer = null
this._view.hide()
}, this._doneHideMs)
}
onInsertFailed(text: string): void {
this._view.showResult(text, this._resultPopupMs)
}
@ -125,7 +153,9 @@ export function createWindowRecordingTipView(): RecordingTipView {
showRecording: () => showRecordingTip('recording'),
hide: () => hideRecordingTip(),
showThinking: () => updateRecordingTipState('thinking'),
showError: (message) => updateRecordingTipState('error', { errorMessage: message }),
showDone: () => updateRecordingTipState('done'),
showError: (message, code) =>
showRecordingTipError(resolveRecordingTipErrorMessage(code, message, getPopupTranslator())),
sendAudioLevel: (level) => sendAudioLevelToTip(level),
sendPartialTranscript: (text) => sendPartialTranscriptToTip(text),
showResult: (text, durationMs) => showResultPopup(text, durationMs),

View file

@ -0,0 +1,101 @@
/* popups/_shared/list-popup.css — 기록·명령 팝업(목록형) 공통 모양 (v4, 2026-09-28)
* 치수는 main/windows/popup-list-geometry.ts 와 짝이다:
* 제목 줄 34px · 목록 한 줄 40px · 빈 상태 56px · 힌트 줄 30px · 판 패딩 6px · 테두리 1px.
* 한쪽을 바꾸면 다른 쪽도 바꾼다. */
@import './popup-base.css';
#root {
width: 100%;
height: 100%;
}
.list-popup {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
padding: 6px;
opacity: 0;
transform: translateY(4px);
transition: opacity 120ms var(--popup-ease), transform 120ms var(--popup-ease);
}
.list-popup.visible {
opacity: 1;
transform: translateY(0);
}
.list-popup.hiding {
opacity: 0;
}
.list-title {
height: 34px;
flex-shrink: 0;
display: flex;
align-items: center;
padding: 0 10px;
font-size: 12px;
font-weight: 500;
color: var(--d3-text-secondary);
}
.list-items {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.list-items::-webkit-scrollbar {
width: 0;
}
.d3-list-item {
height: 40px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 10px;
cursor: pointer;
}
.item-number {
flex-shrink: 0;
}
.item-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: var(--d3-text-primary);
}
.item-meta {
flex-shrink: 0;
font-size: 11px;
color: var(--d3-text-tertiary);
}
.list-empty {
height: 56px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--d3-text-tertiary);
}
.d3-hints {
height: 30px;
flex-shrink: 0;
align-items: center;
overflow: hidden;
}
.d3-hints b {
font-weight: 400;
}

View file

@ -0,0 +1,146 @@
/* popups/_shared/popup-base.css
* 팝업 6종의 공통 바탕 — v4 "조용한 계기" (2026-09-28).
* 이전에는 팝업마다 radius(999/16/10)·폰트 스택·선택 표시·키 힌트·모션이 제각각이었고,
* Pretendard 를 싣지 않아 시스템 폰트로 그려졌다. 이제 모든 팝업이 이 파일을 @import 한다.
* 색은 main 이 insertCSS 로 주입하는 CSS 변수(@d3ro/ui theme-vars — 렌더러와 같은 원시값)만 쓴다.
* 아래 :root 값은 주입 전 첫 프레임용 폴백(dark 기본값과 같다). */
@font-face {
font-family: 'Pretendard Variable';
font-weight: 45 920;
font-style: normal;
font-display: swap;
src: url('../../assets/fonts/PretendardVariable.woff2') format('woff2-variations');
}
:root {
--d3-bg-card: #111a30;
--d3-bg-elevated: #1a2540;
--d3-bg-inset: #0a111f;
--d3-text-primary: #eef2fb;
--d3-text-secondary: #93a4c8;
--d3-text-tertiary: #7e8eb0;
--d3-border-subtle: rgba(148, 180, 255, 0.07);
--d3-border-default: rgba(148, 180, 255, 0.12);
--d3-border-strong: rgba(148, 180, 255, 0.20);
--d3-accent-main: #3b82f6;
--d3-accent-light: #60a5fa;
--d3-accent-dim: rgba(59, 130, 246, 0.14);
--d3-status-danger: #f87171;
--d3-status-success: #34d399;
--d3-overlay-soft: rgba(238, 242, 251, 0.06);
--d3-overlay-strong: rgba(238, 242, 251, 0.12);
--d3-shadow-overlay: 0 12px 32px rgba(3, 7, 18, 0.55);
/* 팝업 전용 치수 — 한 곳에서만 정한다 */
--popup-radius: 10px;
--popup-radius-item: 6px;
--popup-pad: 6px;
--popup-font: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo',
'Noto Sans KR', 'Segoe UI', 'Malgun Gothic', sans-serif;
--popup-font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, 'Pretendard Variable', 'Malgun Gothic', monospace;
--popup-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
background: transparent;
overflow: hidden;
}
body {
font-family: var(--popup-font);
font-size: 13px;
line-height: 1.5;
color: var(--d3-text-primary);
user-select: none;
-webkit-app-region: no-drag;
-webkit-font-smoothing: antialiased;
}
/* 떠 있는 판 하나 — 모든 팝업의 겉모양 */
.d3-panel {
background: var(--d3-bg-card);
border: 1px solid var(--d3-border-default);
border-radius: var(--popup-radius);
box-shadow: var(--d3-shadow-overlay);
}
/* 목록 항목 — 선택은 옅은 오버레이 + 왼쪽 2px 액센트 막대(팝업 공통) */
.d3-item {
position: relative;
border-radius: var(--popup-radius-item);
}
.d3-item:hover {
background: var(--d3-overlay-soft);
}
.d3-item.selected {
background: var(--d3-overlay-strong);
}
.d3-item.selected::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 2px;
border-radius: 1px;
background: var(--d3-accent-main);
}
/* 키 힌트 줄 — 키는 kbd 칩, 설명은 3차 텍스트 (최소 11px) */
.d3-hints {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
padding: 6px 10px 4px;
border-top: 1px solid var(--d3-border-subtle);
font-size: 11px;
color: var(--d3-text-tertiary);
}
.d3-hints span {
display: inline-flex;
align-items: center;
gap: 4px;
}
kbd,
.d3-kbd {
display: inline-flex;
align-items: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
justify-content: center;
border: 1px solid var(--d3-border-strong);
border-radius: 4px;
font-family: var(--popup-font-mono);
font-size: 11px;
line-height: 1;
color: var(--d3-text-secondary);
}
.d3-numeral {
font-variant-numeric: tabular-nums;
}
/* 움직임을 줄인 환경: 반복 애니메이션을 멈추고 전환을 즉시로 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

View file

@ -1,28 +1,18 @@
/* Caption Overlay — Phase 10.1 Live Caption
* Precision Broadcast Subtitle HUD
*/
/* Caption Overlay — Phase 10.1 Live Caption · v4 (2026-09-28)
*
* 의도된 예외: 자막 받침은 앱 테마를 따르지 않는다. 자막은 영상 통화·동영상 같은 임의의 화면 위에 뜨므로
* 방송 자막 관례대로 어두운 받침 + 밝은 글자가 어떤 배경에서도 읽힌다(라이트 테마에서 흰 받침이면 흰 화면 위에서 사라진다).
* 그 값은 아래 --caption-* 에서만 정한다. 강조색(최신 줄 테두리·커서)은 테마 액센트를 따른다.
* v4: 블러·알약(999px)·굵은 글자(600)를 걷어내고 다른 팝업과 같은 폰트·반경 문법으로. */
@import '../_shared/popup-base.css';
:root {
--d3-accent-main: #3b82f6;
--d3-accent-glow: rgba(59, 130, 246, 0.45);
--d3-accent-glow-dim: rgba(59, 130, 246, 0.2);
--d3-accent-light: #93c5fd;
--d3-accent-light-glow: rgba(147, 197, 253, 0.5);
--d3-accent-light-glow-dim: rgba(147, 197, 253, 0.25);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
background: transparent;
overflow: hidden;
user-select: none;
-webkit-app-region: no-drag;
font-family: 'Pretendard Variable', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--caption-bg: rgba(10, 12, 18, 0.9);
--caption-text: #f4f4f5;
--caption-text-strong: #ffffff;
--caption-border: rgba(255, 255, 255, 0.10);
--caption-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
}
#root {
@ -45,10 +35,10 @@ html, body {
gap: 8px;
height: 22px;
padding: 0 10px;
border-radius: 999px;
background: rgba(15, 17, 22, 0.88);
border: 1px solid var(--d3-accent-glow-dim);
color: rgba(244, 244, 245, 0.85);
border-radius: 6px;
background: var(--caption-bg);
border: 1px solid var(--caption-border);
color: var(--caption-text);
font-size: 11px;
white-space: nowrap;
cursor: grab;
@ -93,36 +83,34 @@ html, body {
}
.caption-line {
font-size: 16.5px;
font-weight: 600;
max-width: 90%;
padding: 6px 16px;
border: 1px solid var(--caption-border);
border-radius: 8px;
background: var(--caption-bg);
box-shadow: var(--caption-shadow);
color: var(--caption-text);
font-size: 17px;
font-weight: 500;
line-height: 1.5;
color: #f4f4f5;
background: rgba(15, 17, 22, 0.92);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.12);
border-radius: 999px;
padding: 6px 18px;
text-align: center;
word-break: keep-all;
overflow-wrap: anywhere;
opacity: 1;
transition: opacity 0.4s ease-out;
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 90%;
text-align: center;
}
.caption-line.fading {
opacity: 0;
}
/* Newest line (bottom) is brightest with accent tint */
/* 최신 줄(아래)이 가장 밝다 — 테마 액센트 테두리 */
.caption-line:last-child {
color: #ffffff;
border: 1px solid rgba(59, 130, 246, 0.35);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.2);
color: var(--caption-text-strong);
border-color: var(--d3-accent-main);
}
/* Oldest lines are dimmer */
/* 오래된 줄은 흐리게 */
.caption-line:first-child {
opacity: 0.65;
}
@ -131,7 +119,7 @@ html, body {
opacity: 0.82;
}
/* Delta (partial/unfinished) line has pulsing cursor */
/* 아직 끝나지 않은 줄 — 깜빡이는 커서 */
.caption-line.delta::after {
content: '\2588';
animation: blink 0.8s step-end infinite;
@ -149,17 +137,17 @@ html, body {
opacity: 0.6;
}
/* 문맥 다듬기로 고쳐진 줄 — 잠깐 강조 */
/* 문맥 다듬기로 고쳐진 줄 — 잠깐 테두리 강조 */
.caption-line.refined {
animation: refined-flash 0.9s ease-out;
}
@keyframes refined-flash {
0% { border-color: var(--d3-accent-main); box-shadow: 0 0 0 2px var(--d3-accent-glow-dim); }
100% { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); }
0% { border-color: var(--d3-accent-light); }
100% { border-color: var(--caption-border); }
}
/* 로딩 중 표시 — 점멸 애니메이션 */
/* 준비 중 표시 */
.caption-line.loading {
opacity: 0.6;
animation: pulse 1.5s ease-in-out infinite;

View file

@ -8,14 +8,14 @@
</head>
<body>
<div id="root">
<div id="container" class="command-popup">
<div id="title" class="title" data-i18n="commandSelect">COMMAND SELECT</div>
<div id="items" class="items"></div>
<div class="hints">
<span data-i18n="hintsSelect">↑↓ 선택</span>
<span data-i18n="hintsApply">⏎ 적용</span>
<span data-i18n="hintsClear">0 해제</span>
<span data-i18n="hintsClose">ESC ×</span>
<div id="container" class="list-popup d3-panel">
<div id="title" class="list-title" data-i18n="commandTitle"></div>
<div id="items" class="list-items" role="listbox" aria-labelledby="title"></div>
<div class="d3-hints" aria-hidden="true">
<span><kbd>↑</kbd><kbd>↓</kbd><b data-i18n="hintMove"></b></span>
<span><kbd>Enter</kbd><b data-i18n="hintApply"></b></span>
<span><kbd>0–9</kbd><b data-i18n="hintPick"></b></span>
<span><kbd>Esc</kbd><b data-i18n="hintClose"></b></span>
</div>
</div>
</div>

View file

@ -1,6 +1,7 @@
// CommandPopup 팝업 스크립트
// Ctrl+Shift+C로 커서 위치에 명령어 선택 팝업
// Arrow↑↓ 선택, Enter 적용, 1-5 직접 선택, ESC 닫기
// CommandPopup 팝업 스크립트 — v4 (2026-09-28)
// 활성 명령어 고르기. 맨 위 0번은 항상 "명령 없이 원문 넣기". ↑↓ 이동, Enter 적용, 0-9 바로 고르기, Esc 닫기.
// 문구는 전부 main 의 _i18n(영어 폴백 없음). 정의되지 않은 CSS 변수를 쓰던 빈 상태(--d3-overlay-strong) 수정.
// 행 높이(40px)·"0번 줄 항상 있음"은 main/windows/popup-list-geometry.ts 와 짝이다.
;(function () {
'use strict'
@ -10,142 +11,105 @@
var items = []
var selectedIndex = 0
var currentCommands = []
var currentActiveId = null
var i18nStrings = {}
var i18n = {}
function tr(key) {
return (i18n && i18n[key]) || ''
}
// ── i18n 적용 (HTML 텍스트) ───────────────────────────
function applyI18nToHtml() {
var elements = document.querySelectorAll('[data-i18n]')
elements.forEach(function(el) {
var key = el.getAttribute('data-i18n')
if (i18nStrings[key]) {
el.textContent = i18nStrings[key]
}
document.querySelectorAll('[data-i18n]').forEach(function (el) {
el.textContent = tr(el.getAttribute('data-i18n'))
})
}
// ── 아이템 렌더링 ───────────────────────────────────
function makeRow(index, number, label, detail, isActive, muted) {
var row = document.createElement('div')
row.className = 'd3-item d3-list-item' + (index === selectedIndex ? ' selected' : '') + (muted ? ' muted' : '')
row.id = 'command-item-' + index
row.setAttribute('role', 'option')
row.setAttribute('aria-selected', index === selectedIndex ? 'true' : 'false')
var num = document.createElement('kbd')
num.className = 'item-number'
num.textContent = number
var text = document.createElement('span')
text.className = 'item-text'
text.textContent = label
if (detail) {
var d = document.createElement('span')
d.className = 'item-detail'
d.textContent = detail
text.appendChild(d)
}
row.appendChild(num)
row.appendChild(text)
if (isActive) {
var badge = document.createElement('span')
badge.className = 'item-active'
badge.setAttribute('aria-label', 'active')
row.appendChild(badge)
}
row.addEventListener('click', function () {
selectAndApply(index)
})
return row
}
function renderItems(commands, activeId) {
currentCommands = commands
currentActiveId = activeId
itemsContainer.innerHTML = ''
items = []
var none = makeRow(0, '0', tr('commandNone'), '', !activeId, true)
itemsContainer.appendChild(none)
items.push(none)
if (commands.length === 0) {
var empty = document.createElement('div')
empty.style.cssText = 'color: var(--d3-overlay-strong); font-size: 13px; text-align: center; padding: 24px 16px;'
empty.textContent = i18nStrings.noCommands || 'No commands'
empty.className = 'list-empty'
empty.textContent = tr('commandEmpty')
itemsContainer.appendChild(empty)
return
}
// ── "선택 해제" 항목 (맨 위) ──
var noneDiv = document.createElement('div')
noneDiv.className = 'item item-none' + (0 === selectedIndex ? ' selected' : '') + (!activeId ? ' active' : '')
noneDiv.dataset.index = '0'
var noneNum = document.createElement('span')
noneNum.className = 'item-number'
noneNum.textContent = '0'
var noneName = document.createElement('span')
noneName.className = 'item-name'
noneName.style.opacity = '0.5'
noneName.textContent = i18nStrings.noCommand || 'No command (insert original)'
if (!activeId) {
var noneBadge = document.createElement('span')
noneBadge.className = 'item-active-badge'
noneBadge.textContent = '●'
noneDiv.appendChild(noneNum)
noneDiv.appendChild(noneName)
noneDiv.appendChild(noneBadge)
} else {
noneDiv.appendChild(noneNum)
noneDiv.appendChild(noneName)
}
itemsContainer.appendChild(noneDiv)
items.push(noneDiv)
noneDiv.addEventListener('click', function () {
selectAndApply(-1)
})
commands.forEach(function (cmd, index) {
var itemIndex = index + 1
var div = document.createElement('div')
div.className = 'item' + (itemIndex === selectedIndex ? ' selected' : '') + (cmd.id === activeId ? ' active' : '')
div.dataset.index = String(itemIndex)
var num = document.createElement('span')
num.className = 'item-number'
num.textContent = String(itemIndex)
var name = document.createElement('span')
name.className = 'item-name'
name.textContent = cmd.name
var desc = document.createElement('span')
desc.className = 'item-desc'
desc.textContent = cmd.description
div.appendChild(num)
div.appendChild(name)
if (cmd.id === activeId) {
var badge = document.createElement('span')
badge.className = 'item-active-badge'
badge.textContent = '●'
div.appendChild(badge)
}
div.appendChild(desc)
itemsContainer.appendChild(div)
items.push(div)
div.addEventListener('click', function () {
selectAndApply(itemIndex)
commands.forEach(function (cmd, i) {
var index = i + 1
var row = makeRow(index, index <= 9 ? String(index) : '', cmd.name, cmd.description || '', cmd.id === activeId, false)
itemsContainer.appendChild(row)
items.push(row)
})
})
}
itemsContainer.setAttribute('aria-activedescendant', 'command-item-' + selectedIndex)
}
// ── 선택 업데이트 ───────────────────────────────────
function updateSelection(newIndex) {
var totalItems = currentCommands.length + 1 // +1 for "none" item
if (totalItems <= 1) return
if (newIndex < 0) newIndex = totalItems - 1
if (newIndex >= totalItems) newIndex = 0
var total = currentCommands.length + 1
if (newIndex < 0) newIndex = total - 1
if (newIndex >= total) newIndex = 0
items.forEach(function (item, i) {
if (i === newIndex) {
item.classList.add('selected')
} else {
item.classList.remove('selected')
}
item.classList.toggle('selected', i === newIndex)
item.setAttribute('aria-selected', i === newIndex ? 'true' : 'false')
})
selectedIndex = newIndex
if (items[selectedIndex]) {
items[selectedIndex].scrollIntoView({ block: 'nearest' })
}
itemsContainer.setAttribute('aria-activedescendant', 'command-item-' + selectedIndex)
if (items[selectedIndex]) items[selectedIndex].scrollIntoView({ block: 'nearest' })
}
// ── 선택 적용 ───────────────────────────────────────
/** 0 = 명령 없이 원문, 1.. = 명령 */
function selectAndApply(index) {
// index 0 또는 -1 = "선택 해제"
if (index <= 0) {
window.popupAPI.send('command:selected', { id: '', name: '' })
return
}
var cmdIndex = index - 1
if (cmdIndex < 0 || cmdIndex >= currentCommands.length) return
var cmd = currentCommands[cmdIndex]
var cmd = currentCommands[index - 1]
if (!cmd) return
window.popupAPI.send('command:selected', { id: cmd.id, name: cmd.name })
}
// ── IPC 리스너 ───────────────────────────────────────
window.popupAPI.on('command:showItems', function (data) {
selectedIndex = 0
if (data._i18n) i18nStrings = data._i18n
if (data._i18n) i18n = data._i18n
applyI18nToHtml()
renderItems(data.commands || [], data.activeId || null)
container.classList.remove('hiding')
@ -163,22 +127,13 @@
window.popupAPI.on('command:keyEvent', function (data) {
var key = data.key
if (key === 'ArrowUp') {
updateSelection(selectedIndex - 1)
} else if (key === 'ArrowDown') {
updateSelection(selectedIndex + 1)
} else if (key === 'Enter') {
selectAndApply(selectedIndex)
} else if (key === 'Escape') {
window.popupAPI.send('command:dismissed', {})
} else if (key === '0') {
selectAndApply(0)
} else if (key >= '1' && key <= '9') {
if (key === 'ArrowUp') updateSelection(selectedIndex - 1)
else if (key === 'ArrowDown') updateSelection(selectedIndex + 1)
else if (key === 'Enter') selectAndApply(selectedIndex)
else if (key === 'Escape') window.popupAPI.send('command:dismissed', {})
else if (key >= '0' && key <= '9') {
var idx = parseInt(key, 10)
if (idx <= currentCommands.length) {
selectAndApply(idx)
}
if (idx <= currentCommands.length) selectAndApply(idx)
}
})
})()

View file

@ -1,162 +1,22 @@
/* command-popup/style.css
* Precision Command Quick-Switcher HUD
*/
/* command-popup/style.css — v4 (2026-09-28). 목록형 팝업 공통 모양 + 명령 전용 표시. */
:root {
--d3-bg-card: rgba(15, 17, 22, 0.9);
--d3-border-default: rgba(255, 255, 255, 0.08);
--d3-border-subtle: rgba(255, 255, 255, 0.04);
--d3-text-primary: #f4f4f5;
--d3-text-inactive: #71717a;
--d3-text-muted: rgba(255, 255, 255, 0.35);
--d3-text-dimLabel: #a1a1aa;
--d3-text-secondary: #a1a1aa;
--d3-accent-main: #3b82f6;
--d3-accent-dim: rgba(59, 130, 246, 0.14);
--d3-shadow-popup: 0 16px 36px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.12);
@import '../_shared/list-popup.css';
.item-detail {
margin-left: 8px;
font-size: 12px;
color: var(--d3-text-tertiary);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: transparent;
overflow: hidden;
-webkit-app-region: no-drag;
user-select: none;
font-family: -apple-system, BlinkMacSystemFont, "Pretendard Variable", "Segoe UI", sans-serif;
}
#root {
width: 100%;
height: 100%;
}
.command-popup {
background: var(--d3-bg-card);
border: 1px solid var(--d3-border-default);
border-radius: 16px;
padding: 8px;
backdrop-filter: blur(28px);
-webkit-backdrop-filter: blur(28px);
box-shadow: var(--d3-shadow-popup);
opacity: 0;
transform: translateY(6px) scale(0.98);
transition: opacity 0.15s cubic-bezier(0.16, 1, 0.3, 1), transform 0.15s cubic-bezier(0.16, 1, 0.3, 1);
min-width: 290px;
max-width: 390px;
}
.command-popup.visible {
opacity: 1;
transform: translateY(0) scale(1);
}
.command-popup.hiding {
opacity: 0;
transform: translateY(-4px) scale(0.98);
transition-duration: 0.1s;
}
.title {
color: var(--d3-text-dimLabel);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 6px 10px 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.items {
max-height: 320px;
overflow-y: auto;
}
.item {
display: flex;
align-items: center;
padding: 9px 12px;
border-radius: 10px;
cursor: pointer;
transition: background 120ms ease;
gap: 10px;
position: relative;
}
.item:hover,
.item.selected {
background: rgba(255, 255, 255, 0.06);
}
.item.selected::before {
content: '';
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 3px;
background: var(--d3-accent-main);
border-radius: 999px;
}
.item.active {
background: var(--d3-accent-dim);
}
.item-number {
color: var(--d3-text-inactive);
font-size: 11px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
min-width: 16px;
text-align: center;
}
.item-name {
flex: 1;
color: var(--d3-text-primary);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-desc {
.d3-list-item.muted .item-text {
color: var(--d3-text-secondary);
font-size: 11px;
white-space: nowrap;
}
/* 지금 쓰는 명령 — 색 점 하나(글자 옆 "선택됨" 막대와 구분) */
.item-active {
flex-shrink: 0;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.item-none {
border-bottom: 1px solid var(--d3-border-subtle);
margin-bottom: 3px;
}
.item-active-badge {
color: var(--d3-accent-main);
font-size: 10px;
font-weight: 500;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.hints {
display: flex;
justify-content: center;
gap: 16px;
padding: 6px 0 4px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin-top: 6px;
}
.hints span {
color: var(--d3-text-muted);
font-size: 10.5px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--d3-accent-main);
}

View file

@ -8,12 +8,14 @@
</head>
<body>
<div id="root">
<div id="container" class="history-popup">
<div id="items" class="items"></div>
<div class="hints">
<span data-i18n="hintsSelectEn">↑↓ Select</span>
<span data-i18n="hintsPaste">⏎ Paste</span>
<span data-i18n="hintsClose">ESC ×</span>
<div id="container" class="list-popup d3-panel">
<div id="title" class="list-title" data-i18n="historyTitle"></div>
<div id="items" class="list-items" role="listbox" aria-labelledby="title"></div>
<div class="d3-hints" aria-hidden="true">
<span><kbd>↑</kbd><kbd>↓</kbd><b data-i18n="hintMove"></b></span>
<span><kbd>Enter</kbd><b data-i18n="hintPaste"></b></span>
<span><kbd>1–9</kbd><b data-i18n="hintPick"></b></span>
<span><kbd>Esc</kbd><b data-i18n="hintClose"></b></span>
</div>
</div>
</div>

View file

@ -1,6 +1,7 @@
// HistoryPopup 팝업 스크립트
// Phase 3.5: 커서 위치에 최근 전사 히스토리 팝업
// Arrow↑↓ 선택, Enter 붙여넣기, 1-9 직접 선택, ESC 닫기
// HistoryPopup 팝업 스크립트 — v4 (2026-09-28)
// 커서 곁에 최근 받아쓰기 목록. ↑↓ 이동, Enter 붙여넣기, 1-9 바로 고르기, Esc 닫기(키는 main 이 전역으로 받아 넘긴다).
// 문구는 전부 main 의 _i18n. 영어 폴백("just now", "No history yet")을 두지 않는다.
// 행 높이(40px)는 main/windows/popup-list-geometry.ts 와 짝이다.
;(function () {
'use strict'
@ -10,39 +11,28 @@
var items = []
var selectedIndex = 0
var currentEntries = []
var i18nStrings = {}
var i18n = {}
function tr(key) {
return (i18n && i18n[key]) || ''
}
// ── i18n 적용 (HTML 텍스트) ───────────────────────────
function applyI18nToHtml() {
var elements = document.querySelectorAll('[data-i18n]')
elements.forEach(function(el) {
var key = el.getAttribute('data-i18n')
if (i18nStrings[key]) {
el.textContent = i18nStrings[key]
}
document.querySelectorAll('[data-i18n]').forEach(function (el) {
el.textContent = tr(el.getAttribute('data-i18n'))
})
}
// ── 상대 시간 표시 ───────────────────────────────────
function relativeTime(timestamp) {
var diff = Date.now() - timestamp
var sec = Math.floor(diff / 1000)
if (sec < 60) return i18nStrings.timeJustNow || 'just now'
var sec = Math.floor((Date.now() - timestamp) / 1000)
if (sec < 60) return tr('timeJustNow')
var min = Math.floor(sec / 60)
if (min < 60) return (i18nStrings.timeMinutesAgo || '{{m}}m ago').replace('{{m}}', min)
if (min < 60) return tr('timeMinutesAgo').replace('{{m}}', min)
var hr = Math.floor(min / 60)
if (hr < 24) return (i18nStrings.timeHoursAgo || '{{h}}h ago').replace('{{h}}', hr)
var day = Math.floor(hr / 24)
return (i18nStrings.timeDaysAgo || '{{d}}d ago').replace('{{d}}', day)
if (hr < 24) return tr('timeHoursAgo').replace('{{h}}', hr)
return tr('timeDaysAgo').replace('{{d}}', Math.floor(hr / 24))
}
// ── 텍스트 truncate ──────────────────────────────────
function truncate(text, maxLen) {
if (text.length <= maxLen) return text
return text.substring(0, maxLen) + '...'
}
// ── 아이템 렌더링 ───────────────────────────────────
function renderItems(entries) {
currentEntries = entries
itemsContainer.innerHTML = ''
@ -50,92 +40,68 @@
if (entries.length === 0) {
var empty = document.createElement('div')
empty.className = 'empty-state'
empty.textContent = i18nStrings.noHistory || 'No history yet'
empty.className = 'list-empty'
empty.textContent = tr('historyEmpty')
itemsContainer.appendChild(empty)
return
}
entries.forEach(function (entry, index) {
var div = document.createElement('div')
div.className = 'item' + (index === selectedIndex ? ' selected' : '')
div.dataset.index = String(index)
var row = document.createElement('div')
row.className = 'd3-item d3-list-item' + (index === selectedIndex ? ' selected' : '')
row.id = 'history-item-' + index
row.setAttribute('role', 'option')
row.setAttribute('aria-selected', index === selectedIndex ? 'true' : 'false')
var num = document.createElement('span')
var num = document.createElement('kbd')
num.className = 'item-number'
num.textContent = String(index + 1)
num.textContent = index < 9 ? String(index + 1) : ''
var text = document.createElement('span')
text.className = 'item-text'
text.textContent = truncate(entry.polishedText || entry.originalText, 50)
text.textContent = entry.polishedText || entry.originalText
var time = document.createElement('span')
time.className = 'item-time'
time.className = 'item-meta d3-numeral'
time.textContent = relativeTime(entry.createdAt)
div.appendChild(num)
div.appendChild(text)
div.appendChild(time)
itemsContainer.appendChild(div)
items.push(div)
// 클릭으로 선택+삽입
div.addEventListener('click', function () {
row.appendChild(num)
row.appendChild(text)
row.appendChild(time)
row.addEventListener('click', function () {
selectAndInsert(index)
})
itemsContainer.appendChild(row)
items.push(row)
})
itemsContainer.setAttribute('aria-activedescendant', 'history-item-' + selectedIndex)
}
// ── 선택 업데이트 ───────────────────────────────────
function updateSelection(newIndex) {
if (currentEntries.length === 0) return
if (newIndex < 0) newIndex = currentEntries.length - 1
if (newIndex >= currentEntries.length) newIndex = 0
items.forEach(function (item, i) {
if (i === newIndex) {
item.classList.add('selected')
} else {
item.classList.remove('selected')
}
item.classList.toggle('selected', i === newIndex)
item.setAttribute('aria-selected', i === newIndex ? 'true' : 'false')
})
selectedIndex = newIndex
// 스크롤 보장
if (items[selectedIndex]) {
items[selectedIndex].scrollIntoView({ block: 'nearest' })
}
itemsContainer.setAttribute('aria-activedescendant', 'history-item-' + selectedIndex)
if (items[selectedIndex]) items[selectedIndex].scrollIntoView({ block: 'nearest' })
}
// ── 선택 후 삽입 ───────────────────────────────────
function selectAndInsert(index) {
if (index < 0 || index >= currentEntries.length) return
var entry = currentEntries[index]
var text = entry.polishedText || entry.originalText
window.popupAPI.send('history:itemSelected', { id: entry.id, text: text })
window.popupAPI.send('history:itemSelected', { id: entry.id, text: entry.polishedText || entry.originalText })
}
// ── 크기 측정 ───────────────────────────────────────
function measureAndReport() {
requestAnimationFrame(function () {
requestAnimationFrame(function () {
var rect = container.getBoundingClientRect()
window.popupAPI.send('history:popupMeasured', {
width: Math.ceil(rect.width) + 4,
height: Math.ceil(rect.height) + 4
})
})
})
}
// ── IPC 리스너 ───────────────────────────────────────
window.popupAPI.on('history:showItems', function (data) {
selectedIndex = 0
if (data._i18n) i18nStrings = data._i18n
if (data._i18n) i18n = data._i18n
applyI18nToHtml()
renderItems(data.entries || [])
container.classList.remove('hiding')
measureAndReport()
})
window.popupAPI.on('history:show', function () {
@ -148,23 +114,15 @@
container.classList.remove('visible')
})
// 키보드 이벤트 (main에서 전달)
window.popupAPI.on('history:keyEvent', function (data) {
var key = data.key
if (key === 'ArrowUp') {
updateSelection(selectedIndex - 1)
} else if (key === 'ArrowDown') {
updateSelection(selectedIndex + 1)
} else if (key === 'Enter') {
selectAndInsert(selectedIndex)
} else if (key === 'Escape') {
window.popupAPI.send('history:popupDismissed', {})
} else if (key >= '1' && key <= '9') {
if (key === 'ArrowUp') updateSelection(selectedIndex - 1)
else if (key === 'ArrowDown') updateSelection(selectedIndex + 1)
else if (key === 'Enter') selectAndInsert(selectedIndex)
else if (key === 'Escape') window.popupAPI.send('history:popupDismissed', {})
else if (key >= '1' && key <= '9') {
var idx = parseInt(key, 10) - 1
if (idx < currentEntries.length) {
selectAndInsert(idx)
}
if (idx < currentEntries.length) selectAndInsert(idx)
}
})
})()

View file

@ -1,139 +1,3 @@
/* history-popup/style.css
* Precision Recent Dictation Inserter HUD
*/
/* history-popup/style.css — v4 (2026-09-28). 목록형 팝업 공통 모양을 그대로 쓴다. */
:root {
--d3-bg-card: rgba(15, 17, 22, 0.9);
--d3-border-default: rgba(255, 255, 255, 0.08);
--d3-border-subtle: rgba(255, 255, 255, 0.04);
--d3-text-primary: #f4f4f5;
--d3-text-inactive: #71717a;
--d3-text-muted: rgba(255, 255, 255, 0.35);
--d3-accent-main: #3b82f6;
--d3-shadow-popup: 0 16px 36px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: transparent;
overflow: hidden;
-webkit-app-region: no-drag;
user-select: none;
font-family: -apple-system, BlinkMacSystemFont, "Pretendard Variable", "Segoe UI", sans-serif;
}
#root {
width: 100%;
height: 100%;
}
.history-popup {
background: var(--d3-bg-card);
border: 1px solid var(--d3-border-default);
border-radius: 16px;
padding: 8px;
backdrop-filter: blur(28px);
-webkit-backdrop-filter: blur(28px);
box-shadow: var(--d3-shadow-popup);
opacity: 0;
transform: translateY(6px) scale(0.98);
transition: opacity 0.15s cubic-bezier(0.16, 1, 0.3, 1), transform 0.15s cubic-bezier(0.16, 1, 0.3, 1);
min-width: 310px;
max-width: 430px;
}
.history-popup.visible {
opacity: 1;
transform: translateY(0) scale(1);
}
.history-popup.hiding {
opacity: 0;
transform: translateY(-4px) scale(0.98);
transition-duration: 0.1s;
}
.items {
max-height: 360px;
overflow-y: auto;
}
.item {
display: flex;
align-items: center;
padding: 9px 12px;
border-radius: 10px;
cursor: pointer;
transition: background 120ms ease;
gap: 10px;
position: relative;
}
.item:hover,
.item.selected {
background: rgba(255, 255, 255, 0.06);
}
.item.selected::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 3px;
background: var(--d3-accent-main);
border-radius: 999px;
}
.item-number {
color: var(--d3-text-inactive);
font-size: 11px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
min-width: 16px;
text-align: center;
}
.item-text {
flex: 1;
color: var(--d3-text-primary);
font-size: 13px;
line-height: 1.45;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.item-time {
color: var(--d3-text-inactive);
font-size: 11px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: nowrap;
flex-shrink: 0;
}
.hints {
display: flex;
justify-content: center;
gap: 16px;
padding: 6px 0 4px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin-top: 6px;
}
.hints span {
color: var(--d3-text-muted);
font-size: 10.5px;
}
.empty-state {
color: var(--d3-text-inactive);
font-size: 13px;
text-align: center;
padding: 24px 16px;
}
@import '../_shared/list-popup.css';

View file

@ -8,29 +8,30 @@
</head>
<body>
<div id="root">
<div id="container" class="recording-tip">
<!-- recording 상태 -->
<div id="recording-view" class="view view-recording">
<div class="recording-row">
<div id="wave-bars" class="wave-bars"></div>
<span id="duration-text" class="duration">0:00</span>
</div>
<!-- 실시간 부분 전사 미리보기 -->
<div id="partial-text" class="partial-text hidden"></div>
<!-- 상태는 aria-live 로 읽힌다. 문구는 main 이 보낸 _i18n 으로 채운다(하드코딩 금지). -->
<div id="container" class="recording-tip d3-panel" data-state="idle">
<!-- recording / thinking 공통 머리 줄 -->
<div id="head-row" class="head-row">
<div id="wave-bars" class="wave-bars" aria-hidden="true"></div>
<span id="duration-text" class="duration d3-numeral">0:00</span>
<span id="status-text" class="status-text" role="status" aria-live="polite"></span>
<kbd id="cancel-hint" class="cancel-hint"></kbd>
</div>
<!-- 처리 중: 진행을 꾸며내지 않는 무한 막대 -->
<div id="progress" class="progress hidden" aria-hidden="true"><div class="progress-bar"></div></div>
<!-- 실시간 부분 전사 — 처리 중에도 남겨 둔다(무엇을 받아쓰는지 보인다) -->
<div id="partial-text" class="partial-text hidden"></div>
<!-- done: 넣었다 -->
<div id="done-view" class="line-view hidden">
<span class="done-icon" aria-hidden="true">✓</span>
<span id="done-text" role="status" aria-live="polite"></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 class="error-icon">!</span>
<span id="error-text" class="error-label"></span>
<!-- error -->
<div id="error-view" class="line-view hidden">
<span class="error-icon" aria-hidden="true">!</span>
<span id="error-text" class="error-label" role="alert"></span>
</div>
</div>
</div>

View file

@ -1,50 +1,54 @@
// RecordingTip 팝업 스크립트
// 설계서 03: 9개 웨이브바, cos 분포 가중치, 100ms 애니메이션
// Speakly 패턴 준수
// RecordingTip 팝업 스크립트 — v4 (2026-09-28)
// 상태: recording → thinking → done(잠깐) | error. 9개 웨이브바·cos 가중치·100ms 갱신은 설계서 03 을 따른다.
//
// 정직성 규칙:
// - 입력이 0이면 막대는 바닥에 머문다(이전: 최소 0.08 로 "살아있는 척" 움직여, 마이크가 켜지기 전에도 파형이 흔들렸다).
// - 처리 중에는 꾸며낸 진행률(95%에 점근) 대신 무한 막대. 받아쓰던 문장은 지우지 않고 흐리게 남긴다.
// - 클릭 취소는 녹음 중에만. 처리 중 클릭은 받아쓴 글을 버리는 사고였다 — 처리 중 취소는 Esc(의도가 분명한 입력)로만.
// 사이트 데모(site/src/components/AppLoopDemo.tsx)가 이 캡슐을 재현한다 — 수치를 바꾸면 함께 고친다.
;(function () {
'use strict'
// ── 상수 ─────────────────────────────────────────────
const BAR_COUNT = 9
const UPDATE_INTERVAL = 100
const MIN_HEIGHT = 2
const MAX_HEIGHT = 28
const SMOOTHING = 0.5
const RANDOM_FACTOR = 0.35
var BAR_COUNT = 9
var UPDATE_INTERVAL = 100
var MIN_HEIGHT = 2
var MAX_HEIGHT = 22
var SMOOTHING = 0.5
var RANDOM_FACTOR = 0.35
// 코사인 분포 가중치 (중앙이 가장 높음)
// 설계서 03: cos((n - 4) * PI / 9)
const weights = Array.from({ length: BAR_COUNT }, function (_, i) {
var weights = Array.from({ length: BAR_COUNT }, function (_, i) {
var center = (BAR_COUNT - 1) / 2
var normalized = (i - center) / center
return Math.cos(normalized * Math.PI / 2)
return Math.cos(((i - center) / center) * Math.PI / 2)
})
// ── DOM 참조 ─────────────────────────────────────────
var container = document.getElementById('container')
var recordingView = document.getElementById('recording-view')
var thinkingView = document.getElementById('thinking-view')
var errorView = document.getElementById('error-view')
var waveBarsContainer = document.getElementById('wave-bars')
var durationText = document.getElementById('duration-text')
var progressBar = document.getElementById('progress-bar')
var errorText = document.getElementById('error-text')
var statusText = document.getElementById('status-text')
var cancelHint = document.getElementById('cancel-hint')
var progress = document.getElementById('progress')
var partialText = document.getElementById('partial-text')
var doneView = document.getElementById('done-view')
var doneText = document.getElementById('done-text')
var errorView = document.getElementById('error-view')
var errorText = document.getElementById('error-text')
var headRow = document.getElementById('head-row')
// ── 상태 ─────────────────────────────────────────────
var bars = []
var currentHeights = new Array(BAR_COUNT).fill(MIN_HEIGHT)
var audioLevel = 0
var animInterval = null
var durationInterval = null
var recordingStartTime = 0
var thinkingStartTime = 0
var thinkingRaf = null
var currentState = 'idle'
var i18nStrings = {}
var i18n = {}
function tr(key, fallback) {
return (i18n && i18n[key]) || fallback || ''
}
// ── 웨이브 바 생성 ───────────────────────────────────
function createWaveBars() {
for (var i = 0; i < BAR_COUNT; i++) {
var bar = document.createElement('div')
@ -55,23 +59,17 @@
}
}
// ── 웨이브 바 애니메이션 ─────────────────────────────
function updateBars() {
// 최소 진동: audioLevel이 0이어도 바가 미세하게 움직여 "살아있음" 표현
var effectiveLevel = Math.max(0.08, audioLevel)
for (var i = 0; i < BAR_COUNT; i++) {
var baseTarget = effectiveLevel * MAX_HEIGHT * weights[i]
var randomized = baseTarget * (1 + (Math.random() - 0.5) * 2 * RANDOM_FACTOR)
var target = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, randomized))
// 스무딩 보간
var base = audioLevel * MAX_HEIGHT * weights[i]
// 흔들림은 입력 크기에 비례 — 입력이 0이면 0
var jitter = base * (Math.random() - 0.5) * 2 * RANDOM_FACTOR
var target = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, base + jitter))
currentHeights[i] += (target - currentHeights[i]) * SMOOTHING
bars[i].style.height = Math.round(currentHeights[i]) + 'px'
}
}
// ── 녹음 시간 표시 ───────────────────────────────────
function updateDuration() {
var elapsed = Math.floor((Date.now() - recordingStartTime) / 1000)
var minutes = Math.floor(elapsed / 60)
@ -79,129 +77,122 @@
durationText.textContent = minutes + ':' + (seconds < 10 ? '0' : '') + seconds
}
// ── Thinking 프로그레스 바 (점근 수렴 패턴) ──────────
// 설계서 03: min(95, (1 - 1/(1 + 1.5*t)) * 100)%
function updateThinkingProgress() {
var elapsed = (performance.now() - thinkingStartTime) / 1000
var progress = Math.min(95, (1 - 1 / (1 + 1.5 * elapsed)) * 100)
progressBar.style.width = progress + '%'
if (progress < 95 && currentState === 'thinking') {
thinkingRaf = requestAnimationFrame(updateThinkingProgress)
}
}
// ── 뷰 전환 ─────────────────────────────────────────
function hideAllViews() {
recordingView.classList.add('hidden')
thinkingView.classList.add('hidden')
errorView.classList.add('hidden')
function stopTimers() {
clearInterval(animInterval)
clearInterval(durationInterval)
if (thinkingRaf) cancelAnimationFrame(thinkingRaf)
animInterval = null
durationInterval = null
thinkingRaf = null
}
function show(el, visible) {
el.classList.toggle('hidden', !visible)
}
function setState(state) {
currentState = state
container.setAttribute('data-state', state)
}
function showRecording() {
currentState = 'recording'
hideAllViews()
recordingView.classList.remove('hidden')
stopTimers()
setState('recording')
show(headRow, true)
show(progress, false)
show(doneView, false)
show(errorView, false)
show(durationText, true)
statusText.textContent = ''
statusText.setAttribute('aria-label', tr('tipRecording'))
cancelHint.textContent = tr('tipCancelHint', 'Esc')
show(cancelHint, true)
recordingStartTime = Date.now()
durationText.textContent = '0:00'
audioLevel = 0
currentHeights.fill(MIN_HEIGHT)
for (var i = 0; i < bars.length; i++) bars[i].style.height = MIN_HEIGHT + 'px'
// 이전 세션의 부분 전사 리셋
partialText.textContent = ''
partialText.classList.add('hidden')
show(partialText, false)
partialText.classList.remove('dim')
animInterval = setInterval(updateBars, UPDATE_INTERVAL)
durationInterval = setInterval(updateDuration, 1000)
}
function showThinking() {
currentState = 'thinking'
hideAllViews()
thinkingView.classList.remove('hidden')
progressBar.style.width = '0%'
progressBar.style.transition = 'width 100ms linear'
thinkingStartTime = performance.now()
thinkingRaf = requestAnimationFrame(updateThinkingProgress)
stopTimers()
setState('thinking')
show(headRow, true)
show(durationText, false)
// 막대는 바닥으로 — 더 이상 듣지 않는다
for (var i = 0; i < bars.length; i++) bars[i].style.height = MIN_HEIGHT + 'px'
statusText.textContent = tr('tipProcessing')
statusText.removeAttribute('aria-label')
cancelHint.textContent = tr('tipCancelHint', 'Esc')
show(cancelHint, true)
show(progress, true)
show(doneView, false)
show(errorView, false)
// 받아쓰던 문장은 남긴다(흐리게)
if (partialText.textContent) partialText.classList.add('dim')
}
function showDone() {
stopTimers()
setState('done')
show(headRow, false)
show(progress, false)
show(partialText, false)
show(errorView, false)
doneText.textContent = tr('tipInserted')
show(doneView, true)
}
function showError(message) {
currentState = 'error'
hideAllViews()
errorView.classList.remove('hidden')
errorText.textContent = message || i18nStrings.errorDefault || '오류가 발생했습니다'
stopTimers()
setState('error')
show(headRow, false)
show(progress, false)
show(partialText, false)
show(doneView, false)
errorText.textContent = message || tr('errorDefault')
show(errorView, true)
}
// ── 크기 측정 (2-phase 리사이즈) ────────────────────
function measureAndReport() {
requestAnimationFrame(function () {
requestAnimationFrame(function () {
var rect = container.getBoundingClientRect()
window.popupAPI.send('window:tipMeasured', {
width: Math.ceil(rect.width) + 4,
height: Math.ceil(rect.height) + 4
})
})
})
function applyState(data) {
if (data && data._i18n) i18n = data._i18n
var state = data && data.state
if (state === 'recording') showRecording()
else if (state === 'thinking') showThinking()
else if (state === 'done') showDone()
else if (state === 'error') showError(data.errorMessage)
container.style.opacity = '1'
}
// ── IPC 리스너 ───────────────────────────────────────
function setupListeners() {
// Phase 1: prepare — 숨겨진 상태에서 렌더링 후 크기 측정
window.popupAPI.on('window:tipPrepare', function (data) {
if (data._i18n) i18nStrings = data._i18n
var state = data.state
if (state === 'recording') showRecording()
else if (state === 'thinking') showThinking()
else if (state === 'error') showError(data.errorMessage)
measureAndReport()
})
// Phase 2: show — 리사이즈 완료 후 표시
window.popupAPI.on('window:tipShow', function () {
container.style.opacity = '1'
})
// 오디오 레벨
window.popupAPI.on('voice:audioLevel', function (data) {
audioLevel = data.level || 0
audioLevel = (data && data.level) || 0
})
// 실시간 부분 전사 — 녹음 중 말하는 내용 미리보기
// 실시간 부분 전사 — 녹음 중 말하는 내용 미리보기. 최근 문장이 보이도록 아래쪽을 남긴다(CSS: flex-end).
window.popupAPI.on('voice:partialTranscript', function (data) {
if (currentState !== 'recording') return
var text = (data && data.text) || ''
if (text.length === 0) return
partialText.textContent = text
partialText.classList.remove('hidden')
// 항상 끝부분(최근 발화)이 보이도록 스크롤
partialText.scrollTop = partialText.scrollHeight
show(partialText, true)
})
// 상태 변경 (showRecordingTip + updateRecordingTipState 양쪽에서 사용)
window.popupAPI.on('window:tipStateChanged', function (data) {
if (data._i18n) i18nStrings = data._i18n
var state = data.state
if (state === 'recording') showRecording()
else if (state === 'thinking') showThinking()
else if (state === 'error') showError(data.errorMessage)
// 항상 가시성 보장
container.style.opacity = '1'
})
window.popupAPI.on('window:tipStateChanged', applyState)
// 클릭 시 녹음 취소
// 클릭 취소는 녹음 중에만 — 처리 중 클릭이 받아쓴 글을 버리던 사고 방지
container.addEventListener('click', function () {
if (currentState !== 'recording') return
window.popupAPI.send('voice:cancelRecording', {})
})
}
// ── 초기화 ───────────────────────────────────────────
document.addEventListener('DOMContentLoaded', function () {
createWaveBars()
setupListeners()

View file

@ -1,40 +1,8 @@
/* recording-tip/style.css
* Precision Hardware Voice Capsule HUD (Push-To-Talk Telemetry)
* Pure CSS Custom Properties with Midnight fallback
*/
/* recording-tip/style.css — v4 (2026-09-28)
* 커서 곁 녹음 캡슐. 다른 팝업과 같은 판(.d3-panel)·같은 폰트. 막대는 액센트 한 색.
* 반투명·블러·다색 파형·알약(999px) 모양을 버렸다 — 팝업 6종이 한 앱처럼 보이게. */
:root {
--d3-bg-tip: rgba(15, 17, 22, 0.88);
--d3-text-primary: #f4f4f5;
--d3-text-secondary: #a1a1aa;
--d3-accent-main: #3b82f6;
--d3-border-default: rgba(255, 255, 255, 0.08);
--d3-border-strong: rgba(255, 255, 255, 0.16);
--d3-shadow-popup: 0 16px 36px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.12);
--d3-wave-1: #93c5fd;
--d3-wave-2: #60a5fa;
--d3-wave-3: #4f8dfa;
--d3-wave-4: #3b82f6;
--d3-wave-5: #3b82f6;
--d3-wave-6: #5b75f7;
--d3-wave-7: #6e71f7;
--d3-wave-8: #818cf8;
--d3-wave-9: #a78bfa;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: transparent;
overflow: hidden;
-webkit-app-region: no-drag;
user-select: none;
font-family: -apple-system, BlinkMacSystemFont, "Pretendard Variable", "Segoe UI", sans-serif;
}
@import '../_shared/popup-base.css';
#root {
display: flex;
@ -45,135 +13,157 @@ body {
}
.recording-tip {
background: var(--d3-bg-tip);
border: 1px solid var(--d3-border-default);
border-radius: 999px;
padding: 8px 16px;
display: flex;
flex-direction: column;
gap: 6px;
min-width: 168px;
max-width: 300px;
padding: 8px 12px;
cursor: default;
transition: border-color 160ms var(--popup-ease);
}
.recording-tip[data-state='recording'] {
cursor: pointer; /* 녹음 중에만 눌러서 취소 */
border-color: var(--d3-border-strong);
}
.head-row {
display: flex;
align-items: center;
gap: 10px;
backdrop-filter: blur(28px);
-webkit-backdrop-filter: blur(28px);
box-shadow: var(--d3-shadow-popup);
transition: all 180ms cubic-bezier(0.16, 1, 0.3, 1);
cursor: pointer;
}
.wave-bars {
display: flex;
align-items: center;
gap: 3px;
height: 28px;
height: 22px;
}
.wave-bar {
width: 3px;
min-height: 2px;
border-radius: 2px;
background: var(--d3-accent-main);
border-radius: 999px;
transition: height 80ms ease-out;
min-height: 3px;
}
.wave-bar:nth-child(1) { background: var(--d3-wave-1); opacity: 0.85; }
.wave-bar:nth-child(2) { background: var(--d3-wave-2); opacity: 0.9; }
.wave-bar:nth-child(3) { background: var(--d3-wave-3); opacity: 0.95; }
.wave-bar:nth-child(4) { background: var(--d3-wave-4); opacity: 1; }
.wave-bar:nth-child(5) { background: var(--d3-wave-5); opacity: 1; }
.wave-bar:nth-child(6) { background: var(--d3-wave-6); opacity: 1; }
.wave-bar:nth-child(7) { background: var(--d3-wave-7); opacity: 0.95; }
.wave-bar:nth-child(8) { background: var(--d3-wave-8); opacity: 0.9; }
.wave-bar:nth-child(9) { background: var(--d3-wave-9); opacity: 0.85; }
.recording-tip[data-state='thinking'] .wave-bar {
background: var(--d3-text-tertiary);
}
.duration {
color: var(--d3-text-primary);
font-size: 13px;
font-weight: 600;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
min-width: 34px;
font-weight: 500;
min-width: 32px;
color: var(--d3-text-primary);
}
.progress-container {
width: 120px;
height: 4px;
background: rgba(255, 255, 255, 0.08);
border-radius: 999px;
.status-text {
flex: 1;
font-size: 12px;
color: var(--d3-text-secondary);
white-space: nowrap;
}
.cancel-hint {
margin-left: auto;
width: auto;
padding: 0 5px;
color: var(--d3-text-tertiary);
font-family: var(--popup-font);
white-space: nowrap;
}
/* 처리 중 — 꾸며낸 퍼센트 대신 무한 막대 */
.progress {
position: relative;
height: 2px;
border-radius: 1px;
background: var(--d3-border-default);
overflow: hidden;
}
.progress-bar {
height: 100%;
position: absolute;
top: 0;
bottom: 0;
width: 36%;
border-radius: 1px;
background: var(--d3-accent-main);
border-radius: 999px;
width: 0%;
transition: width 100ms linear;
animation: tip-indeterminate 1.1s var(--popup-ease) infinite;
}
.thinking-label {
color: var(--d3-text-secondary);
@keyframes tip-indeterminate {
from { left: -36%; }
to { left: 100%; }
}
@media (prefers-reduced-motion: reduce) {
.progress-bar {
left: 0;
width: 100%;
opacity: 0.5;
}
}
/* 부분 전사 — 최근 두 줄이 보이도록 아래쪽을 남긴다 */
.partial-text {
display: flex;
flex-direction: column;
justify-content: flex-end;
max-height: 38px;
overflow: hidden;
padding-top: 6px;
border-top: 1px solid var(--d3-border-subtle);
font-size: 12px;
font-weight: 500;
line-height: 1.55;
color: var(--d3-text-primary);
text-align: left;
word-break: keep-all;
overflow-wrap: anywhere;
}
.partial-text.dim {
color: var(--d3-text-tertiary);
}
.line-view {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.done-icon {
color: var(--d3-status-success);
font-size: 13px;
line-height: 1;
}
.error-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: #ef4444;
color: white;
flex-shrink: 0;
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid var(--d3-status-danger);
color: var(--d3-status-danger);
font-size: 11px;
font-weight: 500;
line-height: 1;
}
.error-label {
color: var(--d3-text-primary);
font-size: 12px;
}
.view {
display: flex;
align-items: center;
gap: 10px;
}
.view.hidden {
display: none;
}
/* ── 실시간 부분 전사 (recording 상태) ─────────────── */
.view-recording {
flex-direction: column;
align-items: stretch;
gap: 6px;
min-width: 160px;
max-width: 320px;
}
.recording-row {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.partial-text {
color: var(--d3-text-primary);
font-size: 12px;
line-height: 1.45;
text-align: left;
max-height: 38px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
border-top: 1px solid var(--d3-border-default);
padding-top: 6px;
word-break: keep-all;
overflow-wrap: anywhere;
}
.partial-text.hidden {
display: none;
.hidden {
display: none !important;
}

View file

@ -8,17 +8,27 @@
</head>
<body>
<div id="root">
<div id="container" class="result-popup">
<!-- 자동 입력이 실패했을 때만 뜬다 — 왜 떴는지 제목이 말한다 -->
<div id="container" class="result-popup d3-panel" role="alertdialog" aria-labelledby="result-title" aria-describedby="result-text">
<div class="result-head">
<span id="result-title" class="result-title" data-i18n="resultTitle"></span>
<button id="close-btn" class="icon-btn" type="button" data-i18n-label="close">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div id="result-text" class="result-text"></div>
<div id="actions" class="actions">
<button id="copy-btn" class="action-btn" data-i18n-title="copy" title="복사">
<svg id="copy-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<div class="result-actions">
<button id="copy-btn" class="copy-btn" type="button">
<svg id="copy-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<svg id="check-icon" class="hidden" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg id="check-icon" class="hidden" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
<span id="copy-label"></span>
</button>
</div>
</div>

View file

@ -1,5 +1,6 @@
// ResultPopup 팝업 스크립트
// 설계서 03: 2-phase 리사이즈, auto-close, 마우스 호버 시 유지
// ResultPopup 팝업 스크립트 — v4 (2026-09-28)
// 자동 입력이 실패했을 때 받아쓴 글을 보여 주고 복사하게 한다. 2-phase 리사이즈(설계서 03)·자동 닫힘·호버 시 유지.
// v4: 왜 떴는지 제목, 글자 있는 복사 버튼, 닫기 버튼. 문구는 main 의 _i18n.
;(function () {
'use strict'
@ -9,30 +10,37 @@
var copyBtn = document.getElementById('copy-btn')
var copyIcon = document.getElementById('copy-icon')
var checkIcon = document.getElementById('check-icon')
var i18nStrings = {}
var copyLabel = document.getElementById('copy-label')
var closeBtn = document.getElementById('close-btn')
var i18n = {}
var autoCloseTimer = null
var copiedTimer = null
var remainingTime = 0
var lastTick = 0
// ── i18n 적용 (HTML 속성) ───────────────────────────
function applyI18nToHtml() {
var elements = document.querySelectorAll('[data-i18n-title]')
elements.forEach(function(el) {
var key = el.getAttribute('data-i18n-title')
if (i18nStrings[key]) {
el.setAttribute('title', i18nStrings[key])
}
})
function tr(key) {
return (i18n && i18n[key]) || ''
}
function applyI18nToHtml() {
document.querySelectorAll('[data-i18n]').forEach(function (el) {
el.textContent = tr(el.getAttribute('data-i18n'))
})
document.querySelectorAll('[data-i18n-label]').forEach(function (el) {
var label = tr(el.getAttribute('data-i18n-label'))
el.setAttribute('aria-label', label)
el.setAttribute('title', label)
})
copyLabel.textContent = tr('copy')
}
// ── Auto-close 제어 ─────────────────────────────────
function startAutoCloseTimer(ms) {
remainingTime = ms
lastTick = Date.now()
clearInterval(autoCloseTimer)
autoCloseTimer = setInterval(function () {
remainingTime -= (Date.now() - lastTick)
remainingTime -= Date.now() - lastTick
lastTick = Date.now()
if (remainingTime <= 0) {
clearInterval(autoCloseTimer)
@ -51,33 +59,40 @@
startAutoCloseTimer(remainingTime > 0 ? remainingTime : 2000)
}
// ── 복사 버튼 ───────────────────────────────────────
copyBtn.addEventListener('click', function () {
// navigator.clipboard는 팝업에서 작동 안 할 수 있으므로 IPC 사용
window.popupAPI.send('clipboard:copy', resultText.textContent)
function resetCopied() {
clearTimeout(copiedTimer)
copyBtn.classList.remove('copied')
copyIcon.classList.remove('hidden')
checkIcon.classList.add('hidden')
copyLabel.textContent = tr('copy')
}
copyBtn.addEventListener('click', function () {
// navigator.clipboard 는 팝업에서 작동하지 않을 수 있어 IPC 로 복사한다
window.popupAPI.send('clipboard:copy', resultText.textContent)
copyBtn.classList.add('copied')
copyIcon.classList.add('hidden')
checkIcon.classList.remove('hidden')
setTimeout(function () {
copyBtn.classList.remove('copied')
copyIcon.classList.remove('hidden')
checkIcon.classList.add('hidden')
}, 2000)
copyLabel.textContent = tr('copied')
clearTimeout(copiedTimer)
copiedTimer = setTimeout(resetCopied, 2000)
})
closeBtn.addEventListener('click', function () {
pauseAutoClose()
window.popupAPI.send('window:hideResultPopup')
})
// ── 마우스 호버 시 auto-close 일시정지 ──────────────
container.addEventListener('mouseenter', pauseAutoClose)
container.addEventListener('mouseleave', resumeAutoClose)
// ── IPC 리스너 ───────────────────────────────────────
// Phase 1: prepare — 결과 텍스트 세팅 + 크기 측정
window.popupAPI.on('result:prepare', function (data) {
resultText.textContent = data.text || ''
if (data._i18n) i18nStrings = data._i18n
if (data._i18n) i18n = data._i18n
applyI18nToHtml()
resetCopied()
resultText.textContent = data.text || ''
resultText.scrollTop = 0
container.classList.remove('visible')
requestAnimationFrame(function () {
@ -85,7 +100,7 @@
var rect = container.getBoundingClientRect()
window.popupAPI.send('result:measured', {
width: Math.ceil(rect.width) + 4,
height: Math.ceil(rect.height) + 4
height: Math.ceil(rect.height) + 4,
})
})
})
@ -95,12 +110,9 @@
window.popupAPI.on('result:show', function (data) {
container.classList.add('visible')
var autoHideMs = (data && data.autoHideMs) || 5000
if (autoHideMs > 0) {
startAutoCloseTimer(autoHideMs)
}
if (autoHideMs > 0) startAutoCloseTimer(autoHideMs)
})
// hide
window.popupAPI.on('result:hide', function () {
container.classList.remove('visible')
clearInterval(autoCloseTimer)

View file

@ -1,107 +1,116 @@
/* result-popup/style.css
* Precision Result Card HUD with Spring Physics
*/
/* result-popup/style.css — v4 (2026-09-28)
* 입력 실패 시 받아쓴 글. 긴 글은 높이를 제한하고 스크롤(이전: 높이 상한 없음 → 화면보다 커질 수 있었다). */
:root {
--d3-bg-result: rgba(15, 17, 22, 0.9);
--d3-border-result: rgba(255, 255, 255, 0.08);
--d3-text-result: #f4f4f5;
--d3-action-btn: rgba(255, 255, 255, 0.65);
--d3-action-btn-hover-bg: rgba(255, 255, 255, 0.1);
--d3-action-btn-hover: #ffffff;
--d3-shadow-popup: 0 16px 36px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.12);
--d3-status-success: #10b981;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: transparent;
overflow: hidden;
-webkit-app-region: no-drag;
user-select: none;
font-family: -apple-system, BlinkMacSystemFont, "Pretendard Variable", "Segoe UI", sans-serif;
}
@import '../_shared/popup-base.css';
#root {
display: flex;
width: 100%;
height: 100%;
align-items: flex-start;
justify-content: flex-start;
padding: 2px;
}
.result-popup {
background: var(--d3-bg-result);
border: 1px solid var(--d3-border-result);
border-radius: 16px;
padding: 12px 16px;
backdrop-filter: blur(28px);
-webkit-backdrop-filter: blur(28px);
box-shadow: var(--d3-shadow-popup);
opacity: 0;
transform: translateY(6px) scale(0.98);
transition: opacity 180ms cubic-bezier(0.16, 1, 0.3, 1), transform 180ms cubic-bezier(0.16, 1, 0.3, 1);
max-width: 420px;
display: flex;
align-items: flex-start;
gap: 10px;
flex-direction: column;
gap: 8px;
width: max-content;
min-width: 240px;
max-width: 400px;
padding: 10px 12px 12px;
opacity: 0;
transform: translateY(4px);
transition: opacity 140ms var(--popup-ease), transform 140ms var(--popup-ease);
}
.result-popup.visible {
opacity: 1;
transform: translateY(0) scale(1);
transform: translateY(0);
}
.result-text {
flex: 1;
color: var(--d3-text-result);
font-size: 13.5px;
line-height: 1.55;
word-break: break-word;
font-weight: 500;
}
.actions {
.result-head {
display: flex;
gap: 4px;
flex-shrink: 0;
margin-top: 1px;
align-items: center;
gap: 8px;
}
.action-btn {
.result-title {
flex: 1;
font-size: 12px;
color: var(--d3-text-secondary);
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.04);
border-radius: 8px;
color: var(--d3-action-btn);
width: 24px;
height: 24px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--d3-text-tertiary);
cursor: pointer;
transition: all 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
.action-btn:hover {
background: var(--d3-action-btn-hover-bg);
border-color: rgba(255, 255, 255, 0.12);
color: var(--d3-action-btn-hover);
transform: translateY(-1px);
.icon-btn:hover {
background: var(--d3-overlay-soft);
color: var(--d3-text-primary);
}
.action-btn:active {
transform: translateY(0) scale(0.96);
.result-text {
max-height: 180px;
overflow-y: auto;
font-size: 14px;
line-height: 1.6;
color: var(--d3-text-primary);
white-space: pre-wrap;
word-break: keep-all;
overflow-wrap: anywhere;
user-select: text;
}
.action-btn.copied {
.result-text::-webkit-scrollbar {
width: 0;
}
.result-actions {
display: flex;
justify-content: flex-end;
}
.copy-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 12px;
border: 0;
border-radius: 8px;
background: var(--d3-accent-solid, var(--d3-accent-main));
color: var(--d3-accent-ink, #fff);
font-family: var(--popup-font);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background-color 140ms var(--popup-ease);
}
.copy-btn:hover {
background: var(--d3-accent-solidHover, var(--d3-accent-main));
}
.copy-btn.copied {
background: transparent;
color: var(--d3-status-success);
border-color: rgba(16, 185, 129, 0.3);
background: rgba(16, 185, 129, 0.12);
box-shadow: inset 0 0 0 1px var(--d3-border-strong);
}
button:focus-visible {
outline: 2px solid var(--d3-accent-main);
outline-offset: 2px;
}
.hidden {
display: none;
display: none !important;
}

View file

@ -192,7 +192,15 @@
function applyPayload(payload) {
if (!payload) return
if (payload._i18n) i18nStrings = payload._i18n
if (payload._i18n) {
i18nStrings = payload._i18n
// 닫기 버튼은 아이콘뿐이라 이름을 붙인다(2026-09-28 감사: 스크린리더가 읽을 이름이 없었다)
var closeButton = document.getElementById('close')
if (closeButton && i18nStrings.close) {
closeButton.setAttribute('aria-label', i18nStrings.close)
closeButton.setAttribute('title', i18nStrings.close)
}
}
if (payload._keyHints) keyHints = payload._keyHints
candidates = (payload.candidates || []).map(function (candidate) {
return candidate && candidate.text ? candidate.text : String(candidate)

View file

@ -10,6 +10,14 @@
* 그 파일도 함께 고친다. 각 줄은 내용과 무관하게 높이를 고정한다.
*/
@import '../_shared/popup-base.css';
/* 공통 바탕의 body 글자 크기·줄 높이가 고정 치수 계산을 흔들지 않도록 이 오버레이는 브라우저 기본값을 유지한다 */
body {
font-size: 16px;
line-height: normal;
}
:root {
--d3-bg-result: #111a30;
--d3-border-result: rgba(148, 180, 255, 0.12);
@ -36,7 +44,7 @@ body {
overflow: hidden;
user-select: none;
-webkit-app-region: no-drag;
font-family: 'Pretendard Variable', 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-family: var(--popup-font);
-webkit-font-smoothing: antialiased;
}
@ -216,7 +224,7 @@ body {
padding: 6px 8px 2px;
border-top: 1px solid var(--d3-border-result);
color: var(--d3-text-secondary);
font-size: 10.5px;
font-size: 11px; /* 11px 미만 금지(v4) — 줄 높이 14px 고정이라 창 크기 계산은 그대로 */
line-height: 14px;
}
@ -287,6 +295,7 @@ kbd {
border-radius: 4px;
color: var(--d3-text-result);
font-family: inherit;
font-size: 10px;
font-size: 11px;
line-height: 1;
min-width: 0;
}

View file

@ -15,6 +15,7 @@ interface FakeWebContents {
send: (channel: string, data: unknown) => void
isDestroyed: () => boolean
insertCSS: () => Promise<string>
executeJavaScript: () => Promise<unknown>
invalidate: () => void
setWindowOpenHandler: () => void
}
@ -56,6 +57,7 @@ vi.mock('electron', () => {
},
isDestroyed: () => false,
insertCSS: async () => '',
executeJavaScript: async () => undefined,
invalidate: () => undefined,
setWindowOpenHandler: () => undefined
}
@ -91,6 +93,8 @@ vi.mock('electron', () => {
const scaled = (value: number): number => Math.round(value / state.scale)
return {
// 팝업 테마: 시스템 테마 해석(2026-09-28)
nativeTheme: { shouldUseDarkColors: true, on: () => undefined, removeListener: () => undefined },
app: { quit: vi.fn(), getPath: vi.fn(() => '.'), on: vi.fn(), getVersion: vi.fn(() => '1.0.0'), isPackaged: false },
BrowserWindow: Object.assign(FakeBrowserWindow, { getAllWindows: vi.fn(() => []) }),
screen: {

View file

@ -0,0 +1,282 @@
// redteam r3-4 회귀
// 1) 녹음 phase 전에 끝난 세션의 오류(STT 모델 미설치·쿼터·자막 사용 중)도 RecordingTip 을 띄워 보인다.
// 예전엔 showError 가 상태 IPC 만 보내 숨겨진 창에 그려졌고 사용자에겐 오류음만 남았다.
// 2) macOS 에서 애플리케이션 메뉴를 null 로 지우지 않는다(Cmd+C/V/X/A/Z/Q 는 role 메뉴로만 동작).
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { ErrorCode } from '@d3ro/core/errors'
import { getI18n } from '@d3ro/i18n'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
type Listener = (...args: unknown[]) => void
interface FakeWindow {
options: Record<string, unknown>
visible: boolean
bounds: Array<{ x: number; y: number; width: number; height: number }>
sent: Array<{ channel: string; data: unknown }>
contentsListeners: Map<string, Listener[]>
showInactive: ReturnType<typeof vi.fn>
}
const state = vi.hoisted(() => ({
windows: [] as FakeWindow[],
setApplicationMenu: vi.fn(),
buildFromTemplate: vi.fn((template: unknown) => ({ template })),
}))
vi.unmock('../../../src/main/windows/WindowManager')
vi.mock('electron', () => {
class FakeBrowserWindow {
options: Record<string, unknown>
visible = false
destroyed = false
bounds: Array<{ x: number; y: number; width: number; height: number }> = []
sent: Array<{ channel: string; data: unknown }> = []
listeners = new Map<string, Listener>()
contentsListeners = new Map<string, Listener[]>()
webContents = {
on: (event: string, listener: Listener) => {
const list = this.contentsListeners.get(event) ?? []
list.push(listener)
this.contentsListeners.set(event, list)
},
send: (channel: string, data: unknown) => {
this.sent.push({ channel, data })
},
isDestroyed: () => false,
isLoading: () => false,
insertCSS: vi.fn(async () => ''),
executeJavaScript: async () => undefined,
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
openDevTools: vi.fn(),
}
showInactive = vi.fn(() => {
this.visible = true
})
constructor(options: Record<string, unknown>) {
this.options = options
state.windows.push(this as unknown as FakeWindow)
}
on(event: string, listener: Listener): this {
this.listeners.set(event, listener)
return this
}
once(event: string, listener: Listener): this {
return this.on(event, listener)
}
setBounds(bounds: { x: number; y: number; width: number; height: number }): void {
this.bounds.push(bounds)
}
isVisible(): boolean {
return this.visible
}
isDestroyed(): boolean {
return this.destroyed
}
hide(): void {
this.visible = false
}
show(): void {
this.visible = true
}
setAlwaysOnTop = vi.fn()
moveTop = vi.fn()
setSkipTaskbar = vi.fn()
setMenu = vi.fn()
center = vi.fn()
restore = vi.fn()
focus = vi.fn()
flashFrame = vi.fn()
isMinimized = vi.fn(() => false)
loadURL = vi.fn(async () => undefined)
loadFile = vi.fn(async () => undefined)
}
return {
// 팝업 테마: 시스템 테마 해석(2026-09-28)
nativeTheme: { shouldUseDarkColors: true, on: () => undefined, removeListener: () => undefined },
app: { quit: vi.fn(), getPath: vi.fn(() => '.'), on: vi.fn(), getVersion: vi.fn(() => '1.0.0'), isPackaged: false },
BrowserWindow: Object.assign(FakeBrowserWindow, { getAllWindows: vi.fn(() => []) }),
screen: {
getPrimaryDisplay: vi.fn(() => ({
workAreaSize: { width: 1920, height: 1080 },
workArea: { x: 0, y: 0, width: 1920, height: 1080 },
})),
getAllDisplays: vi.fn(() => [{ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }]),
getCursorScreenPoint: vi.fn(() => ({ x: 500, y: 500 })),
getDisplayNearestPoint: vi.fn(() => ({ workArea: { x: 0, y: 0, width: 1920, height: 1080 } })),
screenToDipRect: vi.fn((_win: unknown, rect: unknown) => rect),
},
ipcMain: { on: vi.fn(), once: vi.fn(), handle: vi.fn(), removeHandler: vi.fn() },
Menu: {
setApplicationMenu: (...args: unknown[]) => state.setApplicationMenu(...args),
buildFromTemplate: (...args: unknown[]) => state.buildFromTemplate(...args),
},
clipboard: { writeText: vi.fn() },
shell: { openExternal: vi.fn(async () => undefined) },
safeStorage: { isEncryptionAvailable: vi.fn(() => false) },
nativeImage: { createFromPath: vi.fn(), createEmpty: vi.fn() },
Tray: vi.fn(),
dialog: { showErrorBox: vi.fn() },
}
})
vi.mock('@electron-toolkit/utils', () => ({ is: { dev: false } }))
const config: Record<string, unknown> = { theme: 'dark', language: 'ko', closeToTray: true }
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: (key: string) => config[key],
configSet: (key: string, value: unknown) => {
config[key] = value
},
}))
vi.mock('../../../src/main/lifecycle', () => ({
getIsQuitting: () => false,
setIsQuitting: () => undefined,
}))
const originalPlatform = process.platform
afterAll(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform })
})
beforeEach(() => {
vi.resetModules()
state.windows.length = 0
state.setApplicationMenu.mockClear()
state.buildFromTemplate.mockClear()
Object.defineProperty(process, 'platform', { value: originalPlatform })
})
function tipStates(win: FakeWindow): Array<{ state?: string; errorMessage?: string }> {
for (const listener of win.contentsListeners.get('did-finish-load') ?? []) listener()
return win.sent
.filter((m) => m.channel === IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED)
.map((m) => m.data as { state?: string; errorMessage?: string })
}
describe('RecordingTip 오류 표시 — recording phase 전에 난 오류도 보인다', () => {
it('phase 없이 onError → 숨겨진 팁 창을 띄우고 오류 문구를 보낸다', async () => {
const { RecordingTipPresenter, createWindowRecordingTipView } = await import(
'../../../src/main/windows/recording-tip-presenter'
)
const presenter = new RecordingTipPresenter(createWindowRecordingTipView())
presenter.onSessionStarted()
presenter.onError('Cannot start dictation: caption mode active')
expect(state.windows).toHaveLength(1)
const tip = state.windows[0]
expect(tip.visible).toBe(true)
expect(tip.showInactive).toHaveBeenCalledTimes(1)
expect(tip.bounds).toHaveLength(1)
expect(tipStates(tip).at(-1)).toMatchObject({
state: 'error',
errorMessage: 'Cannot start dictation: caption mode active',
})
presenter.dispose()
})
it('STT 모델 미설치 코드는 사용자 언어 문구로 바꿔 그린다', async () => {
const wm = await import('../../../src/main/windows/WindowManager')
const { RecordingTipPresenter, createWindowRecordingTipView } = await import(
'../../../src/main/windows/recording-tip-presenter'
)
const presenter = new RecordingTipPresenter(createWindowRecordingTipView())
presenter.onError('STT model not installed: base', 'error', ErrorCode.STTModelNotFound)
const tip = wm.getRecordingTipWindow() as unknown as FakeWindow
expect(tip.visible).toBe(true)
expect(tipStates(tip).at(-1)).toMatchObject({
state: 'error',
errorMessage: getI18n('ko').t('voice.error.modelMissing'),
})
presenter.dispose()
})
it('녹음 도중 난 오류는 떠 있는 팁 자리를 옮기지 않는다', async () => {
const wm = await import('../../../src/main/windows/WindowManager')
wm.showRecordingTip('recording')
const tip = state.windows[0]
expect(tip.bounds).toHaveLength(1)
wm.showRecordingTipError('boom')
expect(tip.bounds).toHaveLength(1)
expect(tip.visible).toBe(true)
expect(tipStates(tip).map((m) => m.state)).toEqual(['recording', 'error'])
})
it('숨겨진 팁은 오류 때 다시 뜬다(이전 세션이 숨긴 뒤)', async () => {
const wm = await import('../../../src/main/windows/WindowManager')
wm.showRecordingTip('recording')
wm.hideRecordingTip()
const tip = state.windows[0]
expect(tip.visible).toBe(false)
wm.showRecordingTipError('boom')
expect(tip.visible).toBe(true)
expect(tip.showInactive).toHaveBeenCalledTimes(2)
expect(tipStates(tip).at(-1)).toMatchObject({ state: 'error', errorMessage: 'boom' })
})
})
describe('resolveRecordingTipErrorMessage', () => {
it('코드별로 번역 문구를 고르고, 모르는 코드는 원문을 그린다', async () => {
const { resolveRecordingTipErrorMessage } = await import('../../../src/main/windows/recording-tip-error-message')
const { t } = getI18n('ko')
expect(resolveRecordingTipErrorMessage(ErrorCode.STTModelNotFound, 'raw', t)).toBe(t('voice.error.modelMissing'))
expect(resolveRecordingTipErrorMessage(ErrorCode.QuotaExceeded, 'raw', t)).toBe(
t('license.quotaExceeded.title', { feature: t('license.feature.dictation') }),
)
// 2026-09-28: 받아쓰기가 실제로 내보내는 코드(자막 충돌·마이크·무음·전사 실패·다듬기 실패)는 전부 사용자 문구로
expect(resolveRecordingTipErrorMessage(ErrorCode.CaptionAlreadyActive, 'raw', t)).toBe(t('voice.error.captionActive'))
expect(resolveRecordingTipErrorMessage(ErrorCode.AudioCaptureStartFailed, 'raw', t)).toBe(t('voice.error.micOpen'))
expect(resolveRecordingTipErrorMessage(ErrorCode.AudioCaptureFailed, 'raw', t)).toBe(t('voice.error.micLost'))
expect(resolveRecordingTipErrorMessage(ErrorCode.STTNoAudioData, 'raw', t)).toBe(t('voice.error.noSpeech'))
expect(resolveRecordingTipErrorMessage(ErrorCode.STTTranscriptionFailed, 'raw', t)).toBe(t('voice.error.transcription'))
expect(resolveRecordingTipErrorMessage(ErrorCode.LLMProcessingFailed, 'raw', t)).toBe(t('voice.error.llmFailed'))
// 분류 밖의 코드와 코드 없음은 원문
expect(resolveRecordingTipErrorMessage(ErrorCode.TTSVoiceNotFound, 'raw', t)).toBe('raw')
expect(resolveRecordingTipErrorMessage(undefined, 'raw', t)).toBe('raw')
})
})
describe('애플리케이션 메뉴 — macOS 는 편집/종료 단축키용 role 메뉴를 유지한다', () => {
it('darwin 에서 메인 창을 만들면 editMenu 가 든 메뉴를 설치한다(null 아님)', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' })
const wm = await import('../../../src/main/windows/WindowManager')
wm.createMainWindow()
expect(state.setApplicationMenu).toHaveBeenCalledTimes(1)
const installed = state.setApplicationMenu.mock.calls[0][0] as { template: Array<{ role: string }> } | null
expect(installed).not.toBeNull()
expect(installed?.template.map((item) => item.role)).toEqual(['appMenu', 'editMenu', 'windowMenu'])
})
it('win32 에서는 지금처럼 메뉴를 제거한다(Alt 키 메뉴 활성화 방지)', async () => {
Object.defineProperty(process, 'platform', { value: 'win32' })
const wm = await import('../../../src/main/windows/WindowManager')
wm.createMainWindow()
expect(state.setApplicationMenu).toHaveBeenCalledWith(null)
expect(state.buildFromTemplate).not.toHaveBeenCalled()
})
it('buildApplicationMenuTemplate 는 darwin 만 템플릿을 준다', async () => {
const { buildApplicationMenuTemplate } = await import('../../../src/main/windows/app-menu')
expect(buildApplicationMenuTemplate('linux')).toBeNull()
expect(buildApplicationMenuTemplate('win32')).toBeNull()
expect(buildApplicationMenuTemplate('darwin')?.map((item) => item.role)).toEqual([
'appMenu',
'editMenu',
'windowMenu',
])
})
})

View file

@ -14,6 +14,7 @@ function fakeView(): RecordingTipView & { calls: string[] } {
showRecording: () => calls.push('show'),
hide: () => calls.push('hide'),
showThinking: () => calls.push('thinking'),
showDone: () => calls.push('done'),
showError: (message) => calls.push(`error:${message}`),
sendAudioLevel: (level) => calls.push(`level:${level}`),
sendPartialTranscript: (text) => calls.push(`partial:${text}`),
@ -91,4 +92,24 @@ describe('RecordingTipPresenter', () => {
vi.advanceTimersByTime(5000)
expect(view.calls).toEqual(['error:x'])
})
it('완료는 "입력함"을 잠깐 보이고 숨기며, 곧바로 시작한 새 세션의 팁은 숨기지 않는다', () => {
const view = fakeView()
const presenter = new RecordingTipPresenter(view)
presenter.onPhase('recording')
presenter.onSessionCompleted()
vi.advanceTimersByTime(699)
expect(view.calls).toEqual(['show', 'done'])
vi.advanceTimersByTime(1)
expect(view.calls).toEqual(['show', 'done', 'hide'])
const view2 = fakeView()
const presenter2 = new RecordingTipPresenter(view2)
presenter2.onSessionCompleted()
vi.advanceTimersByTime(300)
presenter2.onSessionStarted()
presenter2.onPhase('recording')
vi.advanceTimersByTime(2000)
expect(view2.calls).toEqual(['done', 'show'])
})
})

View file

@ -8,19 +8,21 @@ import { PauseIcon, PlayIcon } from './icons'
* (apps/desktop/src/renderer/popups/recording-tip)을 같은 수치로 재현한다.
*
* 대기 → 오른쪽 Alt 누른 채 말하기(캡슐·파형·경과 시간·부분 전사)
* → 키를 떼면 처리 중(점근 진행 막대) → 커서 자리에 다듬어진 문장 입력 → 다음 예시
* → 키를 떼면 받아쓰는 중(무한 진행 막대, 받아쓰던 문장은 흐리게 남김)
* → 커서 자리에 다듬어진 문장 입력 + 캡슐 "입력함" 잠깐 → 다음 예시
* 2026-09-28 앱 캡슐 v4 에 맞췄다: 판 모양(알약 아님)·단색 막대·입력 없으면 막대 정지·Esc 안내.
*
* WCAG 2.2.2: 5초 넘게 움직이므로 일시정지 버튼을 둔다. 화면 밖이거나 탭이 숨겨지면 멈춘다.
* 감소 모션이면 반복하지 않고 한 장면(말한 내용과 입력 결과)을 정지 화면으로 보여 준다.
*/
type Phase = 'idle' | 'listening' | 'thinking' | 'typed'
type Phase = 'idle' | 'listening' | 'thinking' | 'done' | 'typed'
// 앱 recording-tip/script.js 와 같은 값
const BAR_COUNT = 9
const BAR_INTERVAL_MS = 100
const BAR_MIN = 2
const BAR_MAX = 28
const BAR_MAX = 22
const SMOOTHING = 0.5
const RANDOM_FACTOR = 0.35
const BAR_WEIGHTS = Array.from({ length: BAR_COUNT }, (_, i) => {
@ -33,7 +35,9 @@ const IDLE_MS = 1400
const WORD_MS = 260
const LISTEN_TAIL_MS = 700
const THINK_MS = 1100
const TYPED_MS = 3200
/** 앱 recording-tip-presenter 의 doneHideMs 와 같다 */
const DONE_MS = 700
const TYPED_MS = 2500
/**
* 부분 전사가 나타나는 단위. 띄어 쓰는 언어는 낱말(뒤 공백 포함), 띄어 쓰지 않는
@ -65,7 +69,6 @@ export function AppLoopDemo() {
const rootRef = useRef<HTMLDivElement>(null)
const barRefs = useRef<(HTMLSpanElement | null)[]>([])
const progressRef = useRef<HTMLSpanElement>(null)
const timers = useRef<number[]>([])
const running = !reducedMotion && !userPaused && !offscreen
@ -113,7 +116,8 @@ export function AppLoopDemo() {
const listenStart = IDLE_MS
const listenEnd = listenStart + words.length * WORD_MS + LISTEN_TAIL_MS
const thinkEnd = listenEnd + THINK_MS
const loopEnd = thinkEnd + TYPED_MS
const doneEnd = thinkEnd + DONE_MS
const loopEnd = doneEnd + TYPED_MS
schedule(() => setPhase('listening'), listenStart)
words.forEach((_, i) => schedule(() => setWordCount(i + 1), listenStart + (i + 1) * WORD_MS))
@ -121,7 +125,8 @@ export function AppLoopDemo() {
schedule(() => setElapsed(s), listenStart + s)
}
schedule(() => setPhase('thinking'), listenEnd)
schedule(() => setPhase('typed'), thinkEnd)
schedule(() => setPhase('done'), thinkEnd)
schedule(() => setPhase('typed'), doneEnd)
schedule(() => setSampleIndex((i) => i + 1), loopEnd)
return clearTimers
@ -135,7 +140,8 @@ export function AppLoopDemo() {
const paint = (level: number, jitter: boolean) => {
bars.forEach((bar, i) => {
if (!bar) return
const base = Math.max(0.08, level) * BAR_MAX * BAR_WEIGHTS[i]
// 앱과 같이 입력이 0이면 바닥 — "살아있는 척" 최소 흔들림 없음
const base = level * BAR_MAX * BAR_WEIGHTS[i]
const randomized = jitter ? base * (1 + (Math.random() - 0.5) * 2 * RANDOM_FACTOR) : base
const target = Math.max(BAR_MIN, Math.min(BAR_MAX, randomized))
heights[i] = jitter ? heights[i] + (target - heights[i]) * SMOOTHING : target
@ -144,7 +150,7 @@ export function AppLoopDemo() {
}
if (!running || phase !== 'listening') {
paint(reducedMotion ? 0.7 : 0.08, false)
paint(reducedMotion ? 0.7 : 0, false)
return
}
let tick = 0
@ -157,21 +163,6 @@ export function AppLoopDemo() {
return () => window.clearInterval(id)
}, [phase, running, reducedMotion])
// 처리 중 진행 막대: min(95, (1 - 1/(1 + 1.5t)) * 100)%
useEffect(() => {
const bar = progressRef.current
if (!bar || phase !== 'thinking') return
const start = performance.now()
let raf = 0
const step = () => {
const sec = (performance.now() - start) / 1000
bar.style.width = `${Math.min(95, (1 - 1 / (1 + 1.5 * sec)) * 100)}%`
if (running) raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
return () => cancelAnimationFrame(raf)
}, [phase, running])
// 감소 모션: 한 장면을 정지 화면으로.
const shownPhase: Phase = reducedMotion ? 'typed' : phase
const capsulePhase: Phase = reducedMotion ? 'listening' : phase
@ -182,6 +173,7 @@ export function AppLoopDemo() {
idle: t.demo.stateIdle,
listening: t.demo.stateListening,
thinking: t.demo.statePolishing,
done: t.demo.stateDone,
typed: t.demo.stateDone,
}[reducedMotion ? 'typed' : phase]
@ -204,31 +196,39 @@ export function AppLoopDemo() {
<div className="demo-doc keep-ko">
<p className="text-neutral-400">{t.demo.docLine}</p>
<p className="mt-3 text-neutral-50">
{shownPhase === 'typed' && <span className="demo-typed">{sample.clean}</span>}
{(shownPhase === 'typed' || shownPhase === 'done') && <span className="demo-typed">{sample.clean}</span>}
<span className="demo-caret" />
</p>
</div>
{/* 녹음 캡슐 — 앱과 같은 자리(화면 아래 가운데)에 뜬다 */}
<div className={`demo-capsule-slot ${capsulePhase === 'listening' || capsulePhase === 'thinking' ? 'is-visible' : ''}`}>
<div className="capsule">
{capsulePhase === 'thinking' ? (
<div className="capsule-row">
<span className="capsule-progress"><span ref={progressRef} className="capsule-progress-bar" /></span>
<span className="capsule-thinking">{t.demo.processing}</span>
<div className={`demo-capsule-slot ${capsulePhase === 'listening' || capsulePhase === 'thinking' || capsulePhase === 'done' ? 'is-visible' : ''}`}>
<div className={`capsule is-${capsulePhase}`}>
{capsulePhase === 'done' ? (
<div className="capsule-row capsule-done">
<span className="capsule-done-icon">✓</span>
<span>{t.demo.inserted}</span>
</div>
) : (
<div className="capsule-recording">
<div className="capsule-row justify-center">
<>
<div className="capsule-row">
<span className="capsule-bars">
{BAR_WEIGHTS.map((_, i) => (
<span key={i} ref={(el) => { barRefs.current[i] = el }} className="capsule-bar" />
))}
</span>
<span className="capsule-duration">{formatDuration(reducedMotion ? 3000 : elapsed)}</span>
{capsulePhase === 'thinking' ? (
<span className="capsule-thinking">{t.demo.processing}</span>
) : (
<span className="capsule-duration">{formatDuration(reducedMotion ? 3000 : elapsed)}</span>
)}
<kbd className="capsule-cancel">{t.demo.cancelHint}</kbd>
</div>
{capsulePhase === 'thinking' && (
<span className="capsule-progress"><span className="capsule-progress-bar" /></span>
)}
{partial && <p className="capsule-partial keep-ko">{partial}</p>}
</div>
</>
)}
</div>
</div>

View file

@ -81,6 +81,10 @@ export interface Translations {
docLine: string
/** 캡슐의 처리 중 문구 */
processing: string
/** 캡슐 완료 표시 — 앱 recording-tip 의 done 상태 */
inserted: string
/** 캡슐 오른쪽 키 안내 */
cancelHint: string
stateIdle: string
stateListening: string
statePolishing: string

View file

@ -36,7 +36,9 @@ export const de: Translations = {
note: 'Ein vorbereitetes Beispiel, keine echte Aufnahme.',
windowTitle: 'Notizen',
docLine: 'Notizen zum Team-Meeting am Freitag',
processing: 'Wird verarbeitet...',
processing: 'Wird transkribiert',
inserted: 'Eingefügt',
cancelHint: 'Esc zum Abbrechen',
stateIdle: 'Wartet',
stateListening: 'Gedrückt halten und sprechen',
statePolishing: 'Losgelassen, wird bereinigt',

View file

@ -36,7 +36,9 @@ export const en: Translations = {
note: 'A scripted example, not a real recording.',
windowTitle: 'Notes',
docLine: 'Friday team meeting notes',
processing: 'Processing...',
processing: 'Transcribing',
inserted: 'Inserted',
cancelHint: 'Esc to cancel',
stateIdle: 'Waiting',
stateListening: 'Holding and speaking',
statePolishing: 'Released, cleaning up',

View file

@ -36,7 +36,9 @@ export const es: Translations = {
note: 'Un ejemplo guionado, no una grabación real.',
windowTitle: 'Notas',
docLine: 'Notas de la reunión de equipo del viernes',
processing: 'Procesando...',
processing: 'Transcribiendo',
inserted: 'Insertado',
cancelHint: 'Esc para cancelar',
stateIdle: 'Esperando',
stateListening: 'Manteniendo presionado y hablando',
statePolishing: 'Al soltar, limpiando',

View file

@ -36,7 +36,9 @@ export const fr: Translations = {
note: 'Un exemple scénarisé, pas un véritable enregistrement.',
windowTitle: 'Notes',
docLine: 'Notes de la réunion d\'équipe du vendredi',
processing: 'Traitement en cours...',
processing: 'Transcription',
inserted: 'Inséré',
cancelHint: 'Échap pour annuler',
stateIdle: 'En attente',
stateListening: 'Maintien et parole',
statePolishing: 'Relâché, mise en forme',

View file

@ -36,7 +36,9 @@ export const ja: Translations = {
note: '台本による例であり、実際の録音ではありません。',
windowTitle: 'メモ',
docLine: '金曜日のチーム会議メモ',
processing: '処理中...',
processing: '文字起こし中',
inserted: '入力しました',
cancelHint: 'Esc で取消',
stateIdle: '待機中',
stateListening: '押しながら話している',
statePolishing: '離すと整形',

View file

@ -36,7 +36,9 @@ export const ko: Translations = {
note: '실제 녹음이 아닌 예시 화면입니다.',
windowTitle: '메모',
docLine: '금요일 팀 회의 메모',
processing: '다듬는 중...',
processing: '받아쓰는 중',
inserted: '입력함',
cancelHint: 'Esc 취소',
stateIdle: '기다리는 중',
stateListening: '듣는 중',
statePolishing: '다듬는 중',

View file

@ -36,7 +36,9 @@ export const pt: Translations = {
note: 'Um exemplo roteirizado, não uma gravação real.',
windowTitle: 'Notas',
docLine: 'Notas da reunião de equipe de sexta-feira',
processing: 'Processando...',
processing: 'Transcrevendo',
inserted: 'Inserido',
cancelHint: 'Esc para cancelar',
stateIdle: 'Aguardando',
stateListening: 'Segurando e falando',
statePolishing: 'Solto, corrigindo',

View file

@ -36,7 +36,9 @@ export const ru: Translations = {
note: 'Заранее подготовленный пример, а не настоящая запись.',
windowTitle: 'Заметки',
docLine: 'Заметки с пятничной встречи команды',
processing: 'Обработка...',
processing: 'Расшифровка',
inserted: 'Вставлено',
cancelHint: 'Esc — отмена',
stateIdle: 'Ожидание',
stateListening: 'Зажато, говорю',
statePolishing: 'Отпущено, идёт очистка',

View file

@ -36,7 +36,9 @@ export const vi: Translations = {
note: 'Đây là ví dụ dựng sẵn, không phải bản ghi âm thật.',
windowTitle: 'Ghi chú',
docLine: 'Ghi chú họp nhóm thứ Sáu',
processing: 'Đang xử lý...',
processing: 'Đang chép lời',
inserted: 'Đã chèn',
cancelHint: 'Esc để hủy',
stateIdle: 'Đang chờ',
stateListening: 'Đang giữ và nói',
statePolishing: 'Đã thả, đang làm sạch',

View file

@ -36,7 +36,9 @@ export const zh: Translations = {
note: '这是脚本示例,并非真实录音。',
windowTitle: '备忘录',
docLine: '周五团队会议记录',
processing: '处理中...',
processing: '正在转写',
inserted: '已输入',
cancelHint: 'Esc 取消',
stateIdle: '等待中',
stateListening: '按住说话中',
statePolishing: '松开后润色中',

View file

@ -147,28 +147,23 @@
transform: none;
}
/* ── 녹음 캡슐 — 앱 recording-tip/style.css 와 같은 값 ─── */
/* ── 녹음 캡슐 — 앱 popups/recording-tip/style.css(v4, 2026-09-28)와 같은 값 ───
* 판 모양(radius 10)·단색 막대·입력 없으면 막대 정지·처리 중 무한 막대·완료 "입력함".
* 색은 앱 다크 테마 원시값(packages/ui/src/theme-raw.ts)과 같다 — 값이 바뀌면 함께 고친다. */
.capsule {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(15, 17, 22, 0.88);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.12);
backdrop-filter: blur(28px);
-webkit-backdrop-filter: blur(28px);
}
.capsule:has(.capsule-partial) {
border-radius: 18px;
}
.capsule-recording {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 160px;
max-width: 320px;
min-width: 168px;
max-width: 300px;
padding: 8px 12px;
border-radius: 10px;
border: 1px solid rgba(148, 180, 255, 0.12);
background: #111a30;
box-shadow: 0 12px 32px rgba(3, 7, 18, 0.55);
}
.capsule.is-listening {
border-color: rgba(148, 180, 255, 0.2);
}
.capsule-row {
display: flex;
@ -179,66 +174,88 @@
display: flex;
align-items: center;
gap: 3px;
height: 28px;
height: 22px;
}
.capsule-bar {
display: block;
width: 3px;
min-height: 3px;
min-height: 2px;
height: 2px;
border-radius: 999px;
border-radius: 2px;
background: #3b82f6;
transition: height 80ms ease-out;
}
.capsule-bar:nth-child(1) { background: #93c5fd; opacity: 0.85; }
.capsule-bar:nth-child(2) { background: #60a5fa; opacity: 0.9; }
.capsule-bar:nth-child(3) { background: #4f8dfa; opacity: 0.95; }
.capsule-bar:nth-child(4) { background: #3b82f6; }
.capsule-bar:nth-child(5) { background: #3b82f6; }
.capsule-bar:nth-child(6) { background: #5b75f7; }
.capsule-bar:nth-child(7) { background: #6e71f7; opacity: 0.95; }
.capsule-bar:nth-child(8) { background: #818cf8; opacity: 0.9; }
.capsule-bar:nth-child(9) { background: #a78bfa; opacity: 0.85; }
.capsule.is-thinking .capsule-bar {
background: #7e8eb0;
}
.capsule-duration {
min-width: 34px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
min-width: 32px;
font-size: 13px;
font-weight: 600;
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
color: #f4f4f5;
}
.capsule-partial {
display: -webkit-box;
max-height: 38px;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 6px;
font-size: 12px;
line-height: 1.45;
color: #f4f4f5;
}
.capsule-progress {
display: block;
width: 120px;
height: 4px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
}
.capsule-progress-bar {
display: block;
width: 0;
height: 100%;
border-radius: 999px;
background: #3b82f6;
transition: width 100ms linear;
color: #eef2fb;
}
.capsule-thinking {
flex: 1;
font-size: 12px;
font-weight: 500;
color: #a1a1aa;
color: #93a4c8;
white-space: nowrap;
}
.capsule-cancel {
margin-left: auto;
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 5px;
border: 1px solid rgba(148, 180, 255, 0.2);
border-radius: 4px;
font-size: 11px;
line-height: 1;
color: #7e8eb0;
white-space: nowrap;
}
.capsule-progress {
position: relative;
display: block;
height: 2px;
overflow: hidden;
border-radius: 1px;
background: rgba(148, 180, 255, 0.12);
}
.capsule-progress-bar {
position: absolute;
top: 0;
bottom: 0;
width: 36%;
border-radius: 1px;
background: #3b82f6;
animation: capsule-indeterminate 1.1s cubic-bezier(0.22, 1, 0.36, 1) infinite;
}
@keyframes capsule-indeterminate {
from { left: -36%; }
to { left: 100%; }
}
.capsule-partial {
display: flex;
flex-direction: column;
justify-content: flex-end;
max-height: 38px;
overflow: hidden;
border-top: 1px solid rgba(148, 180, 255, 0.07);
padding-top: 6px;
font-size: 12px;
line-height: 1.55;
color: #eef2fb;
}
.capsule.is-thinking .capsule-partial {
color: #7e8eb0;
}
.capsule-done {
font-size: 12px;
color: #eef2fb;
}
.capsule-done-icon {
color: #34d399;
}
/* ── Grid Background Pattern (히어로) ─────────────────── */