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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -81,6 +81,10 @@ export interface Translations {
|
|||
docLine: string
|
||||
/** 캡슐의 처리 중 문구 */
|
||||
processing: string
|
||||
/** 캡슐 완료 표시 — 앱 recording-tip 의 done 상태 */
|
||||
inserted: string
|
||||
/** 캡슐 오른쪽 키 안내 */
|
||||
cancelHint: string
|
||||
stateIdle: string
|
||||
stateListening: string
|
||||
statePolishing: string
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export const ja: Translations = {
|
|||
note: '台本による例であり、実際の録音ではありません。',
|
||||
windowTitle: 'メモ',
|
||||
docLine: '金曜日のチーム会議メモ',
|
||||
processing: '処理中...',
|
||||
processing: '文字起こし中',
|
||||
inserted: '入力しました',
|
||||
cancelHint: 'Esc で取消',
|
||||
stateIdle: '待機中',
|
||||
stateListening: '押しながら話している',
|
||||
statePolishing: '離すと整形',
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export const ko: Translations = {
|
|||
note: '실제 녹음이 아닌 예시 화면입니다.',
|
||||
windowTitle: '메모',
|
||||
docLine: '금요일 팀 회의 메모',
|
||||
processing: '다듬는 중...',
|
||||
processing: '받아쓰는 중',
|
||||
inserted: '입력함',
|
||||
cancelHint: 'Esc 취소',
|
||||
stateIdle: '기다리는 중',
|
||||
stateListening: '듣는 중',
|
||||
statePolishing: '다듬는 중',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export const ru: Translations = {
|
|||
note: 'Заранее подготовленный пример, а не настоящая запись.',
|
||||
windowTitle: 'Заметки',
|
||||
docLine: 'Заметки с пятничной встречи команды',
|
||||
processing: 'Обработка...',
|
||||
processing: 'Расшифровка',
|
||||
inserted: 'Вставлено',
|
||||
cancelHint: 'Esc — отмена',
|
||||
stateIdle: 'Ожидание',
|
||||
stateListening: 'Зажато, говорю',
|
||||
statePolishing: 'Отпущено, идёт очистка',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ export const zh: Translations = {
|
|||
note: '这是脚本示例,并非真实录音。',
|
||||
windowTitle: '备忘录',
|
||||
docLine: '周五团队会议记录',
|
||||
processing: '处理中...',
|
||||
processing: '正在转写',
|
||||
inserted: '已输入',
|
||||
cancelHint: 'Esc 取消',
|
||||
stateIdle: '等待中',
|
||||
stateListening: '按住说话中',
|
||||
statePolishing: '松开后润色中',
|
||||
|
|
|
|||
|
|
@ -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 (히어로) ─────────────────── */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue