feat: Add AgentBar arc dock with per-agent terminal frame and voice modal
- Add ui.json configs for Main (purple) and Ejecutor (red) agents - AgentBar: fused arc-shaped dock at bottom with dynamic glow - Quick press opens styled terminal frame mockup - Hold opens voice modal with Web Speech API streaming transcription - Responsive: full-width mobile, max-width on tablet/desktop/4K - Agents API: serve uiConfig from ui.json in agent directories - Agents page: route, store, toolbar integration
This commit is contained in:
720
frontend/src/components/AgentBar.vue
Normal file
720
frontend/src/components/AgentBar.vue
Normal file
@@ -0,0 +1,720 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
|
||||
// Web Speech API types
|
||||
interface SpeechRecognitionEvent extends Event {
|
||||
resultIndex: number
|
||||
results: SpeechRecognitionResultList
|
||||
}
|
||||
|
||||
interface SpeechRecognitionErrorEvent extends Event {
|
||||
error: string
|
||||
}
|
||||
|
||||
interface SpeechRecognition extends EventTarget {
|
||||
continuous: boolean
|
||||
interimResults: boolean
|
||||
lang: string
|
||||
onresult: ((event: SpeechRecognitionEvent) => void) | null
|
||||
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
|
||||
onend: (() => void) | null
|
||||
start(): void
|
||||
stop(): void
|
||||
abort(): void
|
||||
}
|
||||
|
||||
interface UiConfig {
|
||||
label: string
|
||||
shortLabel: string
|
||||
color: string
|
||||
gradient: string
|
||||
terminalBg: string
|
||||
terminalBorder: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface Agent {
|
||||
id: string
|
||||
name: string
|
||||
directory: string
|
||||
uiConfig: UiConfig | null
|
||||
}
|
||||
|
||||
const agents = ref<Agent[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
// Terminal frame state
|
||||
const terminalAgent = ref<Agent | null>(null)
|
||||
const showTerminal = ref(false)
|
||||
|
||||
// Voice modal state
|
||||
const voiceAgent = ref<Agent | null>(null)
|
||||
const showVoice = ref(false)
|
||||
const isRecording = ref(false)
|
||||
const transcript = ref('')
|
||||
const interimTranscript = ref('')
|
||||
let recognition: SpeechRecognition | null = null
|
||||
|
||||
// Hold detection
|
||||
let holdTimer: number | null = null
|
||||
let holdTriggered = false
|
||||
const HOLD_THRESHOLD = 400
|
||||
|
||||
const enabledAgents = computed(() =>
|
||||
agents.value.filter(a => a.uiConfig?.enabled)
|
||||
)
|
||||
|
||||
// Dynamic glow based on agent colors
|
||||
const barGlowStyle = computed(() => {
|
||||
const list = enabledAgents.value
|
||||
if (!list.length) return {}
|
||||
const glows = list.map(a => {
|
||||
const c = a.uiConfig?.color || '#6366f1'
|
||||
return `0 -6px 30px ${c}50, 0 -2px 15px ${c}30`
|
||||
})
|
||||
return {
|
||||
boxShadow: `${glows.join(', ')}, 0 -10px 50px rgba(0,0,0,0.4)`
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchAgents() {
|
||||
try {
|
||||
const res = await fetch('/api/agents')
|
||||
if (!res.ok) return
|
||||
const data: Agent[] = await res.json()
|
||||
agents.value = data
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Terminal Frame ---
|
||||
function openTerminal(agent: Agent) {
|
||||
terminalAgent.value = agent
|
||||
showTerminal.value = true
|
||||
}
|
||||
|
||||
function closeTerminal() {
|
||||
showTerminal.value = false
|
||||
terminalAgent.value = null
|
||||
}
|
||||
|
||||
// --- Voice Modal ---
|
||||
function openVoice(agent: Agent) {
|
||||
voiceAgent.value = agent
|
||||
showVoice.value = true
|
||||
startRecognition()
|
||||
}
|
||||
|
||||
function closeVoice() {
|
||||
stopRecognition()
|
||||
showVoice.value = false
|
||||
voiceAgent.value = null
|
||||
transcript.value = ''
|
||||
interimTranscript.value = ''
|
||||
}
|
||||
|
||||
function initSpeechRecognition(): SpeechRecognition | null {
|
||||
const SR = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
|
||||
if (!SR) return null
|
||||
|
||||
const rec = new SR()
|
||||
rec.continuous = true
|
||||
rec.interimResults = true
|
||||
rec.lang = 'es-419'
|
||||
|
||||
rec.onresult = (event: SpeechRecognitionEvent) => {
|
||||
let interim = ''
|
||||
let final = ''
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const result = event.results[i]
|
||||
if (!result || !result[0]) continue
|
||||
if (result.isFinal) {
|
||||
final += result[0].transcript + ' '
|
||||
} else {
|
||||
interim += result[0].transcript
|
||||
}
|
||||
}
|
||||
if (final) transcript.value += final
|
||||
interimTranscript.value = interim
|
||||
}
|
||||
|
||||
rec.onerror = () => {
|
||||
isRecording.value = false
|
||||
}
|
||||
|
||||
rec.onend = () => {
|
||||
if (isRecording.value) {
|
||||
try { rec.start() } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
function startRecognition() {
|
||||
if (!recognition) recognition = initSpeechRecognition()
|
||||
if (!recognition) return
|
||||
transcript.value = ''
|
||||
interimTranscript.value = ''
|
||||
try {
|
||||
recognition.start()
|
||||
isRecording.value = true
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function stopRecognition() {
|
||||
if (recognition) {
|
||||
isRecording.value = false
|
||||
try { recognition.stop() } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pill interaction ---
|
||||
function handlePointerDown(agent: Agent) {
|
||||
holdTriggered = false
|
||||
holdTimer = window.setTimeout(() => {
|
||||
holdTriggered = true
|
||||
openVoice(agent)
|
||||
}, HOLD_THRESHOLD)
|
||||
}
|
||||
|
||||
function handlePointerUp(agent: Agent) {
|
||||
if (holdTimer) {
|
||||
clearTimeout(holdTimer)
|
||||
holdTimer = null
|
||||
}
|
||||
if (!holdTriggered) {
|
||||
openTerminal(agent)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerLeave() {
|
||||
if (holdTimer) {
|
||||
clearTimeout(holdTimer)
|
||||
holdTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
function onResize() {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAgents()
|
||||
window.addEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
stopRecognition()
|
||||
recognition = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Agent Bar — arc dock -->
|
||||
<div v-if="enabledAgents.length" class="agent-bar">
|
||||
<div class="agent-bar-inner" :style="barGlowStyle">
|
||||
<button
|
||||
v-for="agent in enabledAgents"
|
||||
:key="agent.id"
|
||||
class="agent-pill"
|
||||
:style="{ background: agent.uiConfig?.gradient || agent.uiConfig?.color }"
|
||||
@pointerdown.prevent="handlePointerDown(agent)"
|
||||
@pointerup="handlePointerUp(agent)"
|
||||
@pointerleave="handlePointerLeave"
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<span class="pill-label">
|
||||
{{ isMobile ? agent.uiConfig?.shortLabel : agent.uiConfig?.label }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Frame Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="frame-fade">
|
||||
<div v-if="showTerminal && terminalAgent" class="terminal-overlay" @click.self="closeTerminal">
|
||||
<div
|
||||
class="terminal-frame"
|
||||
:style="{
|
||||
'--term-bg': terminalAgent.uiConfig?.terminalBg || '#0f0a1a',
|
||||
'--term-border': terminalAgent.uiConfig?.terminalBorder || '#6366f1',
|
||||
'--term-color': terminalAgent.uiConfig?.color || '#6366f1'
|
||||
}"
|
||||
>
|
||||
<div class="term-titlebar">
|
||||
<div class="term-title-left">
|
||||
<span class="term-dot red"></span>
|
||||
<span class="term-dot yellow"></span>
|
||||
<span class="term-dot green"></span>
|
||||
<span class="term-name">{{ terminalAgent.uiConfig?.label || terminalAgent.name }}</span>
|
||||
</div>
|
||||
<button class="term-close" @click="closeTerminal">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="term-body">
|
||||
<div class="term-cursor-line">
|
||||
<span class="term-prompt" :style="{ color: terminalAgent.uiConfig?.color }">~$</span>
|
||||
<span class="term-cursor">_</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Voice Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="voice-fade">
|
||||
<div v-if="showVoice && voiceAgent" class="voice-overlay" @click.self="closeVoice">
|
||||
<div
|
||||
class="voice-frame"
|
||||
:style="{
|
||||
'--voice-color': voiceAgent.uiConfig?.color || '#6366f1',
|
||||
'--voice-gradient': voiceAgent.uiConfig?.gradient || 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
'--voice-bg': voiceAgent.uiConfig?.terminalBg || '#0f0a1a'
|
||||
}"
|
||||
>
|
||||
<div class="voice-header">
|
||||
<div class="voice-agent-badge" :style="{ background: voiceAgent.uiConfig?.gradient }">
|
||||
{{ voiceAgent.uiConfig?.shortLabel }}
|
||||
</div>
|
||||
<span class="voice-title">Voice</span>
|
||||
<div class="voice-rec-indicator" :class="{ active: isRecording }"></div>
|
||||
<button class="voice-close" @click="closeVoice">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="voice-body">
|
||||
<div class="voice-transcript" :class="{ empty: !transcript && !interimTranscript }">
|
||||
<span class="voice-final">{{ transcript }}</span>
|
||||
<span class="voice-interim">{{ interimTranscript }}</span>
|
||||
<span v-if="!transcript && !interimTranscript" class="voice-placeholder">
|
||||
Listening...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ====================== AGENT BAR — ARC DOCK ====================== */
|
||||
.agent-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9990;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.agent-bar-inner {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
overflow: hidden;
|
||||
/* Arc: wide elliptical curve at top, flat bottom */
|
||||
border-radius: 50% 50% 0 0 / 22px 22px 0 0;
|
||||
/* Top edge highlight */
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Sheen line following the arc */
|
||||
.agent-bar-inner::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 15%;
|
||||
right: 15%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.agent-pill {
|
||||
flex: 1;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
transition: filter 0.15s ease, brightness 0.15s ease;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
-webkit-touch-callout: none;
|
||||
position: relative;
|
||||
/* Safe area for phones with gesture bar */
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
/* Divider between pills */
|
||||
.agent-pill + .agent-pill {
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.agent-pill:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
.agent-pill:active {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.pill-label {
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ---- Responsive: Tablet (768+) ---- */
|
||||
@media (min-width: 768px) {
|
||||
.agent-bar-inner {
|
||||
max-width: 480px;
|
||||
height: 56px;
|
||||
border-radius: 50% 50% 0 0 / 26px 26px 0 0;
|
||||
}
|
||||
|
||||
.agent-pill {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Responsive: Desktop (1200+) ---- */
|
||||
@media (min-width: 1200px) {
|
||||
.agent-bar-inner {
|
||||
max-width: 580px;
|
||||
height: 62px;
|
||||
border-radius: 50% 50% 0 0 / 30px 30px 0 0;
|
||||
}
|
||||
|
||||
.agent-pill {
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Responsive: 4K / TV (2400+) ---- */
|
||||
@media (min-width: 2400px) {
|
||||
.agent-bar-inner {
|
||||
max-width: 740px;
|
||||
height: 76px;
|
||||
border-radius: 50% 50% 0 0 / 38px 38px 0 0;
|
||||
}
|
||||
|
||||
.agent-pill {
|
||||
font-size: 20px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ====================== TERMINAL FRAME ====================== */
|
||||
.terminal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10050;
|
||||
}
|
||||
|
||||
.terminal-frame {
|
||||
width: 640px;
|
||||
max-width: 92vw;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--term-border);
|
||||
box-shadow:
|
||||
0 0 30px color-mix(in srgb, var(--term-border) 30%, transparent),
|
||||
0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.term-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
background: color-mix(in srgb, var(--term-bg) 80%, var(--term-border) 20%);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.term-title-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.term-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.term-dot.red { background: #ff5f57; }
|
||||
.term-dot.yellow { background: #ffbd2e; }
|
||||
.term-dot.green { background: #28c840; }
|
||||
|
||||
.term-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-left: 4px;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.term-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.term-close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.term-body {
|
||||
min-height: 300px;
|
||||
padding: 16px;
|
||||
background: var(--term-bg);
|
||||
font-family: 'Consolas', 'Monaco', 'Cascadia Code', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.term-cursor-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.term-prompt {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.term-cursor {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
animation: cursor-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes cursor-blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ====================== VOICE MODAL ====================== */
|
||||
.voice-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10050;
|
||||
}
|
||||
|
||||
.voice-frame {
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: var(--voice-bg);
|
||||
border: 1px solid color-mix(in srgb, var(--voice-color) 40%, transparent);
|
||||
box-shadow:
|
||||
0 0 40px color-mix(in srgb, var(--voice-color) 25%, transparent),
|
||||
0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.voice-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: color-mix(in srgb, var(--voice-bg) 70%, var(--voice-color) 30%);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.voice-agent-badge {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.voice-title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.voice-rec-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.voice-rec-indicator.active {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 8px #ef4444;
|
||||
animation: rec-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rec-pulse {
|
||||
0%, 100% { opacity: 1; box-shadow: 0 0 8px #ef4444; }
|
||||
50% { opacity: 0.5; box-shadow: 0 0 16px #ef4444; }
|
||||
}
|
||||
|
||||
.voice-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.voice-close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.voice-body {
|
||||
min-height: 160px;
|
||||
max-height: 300px;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.voice-transcript {
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.voice-transcript.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.voice-final {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.voice-interim {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.voice-placeholder {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
font-size: 15px;
|
||||
font-style: italic;
|
||||
animation: placeholder-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes placeholder-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ====================== TRANSITIONS ====================== */
|
||||
.frame-fade-enter-active,
|
||||
.voice-fade-enter-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.frame-fade-leave-active,
|
||||
.voice-fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.frame-fade-enter-from,
|
||||
.frame-fade-leave-to,
|
||||
.voice-fade-enter-from,
|
||||
.voice-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.frame-fade-enter-active .terminal-frame {
|
||||
animation: frame-scale-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.frame-fade-leave-active .terminal-frame {
|
||||
animation: frame-scale-out 0.15s ease forwards;
|
||||
}
|
||||
|
||||
.voice-fade-enter-active .voice-frame {
|
||||
animation: frame-scale-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.voice-fade-leave-active .voice-frame {
|
||||
animation: frame-scale-out 0.15s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes frame-scale-in {
|
||||
from { transform: scale(0.92); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes frame-scale-out {
|
||||
from { transform: scale(1); opacity: 1; }
|
||||
to { transform: scale(0.95); opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user