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

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

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

View file

@ -8,19 +8,21 @@ import { PauseIcon, PlayIcon } from './icons'
* (apps/desktop/src/renderer/popups/recording-tip)을 같은 수치로 재현한다.
*
* 대기 → 오른쪽 Alt 누른 채 말하기(캡슐·파형·경과 시간·부분 전사)
* → 키를 떼면 처리 중(점근 진행 막대) → 커서 자리에 다듬어진 문장 입력 → 다음 예시
* → 키를 떼면 받아쓰는 중(무한 진행 막대, 받아쓰던 문장은 흐리게 남김)
* → 커서 자리에 다듬어진 문장 입력 + 캡슐 "입력함" 잠깐 → 다음 예시
* 2026-09-28 앱 캡슐 v4 에 맞췄다: 판 모양(알약 아님)·단색 막대·입력 없으면 막대 정지·Esc 안내.
*
* WCAG 2.2.2: 5초 넘게 움직이므로 일시정지 버튼을 둔다. 화면 밖이거나 탭이 숨겨지면 멈춘다.
* 감소 모션이면 반복하지 않고 한 장면(말한 내용과 입력 결과)을 정지 화면으로 보여 준다.
*/
type Phase = 'idle' | 'listening' | 'thinking' | 'typed'
type Phase = 'idle' | 'listening' | 'thinking' | 'done' | 'typed'
// 앱 recording-tip/script.js 와 같은 값
const BAR_COUNT = 9
const BAR_INTERVAL_MS = 100
const BAR_MIN = 2
const BAR_MAX = 28
const BAR_MAX = 22
const SMOOTHING = 0.5
const RANDOM_FACTOR = 0.35
const BAR_WEIGHTS = Array.from({ length: BAR_COUNT }, (_, i) => {
@ -33,7 +35,9 @@ const IDLE_MS = 1400
const WORD_MS = 260
const LISTEN_TAIL_MS = 700
const THINK_MS = 1100
const TYPED_MS = 3200
/** 앱 recording-tip-presenter 의 doneHideMs 와 같다 */
const DONE_MS = 700
const TYPED_MS = 2500
/**
* 부분 전사가 나타나는 단위. 띄어 쓰는 언어는 낱말(뒤 공백 포함), 띄어 쓰지 않는
@ -65,7 +69,6 @@ export function AppLoopDemo() {
const rootRef = useRef<HTMLDivElement>(null)
const barRefs = useRef<(HTMLSpanElement | null)[]>([])
const progressRef = useRef<HTMLSpanElement>(null)
const timers = useRef<number[]>([])
const running = !reducedMotion && !userPaused && !offscreen
@ -113,7 +116,8 @@ export function AppLoopDemo() {
const listenStart = IDLE_MS
const listenEnd = listenStart + words.length * WORD_MS + LISTEN_TAIL_MS
const thinkEnd = listenEnd + THINK_MS
const loopEnd = thinkEnd + TYPED_MS
const doneEnd = thinkEnd + DONE_MS
const loopEnd = doneEnd + TYPED_MS
schedule(() => setPhase('listening'), listenStart)
words.forEach((_, i) => schedule(() => setWordCount(i + 1), listenStart + (i + 1) * WORD_MS))
@ -121,7 +125,8 @@ export function AppLoopDemo() {
schedule(() => setElapsed(s), listenStart + s)
}
schedule(() => setPhase('thinking'), listenEnd)
schedule(() => setPhase('typed'), thinkEnd)
schedule(() => setPhase('done'), thinkEnd)
schedule(() => setPhase('typed'), doneEnd)
schedule(() => setSampleIndex((i) => i + 1), loopEnd)
return clearTimers
@ -135,7 +140,8 @@ export function AppLoopDemo() {
const paint = (level: number, jitter: boolean) => {
bars.forEach((bar, i) => {
if (!bar) return
const base = Math.max(0.08, level) * BAR_MAX * BAR_WEIGHTS[i]
// 앱과 같이 입력이 0이면 바닥 — "살아있는 척" 최소 흔들림 없음
const base = level * BAR_MAX * BAR_WEIGHTS[i]
const randomized = jitter ? base * (1 + (Math.random() - 0.5) * 2 * RANDOM_FACTOR) : base
const target = Math.max(BAR_MIN, Math.min(BAR_MAX, randomized))
heights[i] = jitter ? heights[i] + (target - heights[i]) * SMOOTHING : target
@ -144,7 +150,7 @@ export function AppLoopDemo() {
}
if (!running || phase !== 'listening') {
paint(reducedMotion ? 0.7 : 0.08, false)
paint(reducedMotion ? 0.7 : 0, false)
return
}
let tick = 0
@ -157,21 +163,6 @@ export function AppLoopDemo() {
return () => window.clearInterval(id)
}, [phase, running, reducedMotion])
// 처리 중 진행 막대: min(95, (1 - 1/(1 + 1.5t)) * 100)%
useEffect(() => {
const bar = progressRef.current
if (!bar || phase !== 'thinking') return
const start = performance.now()
let raf = 0
const step = () => {
const sec = (performance.now() - start) / 1000
bar.style.width = `${Math.min(95, (1 - 1 / (1 + 1.5 * sec)) * 100)}%`
if (running) raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
return () => cancelAnimationFrame(raf)
}, [phase, running])
// 감소 모션: 한 장면을 정지 화면으로.
const shownPhase: Phase = reducedMotion ? 'typed' : phase
const capsulePhase: Phase = reducedMotion ? 'listening' : phase
@ -182,6 +173,7 @@ export function AppLoopDemo() {
idle: t.demo.stateIdle,
listening: t.demo.stateListening,
thinking: t.demo.statePolishing,
done: t.demo.stateDone,
typed: t.demo.stateDone,
}[reducedMotion ? 'typed' : phase]
@ -204,31 +196,39 @@ export function AppLoopDemo() {
<div className="demo-doc keep-ko">
<p className="text-neutral-400">{t.demo.docLine}</p>
<p className="mt-3 text-neutral-50">
{shownPhase === 'typed' && <span className="demo-typed">{sample.clean}</span>}
{(shownPhase === 'typed' || shownPhase === 'done') && <span className="demo-typed">{sample.clean}</span>}
<span className="demo-caret" />
</p>
</div>
{/* 녹음 캡슐 — 앱과 같은 자리(화면 아래 가운데)에 뜬다 */}
<div className={`demo-capsule-slot ${capsulePhase === 'listening' || capsulePhase === 'thinking' ? 'is-visible' : ''}`}>
<div className="capsule">
{capsulePhase === 'thinking' ? (
<div className="capsule-row">
<span className="capsule-progress"><span ref={progressRef} className="capsule-progress-bar" /></span>
<span className="capsule-thinking">{t.demo.processing}</span>
<div className={`demo-capsule-slot ${capsulePhase === 'listening' || capsulePhase === 'thinking' || capsulePhase === 'done' ? 'is-visible' : ''}`}>
<div className={`capsule is-${capsulePhase}`}>
{capsulePhase === 'done' ? (
<div className="capsule-row capsule-done">
<span className="capsule-done-icon">✓</span>
<span>{t.demo.inserted}</span>
</div>
) : (
<div className="capsule-recording">
<div className="capsule-row justify-center">
<>
<div className="capsule-row">
<span className="capsule-bars">
{BAR_WEIGHTS.map((_, i) => (
<span key={i} ref={(el) => { barRefs.current[i] = el }} className="capsule-bar" />
))}
</span>
<span className="capsule-duration">{formatDuration(reducedMotion ? 3000 : elapsed)}</span>
{capsulePhase === 'thinking' ? (
<span className="capsule-thinking">{t.demo.processing}</span>
) : (
<span className="capsule-duration">{formatDuration(reducedMotion ? 3000 : elapsed)}</span>
)}
<kbd className="capsule-cancel">{t.demo.cancelHint}</kbd>
</div>
{capsulePhase === 'thinking' && (
<span className="capsule-progress"><span className="capsule-progress-bar" /></span>
)}
{partial && <p className="capsule-partial keep-ko">{partial}</p>}
</div>
</>
)}
</div>
</div>

View file

@ -81,6 +81,10 @@ export interface Translations {
docLine: string
/** 캡슐의 처리 중 문구 */
processing: string
/** 캡슐 완료 표시 — 앱 recording-tip 의 done 상태 */
inserted: string
/** 캡슐 오른쪽 키 안내 */
cancelHint: string
stateIdle: string
stateListening: string
statePolishing: string

View file

@ -36,7 +36,9 @@ export const de: Translations = {
note: 'Ein vorbereitetes Beispiel, keine echte Aufnahme.',
windowTitle: 'Notizen',
docLine: 'Notizen zum Team-Meeting am Freitag',
processing: 'Wird verarbeitet...',
processing: 'Wird transkribiert',
inserted: 'Eingefügt',
cancelHint: 'Esc zum Abbrechen',
stateIdle: 'Wartet',
stateListening: 'Gedrückt halten und sprechen',
statePolishing: 'Losgelassen, wird bereinigt',

View file

@ -36,7 +36,9 @@ export const en: Translations = {
note: 'A scripted example, not a real recording.',
windowTitle: 'Notes',
docLine: 'Friday team meeting notes',
processing: 'Processing...',
processing: 'Transcribing',
inserted: 'Inserted',
cancelHint: 'Esc to cancel',
stateIdle: 'Waiting',
stateListening: 'Holding and speaking',
statePolishing: 'Released, cleaning up',

View file

@ -36,7 +36,9 @@ export const es: Translations = {
note: 'Un ejemplo guionado, no una grabación real.',
windowTitle: 'Notas',
docLine: 'Notas de la reunión de equipo del viernes',
processing: 'Procesando...',
processing: 'Transcribiendo',
inserted: 'Insertado',
cancelHint: 'Esc para cancelar',
stateIdle: 'Esperando',
stateListening: 'Manteniendo presionado y hablando',
statePolishing: 'Al soltar, limpiando',

View file

@ -36,7 +36,9 @@ export const fr: Translations = {
note: 'Un exemple scénarisé, pas un véritable enregistrement.',
windowTitle: 'Notes',
docLine: 'Notes de la réunion d\'équipe du vendredi',
processing: 'Traitement en cours...',
processing: 'Transcription',
inserted: 'Inséré',
cancelHint: 'Échap pour annuler',
stateIdle: 'En attente',
stateListening: 'Maintien et parole',
statePolishing: 'Relâché, mise en forme',

View file

@ -36,7 +36,9 @@ export const ja: Translations = {
note: '台本による例であり、実際の録音ではありません。',
windowTitle: 'メモ',
docLine: '金曜日のチーム会議メモ',
processing: '処理中...',
processing: '文字起こし中',
inserted: '入力しました',
cancelHint: 'Esc で取消',
stateIdle: '待機中',
stateListening: '押しながら話している',
statePolishing: '離すと整形',

View file

@ -36,7 +36,9 @@ export const ko: Translations = {
note: '실제 녹음이 아닌 예시 화면입니다.',
windowTitle: '메모',
docLine: '금요일 팀 회의 메모',
processing: '다듬는 중...',
processing: '받아쓰는 중',
inserted: '입력함',
cancelHint: 'Esc 취소',
stateIdle: '기다리는 중',
stateListening: '듣는 중',
statePolishing: '다듬는 중',

View file

@ -36,7 +36,9 @@ export const pt: Translations = {
note: 'Um exemplo roteirizado, não uma gravação real.',
windowTitle: 'Notas',
docLine: 'Notas da reunião de equipe de sexta-feira',
processing: 'Processando...',
processing: 'Transcrevendo',
inserted: 'Inserido',
cancelHint: 'Esc para cancelar',
stateIdle: 'Aguardando',
stateListening: 'Segurando e falando',
statePolishing: 'Solto, corrigindo',

View file

@ -36,7 +36,9 @@ export const ru: Translations = {
note: 'Заранее подготовленный пример, а не настоящая запись.',
windowTitle: 'Заметки',
docLine: 'Заметки с пятничной встречи команды',
processing: 'Обработка...',
processing: 'Расшифровка',
inserted: 'Вставлено',
cancelHint: 'Esc — отмена',
stateIdle: 'Ожидание',
stateListening: 'Зажато, говорю',
statePolishing: 'Отпущено, идёт очистка',

View file

@ -36,7 +36,9 @@ export const vi: Translations = {
note: 'Đây là ví dụ dựng sẵn, không phải bản ghi âm thật.',
windowTitle: 'Ghi chú',
docLine: 'Ghi chú họp nhóm thứ Sáu',
processing: 'Đang xử lý...',
processing: 'Đang chép lời',
inserted: 'Đã chèn',
cancelHint: 'Esc để hủy',
stateIdle: 'Đang chờ',
stateListening: 'Đang giữ và nói',
statePolishing: 'Đã thả, đang làm sạch',

View file

@ -36,7 +36,9 @@ export const zh: Translations = {
note: '这是脚本示例,并非真实录音。',
windowTitle: '备忘录',
docLine: '周五团队会议记录',
processing: '处理中...',
processing: '正在转写',
inserted: '已输入',
cancelHint: 'Esc 取消',
stateIdle: '等待中',
stateListening: '按住说话中',
statePolishing: '松开后润色中',

View file

@ -147,28 +147,23 @@
transform: none;
}
/* ── 녹음 캡슐 — 앱 recording-tip/style.css 와 같은 값 ─── */
/* ── 녹음 캡슐 — 앱 popups/recording-tip/style.css(v4, 2026-09-28)와 같은 값 ───
* 판 모양(radius 10)·단색 막대·입력 없으면 막대 정지·처리 중 무한 막대·완료 "입력함".
* 색은 앱 다크 테마 원시값(packages/ui/src/theme-raw.ts)과 같다 — 값이 바뀌면 함께 고친다. */
.capsule {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(15, 17, 22, 0.88);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.12);
backdrop-filter: blur(28px);
-webkit-backdrop-filter: blur(28px);
}
.capsule:has(.capsule-partial) {
border-radius: 18px;
}
.capsule-recording {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 160px;
max-width: 320px;
min-width: 168px;
max-width: 300px;
padding: 8px 12px;
border-radius: 10px;
border: 1px solid rgba(148, 180, 255, 0.12);
background: #111a30;
box-shadow: 0 12px 32px rgba(3, 7, 18, 0.55);
}
.capsule.is-listening {
border-color: rgba(148, 180, 255, 0.2);
}
.capsule-row {
display: flex;
@ -179,66 +174,88 @@
display: flex;
align-items: center;
gap: 3px;
height: 28px;
height: 22px;
}
.capsule-bar {
display: block;
width: 3px;
min-height: 3px;
min-height: 2px;
height: 2px;
border-radius: 999px;
border-radius: 2px;
background: #3b82f6;
transition: height 80ms ease-out;
}
.capsule-bar:nth-child(1) { background: #93c5fd; opacity: 0.85; }
.capsule-bar:nth-child(2) { background: #60a5fa; opacity: 0.9; }
.capsule-bar:nth-child(3) { background: #4f8dfa; opacity: 0.95; }
.capsule-bar:nth-child(4) { background: #3b82f6; }
.capsule-bar:nth-child(5) { background: #3b82f6; }
.capsule-bar:nth-child(6) { background: #5b75f7; }
.capsule-bar:nth-child(7) { background: #6e71f7; opacity: 0.95; }
.capsule-bar:nth-child(8) { background: #818cf8; opacity: 0.9; }
.capsule-bar:nth-child(9) { background: #a78bfa; opacity: 0.85; }
.capsule.is-thinking .capsule-bar {
background: #7e8eb0;
}
.capsule-duration {
min-width: 34px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
min-width: 32px;
font-size: 13px;
font-weight: 600;
font-weight: 500;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
color: #f4f4f5;
}
.capsule-partial {
display: -webkit-box;
max-height: 38px;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 6px;
font-size: 12px;
line-height: 1.45;
color: #f4f4f5;
}
.capsule-progress {
display: block;
width: 120px;
height: 4px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
}
.capsule-progress-bar {
display: block;
width: 0;
height: 100%;
border-radius: 999px;
background: #3b82f6;
transition: width 100ms linear;
color: #eef2fb;
}
.capsule-thinking {
flex: 1;
font-size: 12px;
font-weight: 500;
color: #a1a1aa;
color: #93a4c8;
white-space: nowrap;
}
.capsule-cancel {
margin-left: auto;
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 5px;
border: 1px solid rgba(148, 180, 255, 0.2);
border-radius: 4px;
font-size: 11px;
line-height: 1;
color: #7e8eb0;
white-space: nowrap;
}
.capsule-progress {
position: relative;
display: block;
height: 2px;
overflow: hidden;
border-radius: 1px;
background: rgba(148, 180, 255, 0.12);
}
.capsule-progress-bar {
position: absolute;
top: 0;
bottom: 0;
width: 36%;
border-radius: 1px;
background: #3b82f6;
animation: capsule-indeterminate 1.1s cubic-bezier(0.22, 1, 0.36, 1) infinite;
}
@keyframes capsule-indeterminate {
from { left: -36%; }
to { left: 100%; }
}
.capsule-partial {
display: flex;
flex-direction: column;
justify-content: flex-end;
max-height: 38px;
overflow: hidden;
border-top: 1px solid rgba(148, 180, 255, 0.07);
padding-top: 6px;
font-size: 12px;
line-height: 1.55;
color: #eef2fb;
}
.capsule.is-thinking .capsule-partial {
color: #7e8eb0;
}
.capsule-done {
font-size: 12px;
color: #eef2fb;
}
.capsule-done-icon {
color: #34d399;
}
/* ── Grid Background Pattern (히어로) ─────────────────── */