release: ship v1.6.0 with paged suggestions and a cleaner phrase memory
Some checks failed
deploy-site / deploy (push) Failing after 39s
release / release-windows (push) Failing after 3m41s
portable-unsigned / portable-windows (push) Failing after 12m23s

Next-sentence suggestions now arrive one at a time up to twelve, shown three
per page with Ctrl+Alt+Up/Down to move, Left/Right to page, Enter to accept
and Esc to close; old default bindings migrate and the panel guide follows the
live bindings. The overlay is redesigned, stays put while candidates stream
and sits outside the input box when no caret is reported.

The personal phrase memory stops learning from terminals, code editors and
the coding-agent hub, ignores symbol-heavy lines and empty-field placeholders,
and prunes existing entries that break those rules.

Fixes suggestion keys starting dictation, installs stuck on a pre-1.5.0
speech engine without the focus endpoint, Ollama runner windows flashing
while typing, the speech engine starting twice, and cold-model timeouts.
Live captions can be dragged to a remembered position and show a waiting
notice until the first line arrives.

Bumps the product version to 1.6.0 (Android/iOS build 1060000).
This commit is contained in:
Yun Chan 2026-09-24 19:56:28 +09:00
parent 856e375f3e
commit 2fe20fa7b5
71 changed files with 2469 additions and 366 deletions

View file

@ -8,6 +8,10 @@
</head>
<body>
<div id="root">
<div id="handle" class="drag-handle">
<span class="grip" aria-hidden="true"></span>
<span id="handleHint" class="handle-hint"></span>
</div>
<div id="container" class="caption-overlay">
<div id="lines"></div>
</div>

View file

@ -33,6 +33,8 @@
* @param {{id: string, text: string, timestamp: number, isFinal: boolean}} segment
*/
function addSegment(segment) {
// 첫 자막이 오면 준비 안내를 걷는다 (그 전까지는 계속 보여 빈 화면을 만들지 않는다).
removeStatusLine()
// delta 줄이 있으면 제거 (확정 줄로 교체)
removeDeltaLine()
@ -172,26 +174,92 @@
})
// 상태 변경
// starting: 음성 모델을 준비하는 중
// active : 듣고 있지만 첫 자막은 아직 — 소리를 모아 첫 인식을 마칠 때까지 몇 초 걸린다.
// active 가 되자마자 안내를 지우면 그 몇 초가 빈 화면이라 "고장" 처럼 보였다.
window.popupAPI.on('caption:stateChanged', function (data) {
if (data._i18n) i18nStrings = data._i18n
if (data._i18n) {
i18nStrings = data._i18n
if (handleHint) handleHint.textContent = i18nStrings.captionDragHint || ''
}
if (data.state === 'starting') {
// 로딩 표시
clearAllLines()
var loadingEl = document.createElement('div')
loadingEl.className = 'caption-line loading'
loadingEl.id = 'caption-loading'
loadingEl.textContent = i18nStrings.captionLoading || '⏳ Loading STT model...'
loadingEl.style.fontSize = config.fontSize + 'px'
linesContainer.appendChild(loadingEl)
showStatusLine(i18nStrings.captionLoading)
} else if (data.state === 'active') {
// 로딩 표시 제거
var existing = document.getElementById('caption-loading')
if (existing && existing.parentNode) {
existing.parentNode.removeChild(existing)
}
if (lines.length === 0) showStatusLine(i18nStrings.captionWaiting)
} else if (data.state === 'inactive' || data.state === 'stopping') {
clearAllLines()
}
})
}
// ── 준비 안내 줄 ──────────────────────────────────────
function showStatusLine(text) {
if (!text) return
var el = document.getElementById('caption-loading')
if (!el) {
el = document.createElement('div')
el.className = 'caption-line loading'
el.id = 'caption-loading'
linesContainer.appendChild(el)
}
el.style.fontSize = config.fontSize + 'px'
el.textContent = text
}
function removeStatusLine() {
var el = document.getElementById('caption-loading')
if (el && el.parentNode) el.parentNode.removeChild(el)
}
// ── 끌어서 옮기기 ──────────────────────────────────────
// 창은 클릭 통과라 평소엔 아래 앱을 가리지 않는다. 마우스 이동은 전달받으므로
// 올라오면 손잡이를 보여 주고, 손잡이 위에서만 마우스를 받는다.
var root = document.getElementById('root')
var handle = document.getElementById('handle')
var handleHint = document.getElementById('handleHint')
var hoverTimer = null
var dragging = false
function send(channel, value) {
if (window.popupAPI) window.popupAPI.send(channel, value)
}
document.addEventListener('mousemove', function () {
if (!root) return
root.classList.add('hovering')
if (hoverTimer) clearTimeout(hoverTimer)
hoverTimer = setTimeout(function () {
if (!dragging) root.classList.remove('hovering')
}, 1500)
})
if (handle) {
handle.addEventListener('mouseenter', function () {
send('captionPopup:setInteractive', true)
})
handle.addEventListener('mouseleave', function () {
if (!dragging) send('captionPopup:setInteractive', false)
})
handle.addEventListener('pointerdown', function (event) {
if (event.button !== 0) return
dragging = true
handle.setPointerCapture(event.pointerId)
if (root) root.classList.add('dragging')
send('captionPopup:dragStart')
})
var finishDrag = function () {
if (!dragging) return
dragging = false
if (root) root.classList.remove('dragging')
send('captionPopup:dragEnd')
}
handle.addEventListener('pointerup', finishDrag)
handle.addEventListener('lostpointercapture', finishDrag)
handle.addEventListener('dblclick', function () {
send('captionPopup:resetPosition')
})
}
})()

View file

@ -26,6 +26,7 @@ html, body {
}
#root {
position: relative;
width: 100%;
height: 100%;
display: flex;
@ -33,6 +34,48 @@ html, body {
justify-content: center;
}
/* ── 끌어서 옮기는 손잡이 — 마우스가 자막 창 위에 있을 때만 드러난다 ── */
.drag-handle {
position: absolute;
top: 6px;
left: 50%;
z-index: 2;
display: inline-flex;
align-items: center;
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);
font-size: 11px;
white-space: nowrap;
cursor: grab;
opacity: 0;
transform: translate(-50%, -2px);
transition: opacity 120ms ease-out, transform 120ms ease-out;
}
#root.hovering .drag-handle,
#root.dragging .drag-handle {
opacity: 1;
transform: translate(-50%, 0);
}
#root.dragging .drag-handle {
cursor: grabbing;
border-color: var(--d3-accent-main);
}
.grip {
width: 14px;
height: 8px;
background-image: radial-gradient(circle, currentColor 1px, transparent 1.5px);
background-size: 4.5px 4px;
opacity: 0.8;
}
.caption-overlay {
width: 100%;
padding: 12px 24px;

View file

@ -9,20 +9,23 @@
<body>
<div id="root">
<div id="panel" class="suggestion-panel">
<div class="header">
<div id="status" class="status" hidden>
<span id="spinner" class="spinner" aria-hidden="true"></span>
<span id="statusText" class="status-text"></span>
</div>
<button id="close" class="close" type="button" title="Close">
<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
<path d="M3 3 L13 13 M13 3 L3 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<button id="close" class="close" type="button">
<svg viewBox="0 0 16 16" width="10" height="10" aria-hidden="true">
<path d="M3 3 L13 13 M13 3 L3 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div id="status" class="status" hidden>
<span class="spinner" aria-hidden="true"></span>
<span id="statusText" class="status-text"></span>
</div>
<div id="candidates" class="candidates"></div>
<div id="provenance" class="provenance" aria-live="polite"></div>
<div id="hints" class="hints"></div>
<div id="footer" class="footer" hidden>
<div class="footer-row">
<span id="progress" class="progress" aria-live="polite"></span>
<span id="provenance" class="provenance"></span>
</div>
<div id="keyHints" class="key-hints"></div>
</div>
</div>
</div>
<!-- type="module" 필수: Vite 번들에는 모듈 스크립트만 포함된다. -->

View file

@ -3,18 +3,23 @@
//
// 상태 세 가지를 화면에 드러낸다:
// warmingUp - 모델을 메모리에 올리는 중 (스피너 + "준비 중")
// generating - 토큰이 만들어지는 중 (스피너 + 도착한 부분 텍스트)
// 후보 도착 - 최대 5개, 목록은 스크롤 가능
// generating - 아직 후보가 하나도 없는 첫 생성 중 (스피너 + 도착한 부분 텍스트)
// 후보 도착 - 한 페이지(3개)만 보여준다. 뒤로 더 채워지는 중이면(최대 12개)
// 헤더 스피너 대신 진행률 줄에 작은 표시만 한다(중복 스피너 방지).
//
// 이 창은 focusable:false 다. 그래서 키 입력(수락/순환/닫기)은 전역 키바인딩이
// 이 창은 focusable:false 다. 그래서 키 입력(수락/순환/페이지/닫기)은 전역 키바인딩이
// 메인에서 처리하고, 이 파일은 마우스 클릭만 처리한다.
;(function () {
'use strict'
var PAGE_SIZE = 3
var candidatesContainer = document.getElementById('candidates')
var provenanceContainer = document.getElementById('provenance')
var hintsContainer = document.getElementById('hints')
var progressContainer = document.getElementById('progress')
var keyHintsContainer = document.getElementById('keyHints')
var footer = document.getElementById('footer')
var statusRow = document.getElementById('status')
var statusText = document.getElementById('statusText')
var panel = document.getElementById('panel')
@ -23,23 +28,27 @@
/** @type {string[]} */
var candidates = []
var activeIndex = 0
var targetTotal = 0
var generating = false
var warmingUp = false
var partialText = null
var provenance = null
var i18nStrings = {}
var keyHints = null
var generatingSince = 0
var tickTimer = null
function render() {
candidatesContainer.textContent = ''
var busy = warmingUp || generating
// 후보가 이미 있으면(채우기 루프가 배경에서 더 만드는 중이어도) 헤더 스피너는
// 끈다 — 진행률 줄의 작은 표시가 그 역할을 대신한다(중복 표시 방지).
var busy = warmingUp || (generating && candidates.length === 0)
if (statusRow) statusRow.hidden = !busy
if (statusText) {
if (warmingUp) {
statusText.textContent = i18nStrings.suggestionWarming || '...'
} else if (generating) {
} else if (busy) {
// 경과 시간을 보여준다 — 모델이 바쁘면 몇 초 걸리는지 보이는 편이 덜 답답하다.
var seconds = generatingSince
? Math.max(1, Math.round((Date.now() - generatingSince) / 1000))
@ -51,70 +60,124 @@
}
}
if (footer) footer.hidden = candidates.length === 0
if (candidates.length === 0) {
// 스트리밍 중이면 도착한 부분 텍스트를 그대로 보여준다 ("계속 생성되는" 느낌).
if (partialText) {
var streaming = document.createElement('div')
streaming.className = 'suggestion-item streaming'
streaming.textContent = partialText
candidatesContainer.appendChild(streaming)
candidatesContainer.appendChild(buildItem('div', 'suggestion-item streaming', '1', partialText))
}
renderHints()
renderProvenance()
return
}
for (var i = 0; i < candidates.length; i++) {
var item = document.createElement('button')
var page = Math.floor(activeIndex / PAGE_SIZE)
var pageStart = page * PAGE_SIZE
var pageEnd = Math.min(pageStart + PAGE_SIZE, candidates.length)
for (var i = pageStart; i < pageEnd; i++) {
var item = buildItem(
'button',
i === activeIndex ? 'suggestion-item active' : 'suggestion-item',
String(i + 1),
candidates[i]
)
item.type = 'button'
item.className = i === activeIndex ? 'suggestion-item active' : 'suggestion-item'
item.setAttribute('data-index', String(i))
item.textContent = candidates[i]
item.addEventListener('click', onItemClick)
candidatesContainer.appendChild(item)
}
renderHints()
renderProgress()
renderKeyHints()
renderProvenance()
}
function buildItem(tag, className, number, text) {
var item = document.createElement(tag)
item.className = className
var num = document.createElement('span')
num.className = 'suggestion-num'
num.textContent = number
var body = document.createElement('span')
body.className = 'suggestion-text'
body.textContent = text
item.appendChild(num)
item.appendChild(body)
return item
}
function renderProgress() {
if (!progressContainer) return
progressContainer.textContent = ''
var page = Math.floor(activeIndex / PAGE_SIZE)
var from = page * PAGE_SIZE + 1
var to = Math.min((page + 1) * PAGE_SIZE, candidates.length)
var range = document.createElement('span')
range.textContent = from + '–' + to + ' / ' + candidates.length
progressContainer.appendChild(range)
if (generating) {
var template = i18nStrings.suggestionHintGeneratingMore || i18nStrings.suggestionHintGenerating || ''
var more = document.createElement('span')
more.className = 'progress-more'
var dot = document.createElement('span')
dot.className = 'spinner'
dot.setAttribute('aria-hidden', 'true')
var label = document.createElement('span')
label.textContent = template.replace('{{max}}', String(targetTotal || candidates.length))
more.appendChild(dot)
more.appendChild(label)
progressContainer.appendChild(more)
}
}
function kbd(text) {
var el = document.createElement('kbd')
el.textContent = text
return el
}
function renderKeyHints() {
if (!keyHintsContainer) return
keyHintsContainer.textContent = ''
if (!keyHints) return
// 이동·페이지·수락이 같은 수정자를 쓰면 앞에 한 번만 보여준다 ("Ctrl+Alt +").
if (keyHints.shared) {
var shared = document.createElement('span')
shared.className = 'key-hint shared'
shared.appendChild(kbd(keyHints.shared))
shared.appendChild(document.createTextNode('+'))
keyHintsContainer.appendChild(shared)
}
var entries = [
[keyHints.move, i18nStrings.suggestionHintMoveLabel, ''],
[keyHints.page, i18nStrings.suggestionHintPageLabel, ''],
[keyHints.accept, i18nStrings.suggestionHintAcceptLabel, ''],
[keyHints.close, i18nStrings.suggestionHintCloseLabel, ' close-hint']
]
for (var i = 0; i < entries.length; i++) {
// 바인딩이 없는 액션은 힌트 자체를 생략한다.
if (!entries[i][0]) continue
var hint = document.createElement('span')
hint.className = 'key-hint' + entries[i][2]
hint.appendChild(kbd(entries[i][0]))
if (entries[i][1]) hint.appendChild(document.createTextNode(entries[i][1]))
keyHintsContainer.appendChild(hint)
}
}
function renderProvenance() {
if (!provenanceContainer) return
provenanceContainer.textContent = ''
if (!provenance) return
var parts = []
// 출처는 종류만 보여준다 — 근거 개수는 설정의 제안 기록에서 본다.
var sourceLabel = provenance.mode === 'local-memory'
? i18nStrings.suggestionSourceMemory
: i18nStrings.suggestionSourceModel
if (sourceLabel) parts.push(sourceLabel)
var counts = [
['continuationCount', 'suggestionContinuations'],
['relatedCount', 'suggestionRelated'],
['phraseCount', 'suggestionPhrases'],
['appPhraseCount', 'suggestionAppPhrases']
]
for (var i = 0; i < counts.length; i++) {
var count = provenance[counts[i][0]] || 0
var countLabel = i18nStrings[counts[i][1]]
if (count > 0 && countLabel) parts.push(countLabel + ' ' + count)
}
provenanceContainer.textContent = parts.join(' · ')
}
function renderHints() {
hintsContainer.textContent = ''
if (candidates.length <= 1 && !generating) return
var hint = document.createElement('span')
hint.className = 'hint'
if (candidates.length > 0) {
var nextLabel = i18nStrings.suggestionHintNext || 'Next'
hint.textContent = nextLabel + ' ' + (activeIndex + 1) + '/' + candidates.length
} else {
hint.textContent = i18nStrings.suggestionHintGenerating || '…'
}
hintsContainer.appendChild(hint)
provenanceContainer.textContent = sourceLabel || ''
}
function onItemClick(event) {
@ -127,10 +190,12 @@
function applyPayload(payload) {
if (!payload) return
if (payload._i18n) i18nStrings = payload._i18n
if (payload._keyHints) keyHints = payload._keyHints
candidates = (payload.candidates || []).map(function (candidate) {
return candidate && candidate.text ? candidate.text : String(candidate)
})
activeIndex = payload.activeIndex || 0
if (payload.targetTotal !== undefined) targetTotal = payload.targetTotal || 0
if (payload.generating !== undefined) generating = payload.generating === true
if (payload.warmingUp !== undefined) warmingUp = payload.warmingUp === true
if (payload.partialText !== undefined) partialText = payload.partialText || null
@ -152,16 +217,17 @@
function handleShow(payload) {
applyPayload(payload)
if (payload && payload.generating) generatingSince = Date.now()
if (payload && payload.generating && candidates.length === 0) generatingSince = Date.now()
startTick()
if (panel) panel.classList.add('visible')
render()
}
function handleUpdate(payload) {
var wasGenerating = generating
var wasBusy = generating && candidates.length === 0
applyPayload(payload)
if (generating && !wasGenerating) generatingSince = Date.now()
var isBusy = generating && candidates.length === 0
if (isBusy && !wasBusy) generatingSince = Date.now()
startTick()
render()
}
@ -179,9 +245,13 @@
provenance = null
candidates = []
activeIndex = 0
targetTotal = 0
keyHints = null
candidatesContainer.textContent = ''
if (footer) footer.hidden = true
if (provenanceContainer) provenanceContainer.textContent = ''
hintsContainer.textContent = ''
if (progressContainer) progressContainer.textContent = ''
if (keyHintsContainer) keyHintsContainer.textContent = ''
}
if (closeButton) {

View file

@ -2,6 +2,9 @@
*
* 팝업 스타일은 injectPopupTheme() 의 CSS 변수(--d3-*)로 테마를 따라간다.
* 토큰이 주입되지 않는 상황(개발 초기 로드)을 위해 :root 폴백을 둔다.
*
* 구조: [후보 3개 — 고정 높이] / 가는 선 / [진행률 · 출처] / [키 안내]
* 창 높이(WindowManager SUGGESTION_OVERLAY_HEIGHT)는 이 고정 치수에서 나온다.
*/
:root {
@ -20,13 +23,18 @@
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
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;
font-family: 'Pretendard Variable', 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
}
#root {
@ -36,10 +44,10 @@ body {
}
.suggestion-panel {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
padding: 6px;
border-radius: 10px;
background: var(--d3-bg-result);
border: 1px solid var(--d3-border-result);
@ -54,41 +62,39 @@ body {
transform: translateY(0);
}
/* ── 헤더 (상태 + 닫기) ─────────────────────────────── */
.header {
display: flex;
align-items: center;
gap: 8px;
min-height: 18px;
}
/* ── 닫기 — 구석에 떠 있는 작은 버튼 (빈 헤더 줄을 두지 않는다) ── */
.close {
margin-left: auto;
position: absolute;
top: 6px;
right: 6px;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
width: 20px;
height: 20px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--d3-text-secondary);
cursor: pointer;
transition: background 90ms ease-out, color 90ms ease-out;
opacity: 0.7;
transition: background 90ms ease-out, color 90ms ease-out, opacity 90ms ease-out;
}
.close:hover {
background: var(--d3-accent-dim);
color: var(--d3-text-result);
opacity: 1;
}
/* ── 상태 줄 (워밍업 / 생성 중) ─────────────────────── */
/* ── 상태 (후보가 아직 없을 때만: 준비 중 / 생성 중) ── */
.status {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 4px;
min-height: 32px;
padding: 0 28px 0 8px;
}
.spinner {
@ -110,99 +116,172 @@ body {
.status-text {
color: var(--d3-text-secondary);
font-size: 12px;
font-style: italic;
}
/* ── 후보 목록 (최대 5개, 스크롤) ───────────────────── */
/* ── 후보 (한 페이지 = 3개, 각 최대 2줄, 고정 높이라 채워져도 창이 흔들리지 않는다) ── */
.candidates {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 176px;
overflow-y: auto;
overscroll-behavior: contain;
}
.candidates::-webkit-scrollbar {
width: 8px;
}
.candidates::-webkit-scrollbar-thumb {
background: var(--d3-accent-dim);
border-radius: 4px;
}
.candidates::-webkit-scrollbar-track {
background: transparent;
.candidates:empty {
display: none;
}
.suggestion-item {
display: block;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 8px;
height: 44px;
padding: 0 28px 0 8px;
border: 0;
border-radius: 6px;
border-radius: 7px;
background: transparent;
color: var(--d3-text-result);
font: inherit;
font-size: 14px;
line-height: 1.45;
text-align: left;
cursor: default;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition: background 90ms ease-out;
}
.suggestion-item:hover {
background: var(--d3-accent-dim);
}
.suggestion-item.active {
background: var(--d3-accent-dim);
color: var(--d3-text-result);
box-shadow: inset 2px 0 0 var(--d3-accent-main);
}
.suggestion-item.loading {
.suggestion-num {
flex: 0 0 16px;
color: var(--d3-text-secondary);
font-style: italic;
font-size: 11px;
font-variant-numeric: tabular-nums;
text-align: right;
}
/* 스트리밍 중 — 도착한 만큼 보여주고 커서를 붙인다 */
.suggestion-item.active .suggestion-num {
color: var(--d3-accent-main);
font-weight: 600;
}
.suggestion-text {
display: -webkit-box;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
font-size: 13px;
font-weight: 400;
line-height: 1.4;
word-break: keep-all;
overflow-wrap: anywhere;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.suggestion-item.active .suggestion-text {
font-weight: 500;
}
/* 첫 후보가 스트리밍으로 도착하는 중 — 도착한 만큼 보여주고 커서를 붙인다 */
.suggestion-item.streaming {
color: var(--d3-text-result);
cursor: default;
opacity: 0.85;
white-space: normal;
}
.suggestion-item.streaming::after {
.suggestion-item.streaming .suggestion-text::after {
content: '\258C';
animation: d3-caret-blink 1s steps(1) infinite;
color: var(--d3-accent-main);
}
.provenance {
min-height: 15px;
padding: 0 8px;
color: var(--d3-text-secondary);
font-size: 11px;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes d3-caret-blink {
50% {
opacity: 0;
}
}
.hints {
/* ── 아래쪽: 진행률·출처 한 줄 + 키 안내 한 줄 ── */
.footer {
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px 2px;
flex-direction: column;
gap: 5px;
margin-top: 6px;
padding: 6px 8px 2px;
border-top: 1px solid var(--d3-border-result);
color: var(--d3-text-secondary);
font-size: 11px;
font-size: 10.5px;
line-height: 14px;
}
.hint {
.footer-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
white-space: nowrap;
}
.progress {
display: inline-flex;
align-items: center;
gap: 6px;
font-variant-numeric: tabular-nums;
}
.progress-more {
display: inline-flex;
align-items: center;
gap: 5px;
}
.progress-more .spinner {
width: 8px;
height: 8px;
border-width: 1.5px;
}
.provenance {
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.8;
}
.key-hints {
display: flex;
align-items: center;
gap: 10px;
white-space: nowrap;
overflow: hidden;
}
.key-hint {
display: inline-flex;
align-items: center;
gap: 4px;
}
.key-hint.shared {
gap: 3px;
}
.key-hint.close-hint {
margin-left: auto;
}
kbd {
display: inline-flex;
align-items: center;
height: 16px;
padding: 0 4px;
border: 1px solid var(--d3-border-result);
border-bottom-width: 2px;
border-radius: 4px;
color: var(--d3-text-result);
font-family: inherit;
font-size: 10px;
line-height: 1;
}