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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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