From 7b1210b5d1fe803ae9564aaa075fa03252d6d9bd Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 20:46:24 +0900 Subject: [PATCH] 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). --- apps/desktop/src/main/bootstrap.ts | 44 ++- .../src/main/services/VoiceModeService.ts | 114 +++---- .../desktop/src/main/windows/WindowManager.ts | 69 +++-- .../src/main/windows/popup-list-geometry.ts | 34 +++ .../src/main/windows/popup-navigation.ts | 2 + .../src/main/windows/popup-window-factory.ts | 12 +- .../windows/recording-tip-error-message.ts | 35 +++ .../main/windows/recording-tip-presenter.ts | 40 ++- .../renderer/popups/_shared/list-popup.css | 101 +++++++ .../renderer/popups/_shared/popup-base.css | 146 +++++++++ .../renderer/popups/caption-overlay/style.css | 88 +++--- .../renderer/popups/command-popup/index.html | 16 +- .../renderer/popups/command-popup/script.js | 197 +++++------- .../renderer/popups/command-popup/style.css | 172 +---------- .../renderer/popups/history-popup/index.html | 14 +- .../renderer/popups/history-popup/script.js | 134 +++------ .../renderer/popups/history-popup/style.css | 140 +-------- .../renderer/popups/recording-tip/index.html | 43 +-- .../renderer/popups/recording-tip/script.js | 223 +++++++------- .../renderer/popups/recording-tip/style.css | 232 +++++++------- .../renderer/popups/result-popup/index.html | 20 +- .../renderer/popups/result-popup/script.js | 80 ++--- .../renderer/popups/result-popup/style.css | 159 +++++----- .../popups/suggestion-overlay/script.js | 10 +- .../popups/suggestion-overlay/style.css | 15 +- .../main/windows/popup-redteam-r2-3.test.ts | 4 + ...ording-tip-error-menu-redteam-r3-4.test.ts | 282 ++++++++++++++++++ .../windows/recording-tip-presenter.test.ts | 21 ++ site/src/components/AppLoopDemo.tsx | 70 ++--- site/src/i18n/index.ts | 4 + site/src/i18n/locales/de.ts | 4 +- site/src/i18n/locales/en.ts | 4 +- site/src/i18n/locales/es.ts | 4 +- site/src/i18n/locales/fr.ts | 4 +- site/src/i18n/locales/ja.ts | 4 +- site/src/i18n/locales/ko.ts | 4 +- site/src/i18n/locales/pt.ts | 4 +- site/src/i18n/locales/ru.ts | 4 +- site/src/i18n/locales/vi.ts | 4 +- site/src/i18n/locales/zh.ts | 4 +- site/src/index.css | 147 +++++---- 41 files changed, 1565 insertions(+), 1143 deletions(-) create mode 100644 apps/desktop/src/main/windows/popup-list-geometry.ts create mode 100644 apps/desktop/src/main/windows/recording-tip-error-message.ts create mode 100644 apps/desktop/src/renderer/popups/_shared/list-popup.css create mode 100644 apps/desktop/src/renderer/popups/_shared/popup-base.css create mode 100644 apps/desktop/tests/main/windows/recording-tip-error-menu-redteam-r3-4.test.ts diff --git a/apps/desktop/src/main/bootstrap.ts b/apps/desktop/src/main/bootstrap.ts index 906d19c..6eda7cf 100644 --- a/apps/desktop/src/main/bootstrap.ts +++ b/apps/desktop/src/main/bootstrap.ts @@ -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 { 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 { // 제안이 켜져 있으면 켜진 시점부터 모델을 미리 올려 둔다 — 토글 시에만 워밍업하면 // 앱을 새로 켤 때마다 첫 제안이 콜드 로딩으로 타임아웃된다. - 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 { async function initPopupWindows(): Promise { 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 { 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 { 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) }) } diff --git a/apps/desktop/src/main/services/VoiceModeService.ts b/apps/desktop/src/main/services/VoiceModeService.ts index cd4d695..316238b 100644 --- a/apps/desktop/src/main/services/VoiceModeService.ts +++ b/apps/desktop/src/main/services/VoiceModeService.ts @@ -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 { 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 { - if (!pcmData || pcmData.length === 0) return - + private async _insertCompletedText( + sessionId: string, + finalText: string, + selectionCapture: Promise | null, + ): Promise { + 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() diff --git a/apps/desktop/src/main/windows/WindowManager.ts b/apps/desktop/src/main/windows/WindowManager.ts index 736680c..2ea7bb3 100644 --- a/apps/desktop/src/main/windows/WindowManager.ts +++ b/apps/desktop/src/main/windows/WindowManager.ts @@ -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 { +/** 현재 설정 언어의 번역 함수 — 팝업(Vanilla JS)에 넘길 문구를 main 에서 만들 때 쓴다 */ +export function getPopupTranslator(): ReturnType['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 { + 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 { 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>): 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>, 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) diff --git a/apps/desktop/src/main/windows/popup-list-geometry.ts b/apps/desktop/src/main/windows/popup-list-geometry.ts new file mode 100644 index 0000000..80f71ae --- /dev/null +++ b/apps/desktop/src/main/windows/popup-list-geometry.ts @@ -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) } +} diff --git a/apps/desktop/src/main/windows/popup-navigation.ts b/apps/desktop/src/main/windows/popup-navigation.ts index 492a44a..6a7a61f 100644 --- a/apps/desktop/src/main/windows/popup-navigation.ts +++ b/apps/desktop/src/main/windows/popup-navigation.ts @@ -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' }, ] /** 히스토리 팝업이 사라진 뒤 삽입까지 기다리는 시간(포커스 복귀 대기) */ diff --git a/apps/desktop/src/main/windows/popup-window-factory.ts b/apps/desktop/src/main/windows/popup-window-factory.ts index 7c61cc9..c4348e5 100644 --- a/apps/desktop/src/main/windows/popup-window-factory.ts +++ b/apps/desktop/src/main/windows/popup-window-factory.ts @@ -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 보류 ───────────────────────────── diff --git a/apps/desktop/src/main/windows/recording-tip-error-message.ts b/apps/desktop/src/main/windows/recording-tip-error-message.ts new file mode 100644 index 0000000..ae0b3c4 --- /dev/null +++ b/apps/desktop/src/main/windows/recording-tip-error-message.ts @@ -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 + +const MESSAGE_KEY: Record, 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]) +} diff --git a/apps/desktop/src/main/windows/recording-tip-presenter.ts b/apps/desktop/src/main/windows/recording-tip-presenter.ts index 9367f1b..b886cf6 100644 --- a/apps/desktop/src/main/windows/recording-tip-presenter.ts +++ b/apps/desktop/src/main/windows/recording-tip-presenter.ts @@ -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 | 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), diff --git a/apps/desktop/src/renderer/popups/_shared/list-popup.css b/apps/desktop/src/renderer/popups/_shared/list-popup.css new file mode 100644 index 0000000..f6fbc29 --- /dev/null +++ b/apps/desktop/src/renderer/popups/_shared/list-popup.css @@ -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; +} diff --git a/apps/desktop/src/renderer/popups/_shared/popup-base.css b/apps/desktop/src/renderer/popups/_shared/popup-base.css new file mode 100644 index 0000000..ea1b4cf --- /dev/null +++ b/apps/desktop/src/renderer/popups/_shared/popup-base.css @@ -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; + } +} diff --git a/apps/desktop/src/renderer/popups/caption-overlay/style.css b/apps/desktop/src/renderer/popups/caption-overlay/style.css index b286316..3678dd8 100644 --- a/apps/desktop/src/renderer/popups/caption-overlay/style.css +++ b/apps/desktop/src/renderer/popups/caption-overlay/style.css @@ -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; diff --git a/apps/desktop/src/renderer/popups/command-popup/index.html b/apps/desktop/src/renderer/popups/command-popup/index.html index 996a62f..8917520 100644 --- a/apps/desktop/src/renderer/popups/command-popup/index.html +++ b/apps/desktop/src/renderer/popups/command-popup/index.html @@ -8,14 +8,14 @@
-
-
COMMAND SELECT
-
-
- ↑↓ 선택 - ⏎ 적용 - 0 해제 - ESC × +
+
+
+
diff --git a/apps/desktop/src/renderer/popups/command-popup/script.js b/apps/desktop/src/renderer/popups/command-popup/script.js index d41255b..29924d1 100644 --- a/apps/desktop/src/renderer/popups/command-popup/script.js +++ b/apps/desktop/src/renderer/popups/command-popup/script.js @@ -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) } }) })() diff --git a/apps/desktop/src/renderer/popups/command-popup/style.css b/apps/desktop/src/renderer/popups/command-popup/style.css index bffb8bf..5a5e09a 100644 --- a/apps/desktop/src/renderer/popups/command-popup/style.css +++ b/apps/desktop/src/renderer/popups/command-popup/style.css @@ -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); } diff --git a/apps/desktop/src/renderer/popups/history-popup/index.html b/apps/desktop/src/renderer/popups/history-popup/index.html index 0a461e5..48a0bd0 100644 --- a/apps/desktop/src/renderer/popups/history-popup/index.html +++ b/apps/desktop/src/renderer/popups/history-popup/index.html @@ -8,12 +8,14 @@
-
-
-
- ↑↓ Select - ⏎ Paste - ESC × +
+
+
+
diff --git a/apps/desktop/src/renderer/popups/history-popup/script.js b/apps/desktop/src/renderer/popups/history-popup/script.js index 249d338..196bc9c 100644 --- a/apps/desktop/src/renderer/popups/history-popup/script.js +++ b/apps/desktop/src/renderer/popups/history-popup/script.js @@ -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) } }) })() diff --git a/apps/desktop/src/renderer/popups/history-popup/style.css b/apps/desktop/src/renderer/popups/history-popup/style.css index 06b2939..fc4329e 100644 --- a/apps/desktop/src/renderer/popups/history-popup/style.css +++ b/apps/desktop/src/renderer/popups/history-popup/style.css @@ -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'; diff --git a/apps/desktop/src/renderer/popups/recording-tip/index.html b/apps/desktop/src/renderer/popups/recording-tip/index.html index 623064c..4b71720 100644 --- a/apps/desktop/src/renderer/popups/recording-tip/index.html +++ b/apps/desktop/src/renderer/popups/recording-tip/index.html @@ -8,29 +8,30 @@
-
- -
-
-
- 0:00 -
- - + +
+ +
+ + 0:00 + + +
+ + + + + + + - - - - -
diff --git a/apps/desktop/src/renderer/popups/recording-tip/script.js b/apps/desktop/src/renderer/popups/recording-tip/script.js index 9bb5a80..f0e143b 100644 --- a/apps/desktop/src/renderer/popups/recording-tip/script.js +++ b/apps/desktop/src/renderer/popups/recording-tip/script.js @@ -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() diff --git a/apps/desktop/src/renderer/popups/recording-tip/style.css b/apps/desktop/src/renderer/popups/recording-tip/style.css index 475266b..9cb1e2f 100644 --- a/apps/desktop/src/renderer/popups/recording-tip/style.css +++ b/apps/desktop/src/renderer/popups/recording-tip/style.css @@ -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; } diff --git a/apps/desktop/src/renderer/popups/result-popup/index.html b/apps/desktop/src/renderer/popups/result-popup/index.html index 6457d33..4b8dff0 100644 --- a/apps/desktop/src/renderer/popups/result-popup/index.html +++ b/apps/desktop/src/renderer/popups/result-popup/index.html @@ -8,17 +8,27 @@
-
+ +
+
+ + +
-
-
diff --git a/apps/desktop/src/renderer/popups/result-popup/script.js b/apps/desktop/src/renderer/popups/result-popup/script.js index 070053d..5fe8776 100644 --- a/apps/desktop/src/renderer/popups/result-popup/script.js +++ b/apps/desktop/src/renderer/popups/result-popup/script.js @@ -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) diff --git a/apps/desktop/src/renderer/popups/result-popup/style.css b/apps/desktop/src/renderer/popups/result-popup/style.css index 2123d26..602b07d 100644 --- a/apps/desktop/src/renderer/popups/result-popup/style.css +++ b/apps/desktop/src/renderer/popups/result-popup/style.css @@ -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; } diff --git a/apps/desktop/src/renderer/popups/suggestion-overlay/script.js b/apps/desktop/src/renderer/popups/suggestion-overlay/script.js index b052a36..874609c 100644 --- a/apps/desktop/src/renderer/popups/suggestion-overlay/script.js +++ b/apps/desktop/src/renderer/popups/suggestion-overlay/script.js @@ -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) diff --git a/apps/desktop/src/renderer/popups/suggestion-overlay/style.css b/apps/desktop/src/renderer/popups/suggestion-overlay/style.css index 5361d4d..e9a4b3c 100644 --- a/apps/desktop/src/renderer/popups/suggestion-overlay/style.css +++ b/apps/desktop/src/renderer/popups/suggestion-overlay/style.css @@ -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; } diff --git a/apps/desktop/tests/main/windows/popup-redteam-r2-3.test.ts b/apps/desktop/tests/main/windows/popup-redteam-r2-3.test.ts index fe6f83e..3b230c7 100644 --- a/apps/desktop/tests/main/windows/popup-redteam-r2-3.test.ts +++ b/apps/desktop/tests/main/windows/popup-redteam-r2-3.test.ts @@ -15,6 +15,7 @@ interface FakeWebContents { send: (channel: string, data: unknown) => void isDestroyed: () => boolean insertCSS: () => Promise + executeJavaScript: () => Promise 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: { diff --git a/apps/desktop/tests/main/windows/recording-tip-error-menu-redteam-r3-4.test.ts b/apps/desktop/tests/main/windows/recording-tip-error-menu-redteam-r3-4.test.ts new file mode 100644 index 0000000..2adc2c2 --- /dev/null +++ b/apps/desktop/tests/main/windows/recording-tip-error-menu-redteam-r3-4.test.ts @@ -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 + visible: boolean + bounds: Array<{ x: number; y: number; width: number; height: number }> + sent: Array<{ channel: string; data: unknown }> + contentsListeners: Map + showInactive: ReturnType +} + +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 + visible = false + destroyed = false + bounds: Array<{ x: number; y: number; width: number; height: number }> = [] + sent: Array<{ channel: string; data: unknown }> = [] + listeners = new Map() + contentsListeners = new Map() + 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) { + 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 = { 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', + ]) + }) +}) diff --git a/apps/desktop/tests/main/windows/recording-tip-presenter.test.ts b/apps/desktop/tests/main/windows/recording-tip-presenter.test.ts index 6c0f06a..fe2a25b 100644 --- a/apps/desktop/tests/main/windows/recording-tip-presenter.test.ts +++ b/apps/desktop/tests/main/windows/recording-tip-presenter.test.ts @@ -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']) + }) }) diff --git a/site/src/components/AppLoopDemo.tsx b/site/src/components/AppLoopDemo.tsx index 3756baf..c08461d 100644 --- a/site/src/components/AppLoopDemo.tsx +++ b/site/src/components/AppLoopDemo.tsx @@ -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(null) const barRefs = useRef<(HTMLSpanElement | null)[]>([]) - const progressRef = useRef(null) const timers = useRef([]) 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() {

{t.demo.docLine}

- {shownPhase === 'typed' && {sample.clean}} + {(shownPhase === 'typed' || shownPhase === 'done') && {sample.clean}}

{/* 녹음 캡슐 — 앱과 같은 자리(화면 아래 가운데)에 뜬다 */} -
-
- {capsulePhase === 'thinking' ? ( -
- - {t.demo.processing} +
+
+ {capsulePhase === 'done' ? ( +
+ ✓ + {t.demo.inserted}
) : ( -
-
+ <> +
{BAR_WEIGHTS.map((_, i) => ( { barRefs.current[i] = el }} className="capsule-bar" /> ))} - {formatDuration(reducedMotion ? 3000 : elapsed)} + {capsulePhase === 'thinking' ? ( + {t.demo.processing} + ) : ( + {formatDuration(reducedMotion ? 3000 : elapsed)} + )} + {t.demo.cancelHint}
+ {capsulePhase === 'thinking' && ( + + )} {partial &&

{partial}

} -
+ )}
diff --git a/site/src/i18n/index.ts b/site/src/i18n/index.ts index 5a68081..3cf7cad 100644 --- a/site/src/i18n/index.ts +++ b/site/src/i18n/index.ts @@ -81,6 +81,10 @@ export interface Translations { docLine: string /** 캡슐의 처리 중 문구 */ processing: string + /** 캡슐 완료 표시 — 앱 recording-tip 의 done 상태 */ + inserted: string + /** 캡슐 오른쪽 키 안내 */ + cancelHint: string stateIdle: string stateListening: string statePolishing: string diff --git a/site/src/i18n/locales/de.ts b/site/src/i18n/locales/de.ts index fd0c68f..d431577 100644 --- a/site/src/i18n/locales/de.ts +++ b/site/src/i18n/locales/de.ts @@ -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', diff --git a/site/src/i18n/locales/en.ts b/site/src/i18n/locales/en.ts index ff1aad9..ec6ca3d 100644 --- a/site/src/i18n/locales/en.ts +++ b/site/src/i18n/locales/en.ts @@ -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', diff --git a/site/src/i18n/locales/es.ts b/site/src/i18n/locales/es.ts index cb33656..0740b21 100644 --- a/site/src/i18n/locales/es.ts +++ b/site/src/i18n/locales/es.ts @@ -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', diff --git a/site/src/i18n/locales/fr.ts b/site/src/i18n/locales/fr.ts index df21af4..2564278 100644 --- a/site/src/i18n/locales/fr.ts +++ b/site/src/i18n/locales/fr.ts @@ -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', diff --git a/site/src/i18n/locales/ja.ts b/site/src/i18n/locales/ja.ts index 6ffcd9f..d769ef7 100644 --- a/site/src/i18n/locales/ja.ts +++ b/site/src/i18n/locales/ja.ts @@ -36,7 +36,9 @@ export const ja: Translations = { note: '台本による例であり、実際の録音ではありません。', windowTitle: 'メモ', docLine: '金曜日のチーム会議メモ', - processing: '処理中...', + processing: '文字起こし中', + inserted: '入力しました', + cancelHint: 'Esc で取消', stateIdle: '待機中', stateListening: '押しながら話している', statePolishing: '離すと整形', diff --git a/site/src/i18n/locales/ko.ts b/site/src/i18n/locales/ko.ts index d4fc7eb..1d46837 100644 --- a/site/src/i18n/locales/ko.ts +++ b/site/src/i18n/locales/ko.ts @@ -36,7 +36,9 @@ export const ko: Translations = { note: '실제 녹음이 아닌 예시 화면입니다.', windowTitle: '메모', docLine: '금요일 팀 회의 메모', - processing: '다듬는 중...', + processing: '받아쓰는 중', + inserted: '입력함', + cancelHint: 'Esc 취소', stateIdle: '기다리는 중', stateListening: '듣는 중', statePolishing: '다듬는 중', diff --git a/site/src/i18n/locales/pt.ts b/site/src/i18n/locales/pt.ts index 9260e1b..83376a6 100644 --- a/site/src/i18n/locales/pt.ts +++ b/site/src/i18n/locales/pt.ts @@ -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', diff --git a/site/src/i18n/locales/ru.ts b/site/src/i18n/locales/ru.ts index 757d18c..b986e38 100644 --- a/site/src/i18n/locales/ru.ts +++ b/site/src/i18n/locales/ru.ts @@ -36,7 +36,9 @@ export const ru: Translations = { note: 'Заранее подготовленный пример, а не настоящая запись.', windowTitle: 'Заметки', docLine: 'Заметки с пятничной встречи команды', - processing: 'Обработка...', + processing: 'Расшифровка', + inserted: 'Вставлено', + cancelHint: 'Esc — отмена', stateIdle: 'Ожидание', stateListening: 'Зажато, говорю', statePolishing: 'Отпущено, идёт очистка', diff --git a/site/src/i18n/locales/vi.ts b/site/src/i18n/locales/vi.ts index f73813d..c8350b1 100644 --- a/site/src/i18n/locales/vi.ts +++ b/site/src/i18n/locales/vi.ts @@ -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', diff --git a/site/src/i18n/locales/zh.ts b/site/src/i18n/locales/zh.ts index 6ebbb59..9616a36 100644 --- a/site/src/i18n/locales/zh.ts +++ b/site/src/i18n/locales/zh.ts @@ -36,7 +36,9 @@ export const zh: Translations = { note: '这是脚本示例,并非真实录音。', windowTitle: '备忘录', docLine: '周五团队会议记录', - processing: '处理中...', + processing: '正在转写', + inserted: '已输入', + cancelHint: 'Esc 取消', stateIdle: '等待中', stateListening: '按住说话中', statePolishing: '松开后润色中', diff --git a/site/src/index.css b/site/src/index.css index 2551d2e..056457b 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -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 (히어로) ─────────────────── */