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:
parent
f064c93197
commit
7b1210b5d1
41 changed files with 1565 additions and 1143 deletions
101
apps/desktop/src/renderer/popups/_shared/list-popup.css
Normal file
101
apps/desktop/src/renderer/popups/_shared/list-popup.css
Normal 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;
|
||||
}
|
||||
146
apps/desktop/src/renderer/popups/_shared/popup-base.css
Normal file
146
apps/desktop/src/renderer/popups/_shared/popup-base.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})()
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue