refactor(popup): 5종 팝업 _i18n 주입 + 색상 토큰화 (WS-POPUP)

- 4종 팝업(command/history/result/recording-tip) _i18n 주입:
  WindowManager getPopupI18nStrings() + 팝업 script applyI18nToHtml()
- 한국어/영어 하드코딩 -> popup.* i18n 키 (ko/en 17키)
- theme-vars.ts에 --d3-wave-1~9, --d3-status-error/success 토큰 추가 (6 테마)
- 팝업 style.css hex -> CSS var 토큰
- packages/i18n getI18n() (main 프로세스용) 추가
SKIP: caption-overlay _i18n(별도 IPC 구조), wave box-shadow 보간(opacity 단순화)
정책: docs/REFACTOR_POLICY.md DP3, 이식 인사이트 P5
This commit is contained in:
Yun Chan 2026-07-22 02:37:58 +09:00
parent 660e622a93
commit 00a99e4087
15 changed files with 256 additions and 35 deletions

View file

@ -10,6 +10,7 @@ import { getLogger } from '../services/LoggerService'
import { getIsQuitting } from '../lifecycle'
import { configGet } from '../services/ConfigService'
import { buildPopupThemeCss } from '@d3ro/ui/theme-vars'
import { getI18n } from '@d3ro/i18n'
const logger = getLogger('WindowManager')
@ -28,6 +29,39 @@ function injectPopupTheme(win: BrowserWindow): void {
})
}
// ── 팝업 i18n 문자열 가져오기 ─────────────────────────────
/**
* i18n .
*/
function getPopupI18nStrings(): Record<string, string> {
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 {
// 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'),
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'),
// recording-tip
errorDefault: t('popup.error.default'),
// caption-overlay
captionLoading: t('popup.caption.loading'),
}
}
// ── 윈도우 참조 ───────────────────────────────────────
let mainWindow: BrowserWindow | null = null
@ -181,7 +215,7 @@ export function showRecordingTip(
win.setBounds({ x, y, width: TIP_WIDTH, height: TIP_HEIGHT })
// 상태 전송 후 즉시 show (2-phase 제거 — 숨겨진 윈도우의 렌더러 비활성 문제 방지)
win.webContents.send(IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, ...params })
win.webContents.send(IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params })
if (!win.isVisible()) {
win.showInactive()
}
@ -198,7 +232,7 @@ export function updateRecordingTipState(
params?: { text?: string; errorMessage?: string }
): void {
if (recordingTipWindow && !recordingTipWindow.isDestroyed()) {
recordingTipWindow.webContents.send(IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, ...params })
recordingTipWindow.webContents.send(IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, { state, _i18n: getPopupI18nStrings(), ...params })
}
}
@ -268,7 +302,7 @@ export function showResultPopup(text: string, autoHideMs = 5000): void {
const win = getResultPopupWindow()
// Phase 1: prepare
win.webContents.send(IPC_CHANNELS.POPUP_RESULT.PREPARE, { text })
win.webContents.send(IPC_CHANNELS.POPUP_RESULT.PREPARE, { text, _i18n: getPopupI18nStrings() })
const handler = (_event: Electron.IpcMainEvent, data: { width: number; height: number }) => {
ipcMain.removeListener(IPC_CHANNELS.POPUP_RESULT.MEASURED, handler)
@ -369,7 +403,7 @@ export function showHistoryPopup(entries: Array<Record<string, unknown>>): void
win.setBounds({ x, y, width: popupWidth, height: popupHeight })
win.webContents.send(IPC_CHANNELS.POPUP_HISTORY.SHOW_ITEMS, { entries })
win.webContents.send(IPC_CHANNELS.POPUP_HISTORY.SHOW_ITEMS, { entries, _i18n: getPopupI18nStrings() })
if (!win.isVisible()) {
win.showInactive()
@ -456,7 +490,7 @@ export function showCommandPopup(commands: Array<Record<string, unknown>>, activ
if (y < display.workArea.y) { y = cursorPos.y + 20 }
win.setBounds({ x, y, width: popupWidth, height: popupHeight })
win.webContents.send(IPC_CHANNELS.POPUP_COMMAND.SHOW_ITEMS, { commands, activeId })
win.webContents.send(IPC_CHANNELS.POPUP_COMMAND.SHOW_ITEMS, { commands, activeId, _i18n: getPopupI18nStrings() })
if (!win.isVisible()) { win.showInactive() }
win.webContents.send(IPC_CHANNELS.POPUP_COMMAND.SHOW, {})

View file

@ -17,6 +17,9 @@
var linesContainer = document.getElementById('lines')
var container = document.getElementById('container')
// ── i18n ──────────────────────────────────────────────
var i18nStrings = {}
// ── 상태 ──────────────────────────────────────────────
/** @type {Array<{el: HTMLElement, timer: number|null, id: string}>} */
var lines = []
@ -170,13 +173,14 @@
// 상태 변경
window.popupAPI.on('caption:stateChanged', function (data) {
if (data._i18n) i18nStrings = data._i18n
if (data.state === 'starting') {
// 로딩 표시
clearAllLines()
var loadingEl = document.createElement('div')
loadingEl.className = 'caption-line loading'
loadingEl.id = 'caption-loading'
loadingEl.textContent = '⏳ Loading STT model...'
loadingEl.textContent = i18nStrings.captionLoading || '⏳ Loading STT model...'
loadingEl.style.fontSize = config.fontSize + 'px'
linesContainer.appendChild(loadingEl)
} else if (data.state === 'active') {

View file

@ -9,13 +9,13 @@
<body>
<div id="root">
<div id="container" class="command-popup">
<div id="title" class="title">COMMAND SELECT</div>
<div id="title" class="title" data-i18n="commandSelect">COMMAND SELECT</div>
<div id="items" class="items"></div>
<div class="hints">
<span>↑↓ 선택</span>
<span>⏎ 적용</span>
<span>0 해제</span>
<span>ESC ×</span>
<span data-i18n="hintsSelect">↑↓ 선택</span>
<span data-i18n="hintsApply">⏎ 적용</span>
<span data-i18n="hintsClear">0 해제</span>
<span data-i18n="hintsClose">ESC ×</span>
</div>
</div>
</div>

View file

@ -13,6 +13,17 @@
var currentActiveId = null
var i18nStrings = {}
// ── 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]
}
})
}
// ── 아이템 렌더링 ───────────────────────────────────
function renderItems(commands, activeId) {
currentCommands = commands
@ -135,6 +146,7 @@
window.popupAPI.on('command:showItems', function (data) {
selectedIndex = 0
if (data._i18n) i18nStrings = data._i18n
applyI18nToHtml()
renderItems(data.commands || [], data.activeId || null)
container.classList.remove('hiding')
})

View file

@ -11,9 +11,9 @@
<div id="container" class="history-popup">
<div id="items" class="items"></div>
<div class="hints">
<span>↑↓ Select</span>
<span>⏎ Paste</span>
<span>ESC ×</span>
<span data-i18n="hintsSelectEn">↑↓ Select</span>
<span data-i18n="hintsPaste">⏎ Paste</span>
<span data-i18n="hintsClose">ESC ×</span>
</div>
</div>
</div>

View file

@ -10,18 +10,30 @@
var items = []
var selectedIndex = 0
var currentEntries = []
var i18nStrings = {}
// ── 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]
}
})
}
// ── 상대 시간 표시 ───────────────────────────────────
function relativeTime(timestamp) {
var diff = Date.now() - timestamp
var sec = Math.floor(diff / 1000)
if (sec < 60) return 'just now'
if (sec < 60) return i18nStrings.timeJustNow || 'just now'
var min = Math.floor(sec / 60)
if (min < 60) return min + 'm ago'
if (min < 60) return (i18nStrings.timeMinutesAgo || '{{m}}m ago').replace('{{m}}', min)
var hr = Math.floor(min / 60)
if (hr < 24) return hr + 'h ago'
if (hr < 24) return (i18nStrings.timeHoursAgo || '{{h}}h ago').replace('{{h}}', hr)
var day = Math.floor(hr / 24)
return day + 'd ago'
return (i18nStrings.timeDaysAgo || '{{d}}d ago').replace('{{d}}', day)
}
// ── 텍스트 truncate ──────────────────────────────────
@ -39,7 +51,7 @@
if (entries.length === 0) {
var empty = document.createElement('div')
empty.className = 'empty-state'
empty.textContent = 'No history yet'
empty.textContent = i18nStrings.noHistory || 'No history yet'
itemsContainer.appendChild(empty)
return
}
@ -119,6 +131,8 @@
// ── IPC 리스너 ───────────────────────────────────────
window.popupAPI.on('history:showItems', function (data) {
selectedIndex = 0
if (data._i18n) i18nStrings = data._i18n
applyI18nToHtml()
renderItems(data.entries || [])
container.classList.remove('hiding')
measureAndReport()

View file

@ -42,6 +42,7 @@
var thinkingStartTime = 0
var thinkingRaf = null
var currentState = 'idle'
var i18nStrings = {}
// ── 웨이브 바 생성 ───────────────────────────────────
function createWaveBars() {
@ -133,7 +134,7 @@
currentState = 'error'
hideAllViews()
errorView.classList.remove('hidden')
errorText.textContent = message || '오류가 발생했습니다'
errorText.textContent = message || i18nStrings.errorDefault || '오류가 발생했습니다'
}
// ── 크기 측정 (2-phase 리사이즈) ────────────────────
@ -153,6 +154,7 @@
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()
@ -184,6 +186,7 @@
// 상태 변경 (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()

View file

@ -64,16 +64,16 @@ body {
min-height: 2px;
}
/* 9바 시안→블루→퍼플 스펙트럼 (v2 Midnight — RAW.dark gradient.wave1~3 보간) */
.wave-bar:nth-child(1) { background: #22d3ee; box-shadow: 0 0 6px rgba(34, 211, 238, 0.35); }
.wave-bar:nth-child(2) { background: #28bef0; box-shadow: 0 0 6px rgba(40, 190, 240, 0.35); }
.wave-bar:nth-child(3) { background: #2fa9f2; box-shadow: 0 0 6px rgba(47, 169, 242, 0.35); }
.wave-bar:nth-child(4) { background: #3596f4; box-shadow: 0 0 6px rgba(53, 150, 244, 0.35); }
.wave-bar:nth-child(5) { background: #3b82f6; box-shadow: 0 0 6px rgba(59, 130, 246, 0.35); }
.wave-bar:nth-child(6) { background: #4f78f6; box-shadow: 0 0 6px rgba(79, 120, 246, 0.35); }
.wave-bar:nth-child(7) { background: #636ff6; box-shadow: 0 0 6px rgba(99, 111, 246, 0.35); }
.wave-bar:nth-child(8) { background: #7765f6; box-shadow: 0 0 6px rgba(119, 101, 246, 0.35); }
.wave-bar:nth-child(9) { background: #8b5cf6; box-shadow: 0 0 6px rgba(139, 92, 246, 0.35); }
/* 9바 시안→블루→퍼플 스펙트럼 (v2 Midnight — 테마 변수 사용) */
.wave-bar:nth-child(1) { background: var(--d3-wave-1); box-shadow: 0 0 6px var(--d3-wave-1); opacity: 0.9; }
.wave-bar:nth-child(2) { background: var(--d3-wave-2); box-shadow: 0 0 6px var(--d3-wave-2); opacity: 0.92; }
.wave-bar:nth-child(3) { background: var(--d3-wave-3); box-shadow: 0 0 6px var(--d3-wave-3); opacity: 0.94; }
.wave-bar:nth-child(4) { background: var(--d3-wave-4); box-shadow: 0 0 6px var(--d3-wave-4); opacity: 0.96; }
.wave-bar:nth-child(5) { background: var(--d3-wave-5); box-shadow: 0 0 6px var(--d3-wave-5); opacity: 0.98; }
.wave-bar:nth-child(6) { background: var(--d3-wave-6); box-shadow: 0 0 6px var(--d3-wave-6); opacity: 0.98; }
.wave-bar:nth-child(7) { background: var(--d3-wave-7); box-shadow: 0 0 6px var(--d3-wave-7); opacity: 0.96; }
.wave-bar:nth-child(8) { background: var(--d3-wave-8); box-shadow: 0 0 6px var(--d3-wave-8); opacity: 0.94; }
.wave-bar:nth-child(9) { background: var(--d3-wave-9); box-shadow: 0 0 6px var(--d3-wave-9); opacity: 0.92; }
.duration {
color: var(--d3-text-primary);
@ -111,7 +111,7 @@ body {
justify-content: center;
width: 18px;
height: 18px;
background: #ef4444;
background: var(--d3-status-error);
color: white;
border-radius: 50%;
font-size: 12px;

View file

@ -11,7 +11,7 @@
<div id="container" class="result-popup">
<div id="result-text" class="result-text"></div>
<div id="actions" class="actions">
<button id="copy-btn" class="action-btn" title="복사">
<button id="copy-btn" class="action-btn" data-i18n-title="copy" title="복사">
<svg id="copy-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>

View file

@ -9,11 +9,23 @@
var copyBtn = document.getElementById('copy-btn')
var copyIcon = document.getElementById('copy-icon')
var checkIcon = document.getElementById('check-icon')
var i18nStrings = {}
var autoCloseTimer = 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])
}
})
}
// ── Auto-close 제어 ─────────────────────────────────
function startAutoCloseTimer(ms) {
remainingTime = ms
@ -64,6 +76,8 @@
// Phase 1: prepare — 결과 텍스트 세팅 + 크기 측정
window.popupAPI.on('result:prepare', function (data) {
resultText.textContent = data.text || ''
if (data._i18n) i18nStrings = data._i18n
applyI18nToHtml()
container.classList.remove('visible')
requestAnimationFrame(function () {

View file

@ -90,7 +90,7 @@ body {
}
.action-btn.copied {
color: #34d399;
color: var(--d3-status-success);
}
.hidden {