feat: voice mic, pixel life layer, enhanced transcript-debug UX
VoiceMicButton component, PixelLife aquatic layer, improved UserMessageBubble with voice display, AgentBadge terminal switcher, ChatContainer voice integration, FloatingTranscriptDebug ocean life enhancements, and terminal registry support. Remove traefik config.
This commit is contained in:
@@ -85,13 +85,6 @@ const renderer = useTerminalRenderer({
|
||||
}
|
||||
},
|
||||
onKeyEvent: (e) => {
|
||||
// Ctrl+E: Toggle terminal
|
||||
if (e.ctrlKey && e.key === 'e') {
|
||||
e.preventDefault()
|
||||
toggleTerminal()
|
||||
return false
|
||||
}
|
||||
|
||||
// Ctrl+V: Paste from clipboard
|
||||
if (e.ctrlKey && e.key === 'v' && e.type === 'keydown') {
|
||||
e.preventDefault()
|
||||
@@ -216,11 +209,8 @@ function toggleTerminal() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.ctrlKey && e.key === 'e') {
|
||||
e.preventDefault()
|
||||
toggleTerminal()
|
||||
}
|
||||
function handleKeydown(_e: KeyboardEvent) {
|
||||
// Reserved for future terminal shortcuts
|
||||
}
|
||||
|
||||
function startDrag(e: MouseEvent | TouchEvent) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useTranscriptDebug } from '@/composables/transcript-debug'
|
||||
import { useVoiceInput } from '@/composables/useVoiceInput'
|
||||
import { ChatContainer, AquaticBackground, AgentBadge } from '@/components/transcript-debug'
|
||||
import type { AgentName } from '@/types/transcript-debug'
|
||||
|
||||
@@ -32,14 +33,31 @@ const {
|
||||
processing,
|
||||
ephemeral,
|
||||
terminalReady,
|
||||
openTerminals,
|
||||
activeTerminalSessionId,
|
||||
init,
|
||||
switchAgent,
|
||||
selectSession,
|
||||
createNewSession,
|
||||
switchToTerminal,
|
||||
closeTerminal,
|
||||
disconnectRealtime,
|
||||
sendPrompt
|
||||
} = useTranscriptDebug()
|
||||
|
||||
const voice = useVoiceInput({ language: 'es-419' })
|
||||
const {
|
||||
isRecording: voiceRecording,
|
||||
transcript: voiceTranscript,
|
||||
interimTranscript: voiceInterim,
|
||||
voiceMode,
|
||||
whisperStatus,
|
||||
audioDevices,
|
||||
selectedDeviceId,
|
||||
lastAudioUrl,
|
||||
isPlayingAudio,
|
||||
} = voice
|
||||
|
||||
const agents: { id: AgentName; label: string }[] = [
|
||||
{ id: 'ejecutor', label: 'Ejecutor' },
|
||||
{ id: 'nucleo000', label: 'nucleo000' },
|
||||
@@ -62,6 +80,46 @@ const showChrome = computed(() =>
|
||||
isResizing.value || showSelector.value
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// OCEAN LIFE STATE
|
||||
// ============================================================================
|
||||
|
||||
const currentHour = ref(new Date().getHours())
|
||||
|
||||
const isNightTime = computed(() => currentHour.value >= 20 || currentHour.value < 6)
|
||||
const isNoonTime = computed(() => currentHour.value >= 11 && currentHour.value <= 13)
|
||||
const isDawnTime = computed(() => currentHour.value >= 5 && currentHour.value <= 7)
|
||||
const isFridayTime = computed(() => new Date().getDay() === 5)
|
||||
const isMidnightTime = computed(() => currentHour.value === 0)
|
||||
|
||||
const showComet = ref(false)
|
||||
const showWhale = ref(false)
|
||||
const showBottle = ref(false)
|
||||
const showLeviathan = ref(false)
|
||||
|
||||
const oceanSeeds = Array.from({ length: 20 }, () => Math.random())
|
||||
let oceanLifeTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function tickOceanLife() {
|
||||
currentHour.value = new Date().getHours()
|
||||
if (!showComet.value && Math.random() < 0.04) {
|
||||
showComet.value = true
|
||||
setTimeout(() => { showComet.value = false }, 3000)
|
||||
}
|
||||
if (!showWhale.value && Math.random() < 0.07) {
|
||||
showWhale.value = true
|
||||
setTimeout(() => { showWhale.value = false }, 95000)
|
||||
}
|
||||
if (!showBottle.value && Math.random() < 0.025) {
|
||||
showBottle.value = true
|
||||
setTimeout(() => { showBottle.value = false }, 125000)
|
||||
}
|
||||
if (!showLeviathan.value && Math.random() < 0.008) {
|
||||
showLeviathan.value = true
|
||||
setTimeout(() => { showLeviathan.value = false }, 310000)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DRAG STATE
|
||||
// ============================================================================
|
||||
@@ -84,6 +142,24 @@ type SizeMode = 'pin' | 'medium' | 'large'
|
||||
const savedSize = localStorage.getItem('transcript-size-mode') as SizeMode | null
|
||||
const sizeMode = ref<SizeMode>(savedSize && ['pin', 'medium', 'large'].includes(savedSize) ? savedSize : 'medium')
|
||||
|
||||
// Readability overlay opacity
|
||||
const savedOverlay = localStorage.getItem('transcript-overlay-opacity')
|
||||
const overlayOpacity = ref(savedOverlay !== null ? parseFloat(savedOverlay) : 0.55)
|
||||
|
||||
function setOverlayOpacity(val: number) {
|
||||
overlayOpacity.value = val
|
||||
localStorage.setItem('transcript-overlay-opacity', String(val))
|
||||
}
|
||||
|
||||
// Input textarea max lines
|
||||
const savedMaxLines = localStorage.getItem('transcript-input-max-lines')
|
||||
const inputMaxLines = ref(savedMaxLines !== null ? parseInt(savedMaxLines) : 6)
|
||||
|
||||
function setInputMaxLines(val: number) {
|
||||
inputMaxLines.value = val
|
||||
localStorage.setItem('transcript-input-max-lines', String(val))
|
||||
}
|
||||
|
||||
// Force mobile (bottom sheet) mode on desktop
|
||||
const forceMobile = ref(false)
|
||||
const effectiveMobile = computed(() => isMobile.value || forceMobile.value)
|
||||
@@ -117,6 +193,18 @@ function cycleSizeMode() {
|
||||
const isMobile = ref(false)
|
||||
const sheetHeight = ref(55)
|
||||
|
||||
// Virtual keyboard detection
|
||||
const keyboardVisible = ref(false)
|
||||
const keyboardHeight = ref(0)
|
||||
|
||||
function onVisualViewportResize() {
|
||||
if (!window.visualViewport) return
|
||||
const vv = window.visualViewport
|
||||
const kbH = window.innerHeight - (vv.offsetTop + vv.height)
|
||||
keyboardHeight.value = Math.max(0, kbH)
|
||||
keyboardVisible.value = kbH > 100
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MOBILE DETECTION
|
||||
// ============================================================================
|
||||
@@ -213,21 +301,34 @@ const sizeModePresets: Record<SizeMode, { w: number; h: number }> = {
|
||||
const windowStyle = computed((): Record<string, string> => {
|
||||
if (effectiveMobile.value) {
|
||||
const isFullScreen = sheetHeight.value >= 100
|
||||
const headerEl = document.querySelector('.app-header') as HTMLElement | null
|
||||
const headerH = headerEl ? headerEl.offsetHeight : 40
|
||||
const style: Record<string, string> = {
|
||||
position: 'fixed',
|
||||
left: '0',
|
||||
right: '0',
|
||||
bottom: '0',
|
||||
width: '100%',
|
||||
}
|
||||
if (isFullScreen) {
|
||||
// Full height but respect app header
|
||||
const headerEl = document.querySelector('.app-header') as HTMLElement | null
|
||||
const headerH = headerEl ? headerEl.offsetHeight : 40
|
||||
style.top = `${headerH}px`
|
||||
style.height = 'auto'
|
||||
|
||||
if (keyboardVisible.value) {
|
||||
// Body is position:fixed so no browser auto-scroll — simple offset works
|
||||
style.bottom = `${keyboardHeight.value}px`
|
||||
const available = window.innerHeight - keyboardHeight.value - headerH
|
||||
if (isFullScreen) {
|
||||
style.height = `${Math.max(0, available)}px`
|
||||
} else {
|
||||
const originalH = window.innerHeight * sheetHeight.value / 100
|
||||
style.height = `${Math.max(0, Math.min(originalH, available))}px`
|
||||
}
|
||||
} else {
|
||||
style.height = `${sheetHeight.value}dvh`
|
||||
// No keyboard
|
||||
style.bottom = '0'
|
||||
if (isFullScreen) {
|
||||
style.top = `${headerH}px`
|
||||
style.height = 'auto'
|
||||
} else {
|
||||
style.height = `${sheetHeight.value}dvh`
|
||||
}
|
||||
}
|
||||
return style
|
||||
}
|
||||
@@ -335,6 +436,7 @@ function handleSessionSelect(sessionId: string) {
|
||||
}
|
||||
|
||||
function handleSend(message: string) {
|
||||
voice.clearTranscript()
|
||||
sendPrompt(message)
|
||||
}
|
||||
|
||||
@@ -373,20 +475,29 @@ function handleZoomKey(e: KeyboardEvent) {
|
||||
// LIFECYCLE
|
||||
// ============================================================================
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
document.addEventListener('keydown', handleZoomKey)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', onVisualViewportResize)
|
||||
}
|
||||
oceanLifeTimer = setInterval(tickOceanLife, 20000)
|
||||
tickOceanLife()
|
||||
await voice.init()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (oceanLifeTimer) clearInterval(oceanLifeTimer)
|
||||
disconnectRealtime()
|
||||
voice.cleanup()
|
||||
document.removeEventListener('keydown', handleZoomKey)
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
document.removeEventListener('mousemove', onResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
window.visualViewport?.removeEventListener('resize', onVisualViewportResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -420,8 +531,49 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- Titlebar -->
|
||||
<div class="titlebar">
|
||||
<!-- Ocean Life Ecosystem -->
|
||||
<div class="ocean-life">
|
||||
<!-- Always visible -->
|
||||
<i class="cr fish-school" :style="{ animationDelay: `-${oceanSeeds[0] * 10}s` }"></i>
|
||||
<i class="cr fish-school-2" :style="{ animationDelay: `-${oceanSeeds[1] * 14}s` }"></i>
|
||||
<i class="cr dolphin" :style="{ animationDelay: `-${oceanSeeds[2] * 12}s` }"></i>
|
||||
<i class="cr jellyfish" :style="{ animationDelay: `-${oceanSeeds[3] * 25}s` }"></i>
|
||||
<i class="cr seahorse" :style="{ animationDelay: `-${oceanSeeds[4] * 35}s` }"></i>
|
||||
<i class="cr turtle" :style="{ animationDelay: `-${oceanSeeds[5] * 50}s` }"></i>
|
||||
<i class="cr pirate-ship" :style="{ animationDelay: `-${oceanSeeds[6] * 180}s` }"></i>
|
||||
<i class="cr bubble b1" :style="{ animationDelay: `-${oceanSeeds[7] * 6}s` }"></i>
|
||||
<i class="cr bubble b2" :style="{ animationDelay: `-${oceanSeeds[8] * 9}s` }"></i>
|
||||
<i class="cr bubble b3" :style="{ animationDelay: `-${oceanSeeds[9] * 11}s` }"></i>
|
||||
<!-- Night only -->
|
||||
<template v-if="isNightTime">
|
||||
<i class="cr satellite" :style="{ animationDelay: `-${oceanSeeds[10] * 15}s` }"></i>
|
||||
<i class="cr anglerfish" :style="{ animationDelay: `-${oceanSeeds[11] * 40}s` }"></i>
|
||||
<i class="cr biolum bl1" :style="{ animationDelay: `-${oceanSeeds[12] * 3.5}s` }"></i>
|
||||
<i class="cr biolum bl2" :style="{ animationDelay: `-${oceanSeeds[13] * 4.5}s` }"></i>
|
||||
<i class="cr biolum bl3" :style="{ animationDelay: `-${oceanSeeds[14] * 5.5}s` }"></i>
|
||||
</template>
|
||||
<!-- Rare random -->
|
||||
<i v-if="showComet" class="cr comet"></i>
|
||||
<i v-if="showWhale" class="cr whale" :style="{ animationDelay: `-${oceanSeeds[15] * 90}s` }"></i>
|
||||
<i v-if="showBottle" class="cr bottle" :style="{ animationDelay: `-${oceanSeeds[16] * 120}s` }"></i>
|
||||
<i v-if="showLeviathan" class="cr leviathan" :style="{ animationDelay: `-${oceanSeeds[17] * 300}s` }"></i>
|
||||
<!-- Time-of-day -->
|
||||
<i v-if="isNoonTime" class="cr sun-ray"></i>
|
||||
<i v-if="isDawnTime" class="cr dawn-glow"></i>
|
||||
<!-- Easter eggs -->
|
||||
<i v-if="isFridayTime" class="cr party-fish" :style="{ animationDelay: `-${oceanSeeds[18] * 8}s` }"></i>
|
||||
<i v-if="isMidnightTime" class="cr ghost-ship" :style="{ animationDelay: `-${oceanSeeds[19] * 240}s` }"></i>
|
||||
</div>
|
||||
<div class="left">
|
||||
<AgentBadge v-if="selectedAgent" :agent="selectedAgent" :connected="isRealtime" />
|
||||
<AgentBadge
|
||||
v-if="selectedAgent"
|
||||
:agent="selectedAgent"
|
||||
:connected="isRealtime"
|
||||
:terminals="openTerminals"
|
||||
:active-session-id="activeTerminalSessionId"
|
||||
@switch-terminal="switchToTerminal"
|
||||
@close-terminal="closeTerminal"
|
||||
/>
|
||||
</div>
|
||||
<div class="window-controls">
|
||||
<button
|
||||
@@ -486,7 +638,7 @@ onBeforeUnmount(() => {
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<AquaticBackground />
|
||||
<div class="readability-overlay" />
|
||||
<div class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
|
||||
<ChatContainer
|
||||
ref="chatRef"
|
||||
v-if="conversation"
|
||||
@@ -500,10 +652,27 @@ onBeforeUnmount(() => {
|
||||
:sessions="sessions"
|
||||
:selected-session-id="selectedSessionId"
|
||||
:sessions-loading="loading"
|
||||
:voice-mode="voiceMode"
|
||||
:whisper-status="whisperStatus"
|
||||
:audio-devices="audioDevices"
|
||||
:selected-device-id="selectedDeviceId"
|
||||
:is-recording="voiceRecording"
|
||||
:voice-transcript="voiceTranscript + voiceInterim"
|
||||
:last-audio-url="lastAudioUrl"
|
||||
:is-playing-audio="isPlayingAudio"
|
||||
:overlay-opacity="overlayOpacity"
|
||||
:input-max-lines="inputMaxLines"
|
||||
@send="handleSend"
|
||||
@switch-agent="handleAgentSwitch"
|
||||
@select-session="handleSessionSelect"
|
||||
@create-session="handleCreateSession"
|
||||
@start-recording="voice.startRecording()"
|
||||
@stop-recording="voice.stopRecording()"
|
||||
@set-voice-mode="voice.setMode($event)"
|
||||
@select-microphone="voice.selectMicrophone($event)"
|
||||
@play-last-audio="voice.playLastAudio()"
|
||||
@update:overlay-opacity="setOverlayOpacity"
|
||||
@update:input-max-lines="setInputMaxLines"
|
||||
/>
|
||||
<div v-else class="empty-state">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
@@ -733,9 +902,12 @@ onBeforeUnmount(() => {
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
user-select: none;
|
||||
transition: opacity 0.35s ease, transform 0.35s ease;
|
||||
animation: titlebar-ocean 8s linear infinite;
|
||||
}
|
||||
|
||||
.left {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -744,6 +916,8 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
@@ -831,7 +1005,6 @@ onBeforeUnmount(() => {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.35s ease;
|
||||
@@ -1148,6 +1321,275 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
OCEAN LIFE ECOSYSTEM
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.ocean-life {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.cr {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
background: transparent;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* ── 1. Fish School (3 orange fish, swim left) ── */
|
||||
.fish-school {
|
||||
top: 16px;
|
||||
animation: swim-left 10s linear infinite;
|
||||
box-shadow:
|
||||
0 0 #f97316, 1px 0 #f97316, -1px 0 #fb923c,
|
||||
5px 3px #f97316, 6px 3px #f97316, 4px 3px #fb923c,
|
||||
3px -2px #f97316, 4px -2px #f97316, 2px -2px #fb923c;
|
||||
}
|
||||
|
||||
/* ── 2. Fish School 2 (2 blue fish, swim right) ── */
|
||||
.fish-school-2 {
|
||||
top: 20px;
|
||||
animation: swim-right 14s linear infinite;
|
||||
box-shadow:
|
||||
0 0 #818cf8, -1px 0 #818cf8, 1px 0 #a5b4fc,
|
||||
-4px 2px #818cf8, -5px 2px #818cf8, -3px 2px #a5b4fc;
|
||||
}
|
||||
|
||||
/* ── 3. Dolphin (grey-blue, arc jump) ── */
|
||||
.dolphin {
|
||||
top: 14px;
|
||||
animation: dolphin-arc 12s linear infinite;
|
||||
box-shadow:
|
||||
0 0 #94a3b8, 1px 0 #94a3b8, 2px 0 #94a3b8, 3px 0 #cbd5e1, -1px 0 #64748b,
|
||||
0 1px #64748b, 1px 1px #64748b, 2px 1px #94a3b8,
|
||||
-1px 2px #94a3b8;
|
||||
}
|
||||
|
||||
/* ── 4. Jellyfish (purple, bob) ── */
|
||||
.jellyfish {
|
||||
top: 18px;
|
||||
animation: swim-left 25s linear infinite, jelly-bob 3s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 -1px #c084fc,
|
||||
-1px 0 #a855f7, 0 0 #a855f7, 1px 0 #a855f7,
|
||||
0 1px #c084fc,
|
||||
-1px 2px #7c3aed, 1px 2px #7c3aed,
|
||||
0 3px #7c3aed;
|
||||
}
|
||||
|
||||
/* ── 5. Seahorse (yellow-orange, slow drift) ── */
|
||||
.seahorse {
|
||||
top: 15px;
|
||||
animation: swim-left 35s linear infinite, jelly-bob 4s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 -1px #fbbf24,
|
||||
0 0 #f59e0b, 1px 0 #f59e0b,
|
||||
0 1px #d97706,
|
||||
0 2px #d97706,
|
||||
1px 3px #b45309;
|
||||
}
|
||||
|
||||
/* ── 6. Turtle (green, slow) ── */
|
||||
.turtle {
|
||||
top: 14px;
|
||||
animation: swim-left 50s linear infinite;
|
||||
box-shadow:
|
||||
2px -1px #4ade80,
|
||||
-1px 0 #16a34a, 0 0 #16a34a, 1px 0 #22c55e, 2px 0 #22c55e, 3px 0 #16a34a,
|
||||
-1px 1px #4ade80, 3px 1px #4ade80;
|
||||
}
|
||||
|
||||
/* ── 7. Pirate Ship (dark silhouette, very slow) ── */
|
||||
.pirate-ship {
|
||||
top: 7px;
|
||||
animation: swim-right 180s linear infinite;
|
||||
box-shadow:
|
||||
3px -3px #334155, 3px -2px #334155, 3px -1px #475569,
|
||||
1px 0 #1e293b, 2px 0 #1e293b, 3px 0 #1e293b, 4px 0 #1e293b, 5px 0 #1e293b,
|
||||
0 1px #1e293b, 1px 1px #1e293b, 2px 1px #1e293b, 3px 1px #1e293b, 4px 1px #1e293b, 5px 1px #1e293b, 6px 1px #1e293b,
|
||||
1px 2px #0f172a, 2px 2px #0f172a, 3px 2px #0f172a, 4px 2px #0f172a, 5px 2px #0f172a;
|
||||
}
|
||||
|
||||
/* ── 8-10. Bubbles (rising white dots) ── */
|
||||
.bubble {
|
||||
animation: bubble-rise 6s linear infinite;
|
||||
box-shadow: 0 0 rgba(255,255,255,0.45);
|
||||
}
|
||||
.bubble.b1 { left: 25%; animation-duration: 6s; }
|
||||
.bubble.b2 { left: 55%; animation-duration: 9s; }
|
||||
.bubble.b3 { left: 80%; animation-duration: 11s; }
|
||||
|
||||
/* ── 11. Satellite (night, metallic + blue panels) ── */
|
||||
.satellite {
|
||||
top: 3px;
|
||||
animation: swim-left 15s linear infinite;
|
||||
box-shadow:
|
||||
-2px 0 #3b82f6, -1px 0 #3b82f6, 0 0 #94a3b8, 1px 0 #94a3b8, 2px 0 #3b82f6, 3px 0 #3b82f6;
|
||||
}
|
||||
|
||||
/* ── 12. Anglerfish (night, dark body + glowing lure) ── */
|
||||
.anglerfish {
|
||||
top: 22px;
|
||||
animation: swim-left 40s linear infinite;
|
||||
box-shadow:
|
||||
-2px -1px #fbbf24, -2px -2px rgba(251,191,36,0.4),
|
||||
0 0 #1e293b, 1px 0 #1e293b, 2px 0 #1e293b, 3px 0 #334155,
|
||||
0 1px #0f172a, 1px 1px #0f172a, 2px 1px #1e293b;
|
||||
}
|
||||
|
||||
/* ── 13-15. Bioluminescence (night, pulsing cyan dots) ── */
|
||||
.biolum {
|
||||
box-shadow: 0 0 #22d3ee;
|
||||
animation: bio-pulse 3.5s ease-in-out infinite;
|
||||
}
|
||||
.biolum.bl1 { top: 19px; left: 20%; animation-duration: 3.5s; }
|
||||
.biolum.bl2 { top: 23px; left: 50%; animation-duration: 4.5s; }
|
||||
.biolum.bl3 { top: 17px; left: 75%; animation-duration: 5.5s; }
|
||||
|
||||
/* ── 16. Comet (rare, fast diagonal streak) ── */
|
||||
.comet {
|
||||
top: 2px;
|
||||
right: 0;
|
||||
animation: comet-streak 2.5s linear forwards;
|
||||
box-shadow:
|
||||
0 0 #fef3c7, -1px 0 #fef3c7,
|
||||
-2px 1px #fde68a, -3px 1px #fde68a,
|
||||
-4px 2px rgba(251,191,36,0.6),
|
||||
-5px 3px rgba(251,191,36,0.3),
|
||||
-6px 4px rgba(251,191,36,0.15);
|
||||
}
|
||||
|
||||
/* ── 17. Whale (rare, large blue silhouette) ── */
|
||||
.whale {
|
||||
top: 14px;
|
||||
animation: swim-left 90s linear infinite;
|
||||
box-shadow:
|
||||
4px -1px #2563eb, 5px -1px #2563eb,
|
||||
1px 0 #1d4ed8, 2px 0 #1d4ed8, 3px 0 #1d4ed8, 4px 0 #2563eb, 5px 0 #2563eb, 6px 0 #2563eb, 7px 0 #1d4ed8,
|
||||
0 1px #1e40af, 1px 1px #1e40af, 2px 1px #1d4ed8, 3px 1px #1d4ed8, 4px 1px #1d4ed8, 5px 1px #1d4ed8, 6px 1px #1d4ed8, 7px 1px #1e40af, 8px 1px #1e40af,
|
||||
2px 2px #1e40af, 3px 2px #1e40af, 4px 2px #1e40af, 5px 2px #1e40af, 6px 2px #1e40af,
|
||||
7px 3px #1e40af;
|
||||
}
|
||||
|
||||
/* ── 18. Bottle (rare, message in a bottle) ── */
|
||||
.bottle {
|
||||
top: 11px;
|
||||
animation: swim-left 120s linear infinite, jelly-bob 5s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 -1px #d97706,
|
||||
-1px 0 #92400e, 0 0 #92400e, 1px 0 #fef3c7,
|
||||
-1px 1px #92400e, 0 1px #92400e;
|
||||
}
|
||||
|
||||
/* ── 19. Leviathan (rare, enormous faint shadow) ── */
|
||||
.leviathan {
|
||||
top: 20px;
|
||||
animation: swim-left 300s linear infinite;
|
||||
opacity: 0.15;
|
||||
box-shadow:
|
||||
2px -1px #1e293b, 3px -1px #1e293b, 4px -1px #1e293b, 5px -1px #1e293b, 6px -1px #1e293b, 7px -1px #1e293b, 8px -1px #1e293b, 9px -1px #1e293b,
|
||||
0 0 #0f172a, 1px 0 #0f172a, 2px 0 #0f172a, 3px 0 #0f172a, 4px 0 #0f172a, 5px 0 #0f172a, 6px 0 #0f172a, 7px 0 #0f172a, 8px 0 #0f172a, 9px 0 #0f172a, 10px 0 #0f172a, 11px 0 #0f172a, 12px 0 #0f172a,
|
||||
1px 1px #0f172a, 2px 1px #0f172a, 3px 1px #0f172a, 4px 1px #0f172a, 5px 1px #0f172a, 6px 1px #0f172a, 7px 1px #0f172a, 8px 1px #0f172a, 9px 1px #0f172a, 10px 1px #0f172a, 11px 1px #0f172a,
|
||||
3px 2px #0f172a, 4px 2px #0f172a, 5px 2px #0f172a, 6px 2px #0f172a, 7px 2px #0f172a, 8px 2px #0f172a, 9px 2px #0f172a;
|
||||
}
|
||||
|
||||
/* ── 20. Sun Ray (noon, golden beam) ── */
|
||||
.sun-ray {
|
||||
top: 0;
|
||||
left: 40%;
|
||||
width: 20px;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, rgba(251,191,36,0.12) 0%, rgba(251,191,36,0.04) 40%, transparent 100%);
|
||||
animation: sun-ray-pulse 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── 21. Dawn Glow (dawn, warm orange) ── */
|
||||
.dawn-glow {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, rgba(251,146,60,0.1) 0%, transparent 40%);
|
||||
animation: sun-ray-pulse 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── 22. Party Fish (Fridays, rainbow nyan) ── */
|
||||
.party-fish {
|
||||
top: 15px;
|
||||
animation: swim-right 8s linear infinite;
|
||||
box-shadow:
|
||||
-4px 0 #ef4444, -3px 0 #f97316, -2px 0 #eab308, -1px 0 #22c55e, 0 0 #3b82f6, 1px 0 #8b5cf6,
|
||||
-3px 1px #f97316, -2px 1px #eab308, -1px 1px #22c55e, 0 1px #3b82f6;
|
||||
}
|
||||
|
||||
/* ── 23. Ghost Ship (midnight, ethereal) ── */
|
||||
.ghost-ship {
|
||||
top: 7px;
|
||||
animation: swim-left 240s linear infinite;
|
||||
opacity: 0.3;
|
||||
box-shadow:
|
||||
3px -3px rgba(255,255,255,0.6), 3px -2px rgba(255,255,255,0.5), 3px -1px rgba(255,255,255,0.4),
|
||||
1px 0 rgba(255,255,255,0.3), 2px 0 rgba(255,255,255,0.3), 3px 0 rgba(255,255,255,0.3), 4px 0 rgba(255,255,255,0.3), 5px 0 rgba(255,255,255,0.3),
|
||||
0 1px rgba(255,255,255,0.2), 1px 1px rgba(255,255,255,0.2), 2px 1px rgba(255,255,255,0.2), 3px 1px rgba(255,255,255,0.2), 4px 1px rgba(255,255,255,0.2), 5px 1px rgba(255,255,255,0.2), 6px 1px rgba(255,255,255,0.2),
|
||||
1px 2px rgba(255,255,255,0.15), 2px 2px rgba(255,255,255,0.15), 3px 2px rgba(255,255,255,0.15), 4px 2px rgba(255,255,255,0.15), 5px 2px rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
/* ── Ocean Life Keyframes ── */
|
||||
|
||||
@keyframes swim-left {
|
||||
from { left: calc(100% + 15px); }
|
||||
to { left: -15px; }
|
||||
}
|
||||
|
||||
@keyframes swim-right {
|
||||
from { left: -15px; }
|
||||
to { left: calc(100% + 15px); }
|
||||
}
|
||||
|
||||
@keyframes dolphin-arc {
|
||||
0% { left: calc(100% + 15px); top: 14px; }
|
||||
35% { top: 14px; }
|
||||
50% { top: 4px; }
|
||||
65% { top: 14px; }
|
||||
100% { left: -15px; top: 14px; }
|
||||
}
|
||||
|
||||
@keyframes jelly-bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-2px); }
|
||||
}
|
||||
|
||||
@keyframes bubble-rise {
|
||||
0% { top: 28px; opacity: 0.5; }
|
||||
80% { opacity: 0.3; }
|
||||
100% { top: -2px; opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes bio-pulse {
|
||||
0%, 100% { opacity: 0; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes comet-streak {
|
||||
0% { top: 1px; left: 100%; opacity: 0.9; }
|
||||
100% { top: 18px; left: -20px; opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes sun-ray-pulse {
|
||||
0%, 100% { opacity: 0.05; }
|
||||
50% { opacity: 0.18; }
|
||||
}
|
||||
|
||||
@keyframes titlebar-ocean {
|
||||
from { background-position: 0 0, 0 0; }
|
||||
to { background-position: 120px 0, 0 0; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -1157,7 +1599,8 @@ onBeforeUnmount(() => {
|
||||
min-width: unset;
|
||||
min-height: unset;
|
||||
max-width: 100%;
|
||||
transition: height 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: height 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
bottom 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.aero-win.mobile .glass {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import type { TerminalSlot } from '@/types/transcript-debug'
|
||||
|
||||
defineProps<{
|
||||
agent: string
|
||||
connected: boolean
|
||||
terminals: TerminalSlot[]
|
||||
activeSessionId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'switch-terminal': [sessionId: string]
|
||||
'close-terminal': [sessionId: string]
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
@@ -21,6 +29,22 @@ function onClickOutside(e: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleSwitch(sessionId: string) {
|
||||
emit('switch-terminal', sessionId)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function handleClose(e: Event, sessionId: string) {
|
||||
e.stopPropagation()
|
||||
emit('close-terminal', sessionId)
|
||||
}
|
||||
|
||||
function slotColor(t: TerminalSlot): string {
|
||||
if (!t.alive) return '#f87171' // PTY dead → red
|
||||
if (t.clients > 0) return '#4ade80' // alive + connected → green
|
||||
return '#fbbf24' // alive, no clients (parked) → orange
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', onClickOutside))
|
||||
onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
||||
</script>
|
||||
@@ -28,6 +52,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
||||
<template>
|
||||
<div ref="wrapperRef" class="agent-badge-wrapper" :class="{ connected }" @click="toggle">
|
||||
<span class="agent-label">{{ agent }}</span>
|
||||
<span v-if="terminals.length > 1" class="term-count">{{ terminals.length }}</span>
|
||||
<svg class="caret" :class="{ open: isOpen }" width="6" height="6" viewBox="0 0 6 6" shape-rendering="crispEdges">
|
||||
<rect x="2" y="4" width="2" height="2" fill="currentColor"/>
|
||||
<rect x="0" y="2" width="2" height="2" fill="currentColor"/>
|
||||
@@ -35,7 +60,23 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
||||
</svg>
|
||||
<Transition name="dropdown">
|
||||
<div v-if="isOpen" class="dropdown">
|
||||
<div class="dropdown-item todo">TODO</div>
|
||||
<div v-if="terminals.length === 0" class="dropdown-item empty">No terminals</div>
|
||||
<div
|
||||
v-for="t in terminals"
|
||||
:key="t.sessionId"
|
||||
class="dropdown-item terminal-item"
|
||||
:class="{ active: t.sessionId === activeSessionId }"
|
||||
@click.stop="handleSwitch(t.sessionId)"
|
||||
>
|
||||
<span class="state-dot" :style="{ background: slotColor(t) }" />
|
||||
<span class="terminal-label">{{ t.label }}</span>
|
||||
<button class="close-btn" @click="handleClose($event, t.sessionId)" title="Close terminal">
|
||||
<svg width="6" height="6" viewBox="0 0 6 6">
|
||||
<line x1="0" y1="0" x2="6" y2="6" stroke="currentColor" stroke-width="1.2"/>
|
||||
<line x1="6" y1="0" x2="0" y2="6" stroke="currentColor" stroke-width="1.2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -84,6 +125,23 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.term-count {
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: rgba(165, 180, 252, 0.6);
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
padding: 0 3px;
|
||||
min-width: 12px;
|
||||
text-align: center;
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.connected .term-count {
|
||||
color: rgba(134, 239, 172, 0.6);
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
|
||||
.caret {
|
||||
color: rgba(165, 180, 252, 0.5);
|
||||
transition: transform 0.2s ease;
|
||||
@@ -101,13 +159,16 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 120px;
|
||||
min-width: 180px;
|
||||
max-width: 260px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
background: rgba(8, 8, 18, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
z-index: 100;
|
||||
padding: 4px 0;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
@@ -116,12 +177,69 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
||||
font-family: 'Courier New', monospace;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dropdown-item.todo {
|
||||
.dropdown-item.empty {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dropdown-item.terminal-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.dropdown-item.terminal-item:hover {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
.dropdown-item.terminal-item.active {
|
||||
background: rgba(99, 102, 241, 0.18);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.state-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.terminal-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.dropdown-item.terminal-item:hover .close-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #fca5a5;
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
@@ -138,4 +256,17 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onClickOutside))
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.dropdown::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.dropdown::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dropdown::-webkit-scrollbar-thumb {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,6 +27,16 @@ const props = defineProps<{
|
||||
sessions?: { id: string; firstUserMessage?: string }[]
|
||||
selectedSessionId?: string | null
|
||||
sessionsLoading?: boolean
|
||||
voiceMode?: 'web' | 'whisper'
|
||||
whisperStatus?: 'offline' | 'loading' | 'ready'
|
||||
audioDevices?: MediaDeviceInfo[]
|
||||
selectedDeviceId?: string
|
||||
isRecording?: boolean
|
||||
voiceTranscript?: string
|
||||
lastAudioUrl?: string
|
||||
isPlayingAudio?: boolean
|
||||
overlayOpacity?: number
|
||||
inputMaxLines?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -34,6 +44,13 @@ const emit = defineEmits<{
|
||||
switchAgent: [agent: AgentName]
|
||||
selectSession: [sessionId: string]
|
||||
createSession: []
|
||||
startRecording: []
|
||||
stopRecording: []
|
||||
setVoiceMode: [mode: 'web' | 'whisper']
|
||||
selectMicrophone: [deviceId: string]
|
||||
playLastAudio: []
|
||||
'update:overlayOpacity': [value: number]
|
||||
'update:inputMaxLines': [value: number]
|
||||
}>()
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
@@ -286,6 +303,71 @@ function formatDuration(start: string, end: string): string {
|
||||
:terminal="terminal ?? null"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="voiceMode" class="selector-row">
|
||||
<label class="selector-label">Mic</label>
|
||||
<select
|
||||
class="session-select"
|
||||
:value="selectedDeviceId || ''"
|
||||
@change="emit('selectMicrophone', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="" disabled>Select mic...</option>
|
||||
<option v-for="d in audioDevices" :key="d.deviceId" :value="d.deviceId">
|
||||
{{ d.label || 'Microphone ' + d.deviceId.slice(0, 8) }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
class="mode-toggle-btn"
|
||||
:class="{ gpu: voiceMode === 'whisper' }"
|
||||
@click="emit('setVoiceMode', voiceMode === 'whisper' ? 'web' : 'whisper')"
|
||||
:title="voiceMode === 'whisper' ? 'Switch to Web Speech' : 'Switch to Whisper GPU'"
|
||||
>
|
||||
{{ voiceMode === 'whisper' ? 'GPU' : 'WEB' }}
|
||||
</button>
|
||||
<span
|
||||
class="whisper-dot"
|
||||
:class="whisperStatus"
|
||||
:title="'Whisper: ' + (whisperStatus || 'offline')"
|
||||
></span>
|
||||
<button
|
||||
v-if="lastAudioUrl"
|
||||
class="play-audio-btn"
|
||||
:class="{ playing: isPlayingAudio }"
|
||||
@click="emit('playLastAudio')"
|
||||
title="Play last recording"
|
||||
>
|
||||
<svg v-if="!isPlayingAudio" width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
<svg v-else width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="6" y="4" width="4" height="16"/>
|
||||
<rect x="14" y="4" width="4" height="16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="selector-row">
|
||||
<label class="selector-label">Overlay</label>
|
||||
<input
|
||||
type="range"
|
||||
class="overlay-slider"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="Math.round((overlayOpacity ?? 0.55) * 100)"
|
||||
@input="emit('update:overlayOpacity', +($event.target as HTMLInputElement).value / 100)"
|
||||
/>
|
||||
<span class="overlay-value">{{ Math.round((overlayOpacity ?? 0.55) * 100) }}%</span>
|
||||
</div>
|
||||
<div class="selector-row">
|
||||
<label class="selector-label">Lines</label>
|
||||
<input
|
||||
type="range"
|
||||
class="overlay-slider"
|
||||
min="1"
|
||||
max="12"
|
||||
:value="inputMaxLines ?? 6"
|
||||
@input="emit('update:inputMaxLines', +($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<span class="overlay-value">{{ inputMaxLines ?? 6 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="scrollContainer" class="messages-scroll">
|
||||
<template
|
||||
@@ -367,7 +449,14 @@ function formatDuration(start: string, end: string): string {
|
||||
<UserInput
|
||||
:processing="props.processing"
|
||||
:terminal-ready="props.terminalReady"
|
||||
:voice-transcript="voiceTranscript"
|
||||
:is-recording="isRecording"
|
||||
:voice-mode="voiceMode"
|
||||
:whisper-status="whisperStatus"
|
||||
:max-lines="props.inputMaxLines"
|
||||
@send="emit('send', $event)"
|
||||
@start-recording="emit('startRecording')"
|
||||
@stop-recording="emit('stopRecording')"
|
||||
/>
|
||||
|
||||
<div class="status-bar">
|
||||
@@ -792,4 +881,124 @@ function formatDuration(start: string, end: string): string {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Mic settings row ── */
|
||||
.mode-toggle-btn {
|
||||
padding: 2px 6px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted, rgba(255,255,255,0.4));
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mode-toggle-btn:hover {
|
||||
background: var(--bg-hover, rgba(255,255,255,0.06));
|
||||
color: var(--text-secondary, rgba(255,255,255,0.7));
|
||||
}
|
||||
|
||||
.mode-toggle-btn.gpu {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.whisper-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.whisper-dot.offline {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.whisper-dot.loading {
|
||||
background: #f59e0b;
|
||||
animation: pulse-dot 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
40% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.whisper-dot.ready {
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 6px rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
.play-audio-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted, rgba(255,255,255,0.4));
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.play-audio-btn:hover {
|
||||
background: var(--bg-hover, rgba(255,255,255,0.06));
|
||||
color: var(--text-primary, rgba(255,255,255,0.8));
|
||||
}
|
||||
|
||||
.play-audio-btn.playing {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
/* Overlay slider */
|
||||
.overlay-slider {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--border-color, rgba(255,255,255,0.08));
|
||||
border-radius: 0;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overlay-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #0ea5e9;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overlay-slider::-moz-range-thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #0ea5e9;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.overlay-value {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: var(--text-muted, rgba(255,255,255,0.4));
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import VoiceMicButton from './VoiceMicButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
processing?: boolean
|
||||
terminalReady?: boolean
|
||||
voiceTranscript?: string
|
||||
isRecording?: boolean
|
||||
voiceMode?: 'web' | 'whisper'
|
||||
whisperStatus?: 'offline' | 'loading' | 'ready'
|
||||
maxLines?: number
|
||||
}>()
|
||||
|
||||
const maxH = computed(() => {
|
||||
const lines = props.maxLines ?? 6
|
||||
// line-height is 1.5 at 13px = ~20px per line, plus padding
|
||||
return lines <= 1 ? '20px' : `${lines * 20}px`
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
send: [message: string]
|
||||
startRecording: []
|
||||
stopRecording: []
|
||||
}>()
|
||||
|
||||
const input = ref('')
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
function adjustHeight() {
|
||||
const el = textareaRef.value
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = el.scrollHeight + 'px'
|
||||
}
|
||||
|
||||
const notReady = computed(() => props.terminalReady === false)
|
||||
const isDisabled = computed(() => !input.value.trim() || props.processing || notReady.value)
|
||||
@@ -20,6 +42,7 @@ function handleSend() {
|
||||
if (!msg || props.processing || notReady.value) return
|
||||
emit('send', msg)
|
||||
input.value = ''
|
||||
nextTick(adjustHeight)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
@@ -28,6 +51,16 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
// Fill textarea with voice transcript
|
||||
watch(() => props.voiceTranscript, (newText) => {
|
||||
if (newText && newText.trim()) {
|
||||
input.value = newText
|
||||
}
|
||||
})
|
||||
|
||||
// Auto-grow textarea when content changes
|
||||
watch(input, () => nextTick(adjustHeight))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -46,13 +79,24 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
</div>
|
||||
<div class="input-container" :class="{ disabled: processing || notReady }">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="input"
|
||||
class="input-field"
|
||||
:style="{ maxHeight: maxH }"
|
||||
:placeholder="notReady ? 'Starting terminal...' : processing ? 'Wait for agent to finish...' : 'Continue this conversation...'"
|
||||
rows="1"
|
||||
:disabled="processing || notReady"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<VoiceMicButton
|
||||
v-if="voiceMode"
|
||||
:is-recording="isRecording ?? false"
|
||||
:voice-mode="voiceMode"
|
||||
:whisper-status="whisperStatus ?? 'offline'"
|
||||
:disabled="processing || notReady"
|
||||
@start="emit('startRecording')"
|
||||
@stop="emit('stopRecording')"
|
||||
/>
|
||||
<button
|
||||
class="send-btn"
|
||||
:disabled="isDisabled"
|
||||
@@ -139,7 +183,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
min-height: 20px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
padding: 0.15rem 0.25rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ParsedUserMessage } from '@/types/transcript-debug'
|
||||
import MarkdownContent from './MarkdownContent.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
message: ParsedUserMessage
|
||||
@@ -14,6 +15,57 @@ const emit = defineEmits<{
|
||||
|
||||
const isOptimistic = computed(() => props.message.uuid.startsWith('optimistic-'))
|
||||
|
||||
// ── Command / special message detection ──
|
||||
type CommandInfo =
|
||||
| { type: 'caveat'; text: string }
|
||||
| { type: 'command'; name: string; message: string; args: string }
|
||||
| { type: 'stdout'; text: string }
|
||||
| { type: 'interrupted' }
|
||||
| { type: 'meta-action'; text: string }
|
||||
| null
|
||||
|
||||
const commandInfo = computed<CommandInfo>(() => {
|
||||
const c = props.message.content
|
||||
if (!c) return null
|
||||
|
||||
// [Request interrupted by user ...]
|
||||
if (c.includes('[Request interrupted by user')) {
|
||||
return { type: 'interrupted' }
|
||||
}
|
||||
|
||||
// Meta messages: "Continue from where you left off", etc.
|
||||
if (props.message.isMeta) {
|
||||
return { type: 'meta-action', text: c.trim() }
|
||||
}
|
||||
|
||||
// <local-command-caveat>...</local-command-caveat>
|
||||
const caveatMatch = c.match(/<local-command-caveat>([\s\S]*?)<\/local-command-caveat>/)
|
||||
if (caveatMatch) return { type: 'caveat', text: caveatMatch[1].trim() }
|
||||
|
||||
// <command-name>...</command-name>
|
||||
const cmdNameMatch = c.match(/<command-name>([\s\S]*?)<\/command-name>/)
|
||||
if (cmdNameMatch) {
|
||||
const msgMatch = c.match(/<command-message>([\s\S]*?)<\/command-message>/)
|
||||
const argsMatch = c.match(/<command-args>([\s\S]*?)<\/command-args>/)
|
||||
return {
|
||||
type: 'command',
|
||||
name: cmdNameMatch[1].trim(),
|
||||
message: msgMatch?.[1]?.trim() || '',
|
||||
args: argsMatch?.[1]?.trim() || ''
|
||||
}
|
||||
}
|
||||
|
||||
// <local-command-stdout>...</local-command-stdout>
|
||||
const stdoutMatch = c.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/)
|
||||
if (stdoutMatch !== null && c.includes('<local-command-stdout>')) {
|
||||
return { type: 'stdout', text: stdoutMatch[1].trim() }
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const isCommand = computed(() => commandInfo.value !== null)
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
if (!ts) return ''
|
||||
return new Date(ts).toLocaleTimeString()
|
||||
@@ -21,7 +73,65 @@ function formatTime(ts: string): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['user-divider', { meta: message.isMeta, optimistic: isOptimistic }]">
|
||||
<!-- ═══════ COMMAND MESSAGE (compact) ═══════ -->
|
||||
<div v-if="isCommand" class="cmd-row">
|
||||
<!-- Caveat: system note about local commands -->
|
||||
<template v-if="commandInfo!.type === 'caveat'">
|
||||
<span class="cmd-icon cmd-icon-caveat">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="cmd-label cmd-caveat-text">local command caveat</span>
|
||||
</template>
|
||||
|
||||
<!-- Command invocation -->
|
||||
<template v-else-if="commandInfo!.type === 'command'">
|
||||
<span class="cmd-icon">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
</span>
|
||||
<code class="cmd-name">{{ (commandInfo as any).name }}</code>
|
||||
<span v-if="(commandInfo as any).args" class="cmd-args">{{ (commandInfo as any).args }}</span>
|
||||
</template>
|
||||
|
||||
<!-- Stdout -->
|
||||
<template v-else-if="commandInfo!.type === 'stdout'">
|
||||
<span class="cmd-icon cmd-icon-out">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span v-if="(commandInfo as any).text" class="cmd-stdout">{{ (commandInfo as any).text }}</span>
|
||||
<span v-else class="cmd-empty">no output</span>
|
||||
</template>
|
||||
|
||||
<!-- Interrupted -->
|
||||
<template v-else-if="commandInfo!.type === 'interrupted'">
|
||||
<span class="cmd-icon cmd-icon-interrupted">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<circle cx="12" cy="12" r="10"/><rect x="9" y="9" width="6" height="6" rx="0.5" fill="currentColor" stroke="none"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="cmd-label cmd-interrupted-text">interrupted by user</span>
|
||||
</template>
|
||||
|
||||
<!-- Meta action (continue, etc.) -->
|
||||
<template v-else-if="commandInfo!.type === 'meta-action'">
|
||||
<span class="cmd-icon cmd-icon-meta">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="cmd-label cmd-meta-text">{{ (commandInfo as any).text }}</span>
|
||||
</template>
|
||||
|
||||
<span class="cmd-time">{{ formatTime(message.timestamp) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ NORMAL USER MESSAGE ═══════ -->
|
||||
<div v-else :class="['user-divider', { meta: message.isMeta, optimistic: isOptimistic }]">
|
||||
<div class="divider-line" />
|
||||
<div class="divider-content">
|
||||
<div class="divider-header">
|
||||
@@ -46,25 +156,142 @@ function formatTime(ts: string): string {
|
||||
</button>
|
||||
<span class="timestamp">{{ formatTime(message.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="divider-text">{{ message.content }}</div>
|
||||
<div class="divider-text">
|
||||
<MarkdownContent :content="message.content" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ══════════════════════════════════════
|
||||
Command row — compact inline display
|
||||
══════════════════════════════════════ */
|
||||
.cmd-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
margin: 0.1rem 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.cmd-row:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.cmd-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cmd-icon-caveat { color: #f59e0b; }
|
||||
.cmd-icon-out { color: #64748b; }
|
||||
|
||||
.cmd-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cmd-name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: #818cf8;
|
||||
background: rgba(129, 140, 248, 0.08);
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.cmd-args {
|
||||
font-size: 10px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cmd-stdout {
|
||||
font-size: 10px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cmd-empty {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.cmd-caveat-text {
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cmd-icon-interrupted { color: #ef4444; }
|
||||
.cmd-interrupted-text {
|
||||
color: rgba(239, 68, 68, 0.7);
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.cmd-icon-meta { color: #f59e0b; }
|
||||
.cmd-meta-text {
|
||||
color: rgba(251, 191, 36, 0.65);
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cmd-time {
|
||||
margin-left: auto;
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════
|
||||
Normal user message
|
||||
══════════════════════════════════════ */
|
||||
.user-divider {
|
||||
width: 100%;
|
||||
margin: 0.75rem 0 0.25rem;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(129, 140, 248, 0.2) 10%, rgba(129, 140, 248, 0.2) 90%, transparent);
|
||||
margin-bottom: 0.5rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.divider-content {
|
||||
padding: 0 0.25rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
background: linear-gradient(170deg, rgba(148, 163, 184, 0.10) 0%, rgba(100, 116, 139, 0.15) 50%, rgba(71, 85, 105, 0.12) 100%);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.18);
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 2px 6px rgba(0, 0, 0, 0.12),
|
||||
0 1px 2px rgba(0, 0, 0, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.07),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.divider-header {
|
||||
@@ -103,19 +330,34 @@ function formatTime(ts: string): string {
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.divider-text :deep(.md-content) {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Tighten markdown spacing inside user bubbles */
|
||||
.divider-text :deep(.md-content p) {
|
||||
margin: 0.15em 0;
|
||||
}
|
||||
|
||||
.divider-text :deep(.md-content pre) {
|
||||
margin: 0.4em 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.divider-text :deep(.md-content code) {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Meta messages: dimmed */
|
||||
.user-divider.meta {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.user-divider.meta .divider-line {
|
||||
background: linear-gradient(90deg, transparent, rgba(251, 191, 36, 0.15) 10%, rgba(251, 191, 36, 0.15) 90%, transparent);
|
||||
}
|
||||
|
||||
/* Collapse button */
|
||||
.collapse-btn {
|
||||
display: inline-flex;
|
||||
@@ -155,10 +397,6 @@ function formatTime(ts: string): string {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.user-divider.optimistic .divider-line {
|
||||
background: linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.1) 10%, rgba(99, 102, 241, 0.1) 90%, transparent);
|
||||
}
|
||||
|
||||
.sending-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
191
frontend/src/components/transcript-debug/VoiceMicButton.vue
Normal file
191
frontend/src/components/transcript-debug/VoiceMicButton.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
isRecording: boolean
|
||||
voiceMode: 'web' | 'whisper'
|
||||
whisperStatus: 'offline' | 'loading' | 'ready'
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
start: []
|
||||
stop: []
|
||||
}>()
|
||||
|
||||
// ── Interaction state machine ──
|
||||
// idle → pointerdown → wait 250ms
|
||||
// if pointerup < 250ms → "click" → locked (recording until next press+release)
|
||||
// if 250ms passes → "hold" → ptt (recording until release + 500ms trail)
|
||||
// locked → pointerdown → stopping
|
||||
// stopping → pointerup → stop recording → idle
|
||||
// ptt → pointerup → wait 500ms trail → stop recording → idle
|
||||
|
||||
type Mode = 'idle' | 'locked' | 'ptt' | 'stopping'
|
||||
const mode = ref<Mode>('idle')
|
||||
|
||||
const HOLD_THRESHOLD = 250
|
||||
const TRAIL_BUFFER = 500
|
||||
|
||||
let holdTimer: number | null = null
|
||||
let trailTimer: number | null = null
|
||||
|
||||
function clearTimers() {
|
||||
if (holdTimer) { clearTimeout(holdTimer); holdTimer = null }
|
||||
if (trailTimer) { clearTimeout(trailTimer); trailTimer = null }
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (props.disabled) return
|
||||
e.preventDefault()
|
||||
// Capture pointer so we get pointerup even if cursor leaves button
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
|
||||
if (mode.value === 'idle') {
|
||||
// Start hold detection
|
||||
holdTimer = window.setTimeout(() => {
|
||||
holdTimer = null
|
||||
// Still holding after 250ms → PTT mode
|
||||
mode.value = 'ptt'
|
||||
emit('start')
|
||||
}, HOLD_THRESHOLD)
|
||||
} else if (mode.value === 'locked') {
|
||||
// Second press while locked → prepare to stop on release
|
||||
mode.value = 'stopping'
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (props.disabled) return
|
||||
e.preventDefault()
|
||||
|
||||
if (mode.value === 'idle' && holdTimer) {
|
||||
// Quick click (< 250ms) → lock mode
|
||||
clearTimers()
|
||||
mode.value = 'locked'
|
||||
emit('start')
|
||||
} else if (mode.value === 'ptt') {
|
||||
// Release from PTT → 500ms trailing buffer then stop
|
||||
clearTimers()
|
||||
trailTimer = window.setTimeout(() => {
|
||||
trailTimer = null
|
||||
mode.value = 'idle'
|
||||
emit('stop')
|
||||
}, TRAIL_BUFFER)
|
||||
} else if (mode.value === 'stopping') {
|
||||
// Release from second press → stop immediately
|
||||
clearTimers()
|
||||
mode.value = 'idle'
|
||||
emit('stop')
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="mic-btn"
|
||||
:class="{ recording: isRecording, ptt: mode === 'ptt', disabled }"
|
||||
:disabled="disabled"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointerup="onPointerUp"
|
||||
:title="isRecording ? (mode === 'ptt' ? 'Release to stop' : 'Click to stop') : 'Click or hold to record'"
|
||||
>
|
||||
<!-- Mic icon -->
|
||||
<svg v-if="!isRecording" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
||||
<line x1="12" y1="19" x2="12" y2="23"/>
|
||||
<line x1="8" y1="23" x2="16" y2="23"/>
|
||||
</svg>
|
||||
<!-- Stop icon (square) when recording -->
|
||||
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
||||
</svg>
|
||||
<!-- Mode badge -->
|
||||
<span class="mode-badge" :class="voiceMode">
|
||||
{{ voiceMode === 'whisper' ? 'GPU' : 'WEB' }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mic-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: var(--bg-hover, rgba(255,255,255,0.06));
|
||||
border: 1px solid var(--border-color, rgba(255,255,255,0.08));
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted, #888);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mic-btn:hover:not(:disabled) {
|
||||
background: var(--bg-hover, rgba(255,255,255,0.1));
|
||||
color: var(--text-primary, #ccc);
|
||||
border-color: var(--text-muted, rgba(255,255,255,0.15));
|
||||
}
|
||||
|
||||
.mic-btn.recording {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
color: white;
|
||||
animation: rec-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mic-btn.recording.ptt {
|
||||
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
|
||||
border-color: rgba(249, 115, 22, 0.5);
|
||||
animation: rec-pulse-ptt 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mic-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mode-badge {
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
font-size: 7px;
|
||||
font-weight: 700;
|
||||
font-family: 'Courier New', monospace;
|
||||
padding: 1px 3px;
|
||||
border-radius: 3px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.3px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mode-badge.whisper {
|
||||
background: rgba(16, 185, 129, 0.9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mode-badge.web {
|
||||
background: rgba(59, 130, 246, 0.9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@keyframes rec-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
||||
50% { box-shadow: 0 0 0 4px rgba(239, 68, 68, 0); }
|
||||
}
|
||||
|
||||
@keyframes rec-pulse-ptt {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.5); transform: scale(1); }
|
||||
50% { box-shadow: 0 0 0 5px rgba(249, 115, 22, 0); transform: scale(1.08); }
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
JellyfishDrift,
|
||||
EventOverlay,
|
||||
EdgeFade,
|
||||
PixelLife,
|
||||
} from './layers'
|
||||
|
||||
const { start, stop } = useAquaticEvents()
|
||||
@@ -33,6 +34,7 @@ onBeforeUnmount(() => stop())
|
||||
<BubbleStream />
|
||||
<FishSchool />
|
||||
<JellyfishDrift />
|
||||
<PixelLife />
|
||||
<EventOverlay />
|
||||
<EdgeFade />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useAquaticState } from '../useAquaticState'
|
||||
|
||||
const { activeEventModifiers } = useAquaticState()
|
||||
|
||||
const pixelSeeds = Array.from({ length: 15 }, () => Math.random())
|
||||
|
||||
// All swimming creatures are rare — only stationary ones always visible
|
||||
const showSeahorse = ref(false)
|
||||
const showOctopus = ref(false)
|
||||
const showCrab = ref(false)
|
||||
const showEel = ref(false)
|
||||
const showPufferfish = ref(false)
|
||||
const showShrimp = ref(false)
|
||||
const showNautilus = ref(false)
|
||||
const showSwordfish = ref(false)
|
||||
const showStarfish = ref(false)
|
||||
const showAnchor = ref(false)
|
||||
const showSubmarine = ref(false)
|
||||
const showDiver = ref(false)
|
||||
|
||||
let tickTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function tickPixelLife() {
|
||||
// Common creatures — low chance each tick
|
||||
showSeahorse.value = Math.random() < 0.06
|
||||
showOctopus.value = Math.random() < 0.05
|
||||
showCrab.value = Math.random() < 0.06
|
||||
showEel.value = Math.random() < 0.04
|
||||
showPufferfish.value = Math.random() < 0.05
|
||||
showShrimp.value = Math.random() < 0.06
|
||||
showNautilus.value = Math.random() < 0.04
|
||||
showSwordfish.value = Math.random() < 0.03
|
||||
showStarfish.value = Math.random() < 0.05
|
||||
|
||||
// Very rare creatures
|
||||
showAnchor.value = Math.random() < 0.01
|
||||
showSubmarine.value = Math.random() < 0.008
|
||||
showDiver.value = Math.random() < 0.015
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tickPixelLife()
|
||||
tickTimer = setInterval(tickPixelLife, 60000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (tickTimer) clearInterval(tickTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pixel-life">
|
||||
<!-- Swimming creatures (all rare) -->
|
||||
<i v-if="showSeahorse" class="px seahorse" :style="{ animationDelay: `-${pixelSeeds[0] * 160}s` }"></i>
|
||||
<i v-if="showOctopus" class="px octopus" :style="{ animationDelay: `-${pixelSeeds[1] * 200}s` }"></i>
|
||||
<i v-if="showCrab" class="px crab" :style="{ animationDelay: `-${pixelSeeds[2] * 120}s` }"></i>
|
||||
<i v-if="showEel" class="px eel" :style="{ animationDelay: `-${pixelSeeds[3] * 70}s` }"></i>
|
||||
<i v-if="showPufferfish" class="px pufferfish" :style="{ animationDelay: `-${pixelSeeds[4] * 140}s` }"></i>
|
||||
<i v-if="showShrimp" class="px shrimp" :style="{ animationDelay: `-${pixelSeeds[5] * 50}s` }"></i>
|
||||
<i v-if="showNautilus" class="px nautilus" :style="{ animationDelay: `-${pixelSeeds[6] * 240}s` }"></i>
|
||||
<i v-if="showSwordfish" class="px swordfish" :style="{ animationDelay: `-${pixelSeeds[7] * 25}s` }"></i>
|
||||
<i v-if="showStarfish" class="px starfish-walk" :style="{ animationDelay: `-${pixelSeeds[8] * 360}s` }"></i>
|
||||
|
||||
<!-- Stationary creatures (always visible) -->
|
||||
<i class="px treasure"></i>
|
||||
<i class="px sea-anemone"></i>
|
||||
<i class="px clam"></i>
|
||||
|
||||
<!-- Very rare creatures -->
|
||||
<i v-if="showAnchor" class="px anchor"></i>
|
||||
<i v-if="showSubmarine" class="px submarine" :style="{ animationDelay: `-${pixelSeeds[11] * 320}s` }"></i>
|
||||
<i v-if="showDiver" class="px diver" :style="{ animationDelay: `-${pixelSeeds[12] * 180}s` }"></i>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
Pixel Life — box-shadow pixel art creatures for the main background
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
.pixel-life {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.px {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
background: transparent;
|
||||
font-style: normal;
|
||||
transform: scale(3);
|
||||
transform-origin: center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* ── Keyframes ── */
|
||||
|
||||
@keyframes px-swim-left {
|
||||
from { left: calc(100% + 20px); }
|
||||
to { left: -20px; }
|
||||
}
|
||||
|
||||
@keyframes px-swim-right {
|
||||
from { left: -20px; }
|
||||
to { left: calc(100% + 20px); }
|
||||
}
|
||||
|
||||
@keyframes px-bob {
|
||||
0%, 100% { transform: scale(3) translateY(0); }
|
||||
50% { transform: scale(3) translateY(-4px); }
|
||||
}
|
||||
|
||||
@keyframes px-sine {
|
||||
0% { transform: scale(3) translateY(0); }
|
||||
25% { transform: scale(3) translateY(-6px); }
|
||||
50% { transform: scale(3) translateY(0); }
|
||||
75% { transform: scale(3) translateY(6px); }
|
||||
100% { transform: scale(3) translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes px-sink {
|
||||
from { top: -10px; }
|
||||
to { top: calc(100% + 10px); }
|
||||
}
|
||||
|
||||
@keyframes px-puff {
|
||||
0%, 100% { transform: scale(3); }
|
||||
50% { transform: scale(4.5); }
|
||||
}
|
||||
|
||||
@keyframes px-pulse-glow {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes px-tentacle {
|
||||
0%, 100% { transform: scale(3) translateY(0) skewX(0deg); }
|
||||
25% { transform: scale(3) translateY(-2px) skewX(2deg); }
|
||||
75% { transform: scale(3) translateY(2px) skewX(-2deg); }
|
||||
}
|
||||
|
||||
@keyframes px-clam-open {
|
||||
0%, 100% { transform: scale(3) scaleY(1); }
|
||||
50% { transform: scale(3) scaleY(0.5); }
|
||||
}
|
||||
|
||||
/* ── 1. Seahorse — yellow-orange, gentle bob, swims left ── */
|
||||
.seahorse {
|
||||
top: 40%;
|
||||
animation:
|
||||
px-swim-left 160s linear infinite,
|
||||
px-bob 8s ease-in-out infinite;
|
||||
box-shadow:
|
||||
1px -2px #fbbf24, 2px -2px #fbbf24,
|
||||
0 -1px #f59e0b, 1px -1px #f59e0b, 2px -1px #f59e0b,
|
||||
0 0 #f59e0b, 1px 0 #d97706,
|
||||
0 1px #d97706, 1px 1px #d97706,
|
||||
0 2px #b45309, 1px 2px #b45309,
|
||||
-1px 3px #b45309, 0 3px #92400e,
|
||||
-1px 4px #92400e;
|
||||
}
|
||||
|
||||
/* ── 2. Octopus — purple-red, 4 trailing tentacles, swims right ── */
|
||||
.octopus {
|
||||
top: 55%;
|
||||
animation: px-swim-right 200s linear infinite;
|
||||
box-shadow:
|
||||
1px -1px #a855f7, 2px -1px #a855f7,
|
||||
0 0 #9333ea, 1px 0 #9333ea, 2px 0 #9333ea, 3px 0 #a855f7,
|
||||
0 1px #7c3aed, 1px 1px #7c3aed, 2px 1px #7c3aed, 3px 1px #7c3aed,
|
||||
-1px 2px #6d28d9, 0 2px #7c3aed, 2px 2px #7c3aed, 3px 2px #6d28d9,
|
||||
-1px 3px #5b21b6, 1px 3px #6d28d9, 3px 3px #5b21b6,
|
||||
-2px 4px #4c1d95, 0 4px #5b21b6, 2px 4px #5b21b6, 4px 4px #4c1d95;
|
||||
}
|
||||
|
||||
/* ── 3. Crab — red-orange, walking along bottom ── */
|
||||
.crab {
|
||||
top: 85%;
|
||||
animation: px-swim-right 120s linear infinite;
|
||||
box-shadow:
|
||||
-2px -1px #dc2626, 4px -1px #dc2626,
|
||||
-2px 0 #ef4444, -1px 0 #ef4444, 4px 0 #ef4444, 3px 0 #ef4444,
|
||||
0 0 #b91c1c, 1px 0 #dc2626, 2px 0 #dc2626,
|
||||
0 1px #991b1b, 1px 1px #b91c1c, 2px 1px #b91c1c,
|
||||
-1px 2px #7f1d1d, 0 2px #991b1b, 2px 2px #991b1b, 3px 2px #7f1d1d;
|
||||
}
|
||||
|
||||
/* ── 4. Eel — green-dark, sinuous, swims left ── */
|
||||
.eel {
|
||||
top: 45%;
|
||||
animation:
|
||||
px-swim-left 70s linear infinite,
|
||||
px-sine 4s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 0 #16a34a, 1px 0 #15803d, 2px 0 #166534, 3px 0 #14532d,
|
||||
4px 0 #166534, 5px 0 #15803d, 6px 0 #16a34a, 7px 0 #15803d,
|
||||
1px 1px #22c55e, 2px 1px #22c55e, 3px 1px #22c55e, 5px 1px #22c55e, 6px 1px #22c55e,
|
||||
0 -1px #fbbf24;
|
||||
}
|
||||
|
||||
/* ── 5. Pufferfish — yellow, inflates via scale pulse ── */
|
||||
.pufferfish {
|
||||
top: 35%;
|
||||
animation:
|
||||
px-swim-left 140s linear infinite,
|
||||
px-puff 10s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 -1px #fbbf24, 1px -1px #fbbf24,
|
||||
-1px 0 #f59e0b, 0 0 #eab308, 1px 0 #eab308, 2px 0 #f59e0b,
|
||||
-1px 1px #f59e0b, 0 1px #eab308, 1px 1px #eab308, 2px 1px #f59e0b,
|
||||
0 2px #d97706, 1px 2px #d97706,
|
||||
-2px 0 #ca8a04, 3px 0 #ca8a04, 0 -2px #ca8a04, 1px -2px #ca8a04,
|
||||
0 0 #1e293b;
|
||||
}
|
||||
|
||||
/* ── 6. Shrimp — pink-red, dart near bottom ── */
|
||||
.shrimp {
|
||||
top: 80%;
|
||||
animation: px-swim-right 50s linear infinite;
|
||||
box-shadow:
|
||||
0 0 #fb7185, 1px 0 #f43f5e, 2px 0 #e11d48, 3px 0 #be123c,
|
||||
0 1px #fda4af, 1px 1px #fb7185, 2px 1px #f43f5e,
|
||||
-1px -1px rgba(251,113,133,0.6), -2px -2px rgba(251,113,133,0.3),
|
||||
4px 0 #9f1239, 5px -1px #881337;
|
||||
}
|
||||
|
||||
/* ── 7. Nautilus — cream-brown spiral shell, swims left ── */
|
||||
.nautilus {
|
||||
top: 50%;
|
||||
animation:
|
||||
px-swim-left 240s linear infinite,
|
||||
px-bob 12s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 -1px #d4a574, 1px -1px #c2956a,
|
||||
-1px 0 #d4a574, 0 0 #b08050, 1px 0 #a0714a, 2px 0 #c2956a,
|
||||
-1px 1px #c2956a, 0 1px #8b6040, 1px 1px #a0714a, 2px 1px #b08050,
|
||||
0 2px #c2956a, 1px 2px #d4a574,
|
||||
-2px 1px #fef3c7, -2px 2px #fde68a;
|
||||
}
|
||||
|
||||
/* ── 8. Swordfish — silver-blue, fast streak ── */
|
||||
.swordfish {
|
||||
top: 25%;
|
||||
animation: px-swim-left 25s linear infinite;
|
||||
box-shadow:
|
||||
-3px 0 #94a3b8, -2px 0 #94a3b8,
|
||||
-1px 0 #64748b, 0 0 #475569, 1px 0 #475569,
|
||||
2px 0 #334155, 3px 0 #334155, 4px 0 #475569, 5px 0 #64748b,
|
||||
1px 1px #94a3b8, 2px 1px #94a3b8, 3px 1px #94a3b8, 4px 1px #94a3b8,
|
||||
2px -1px #334155, 3px -1px #334155,
|
||||
6px -1px #475569, 6px 1px #475569;
|
||||
}
|
||||
|
||||
/* ── 9. Starfish — orange, very slow crawl along floor ── */
|
||||
.starfish-walk {
|
||||
top: 88%;
|
||||
animation: px-swim-right 360s linear infinite;
|
||||
box-shadow:
|
||||
0 0 #f97316, 1px 0 #ea580c,
|
||||
0 -2px #fb923c, 1px -2px #fb923c,
|
||||
-2px 0 #fb923c, 3px 0 #fb923c,
|
||||
-1px 2px #fb923c, 2px 2px #fb923c,
|
||||
0 -1px #f97316, 1px -1px #f97316,
|
||||
-1px 0 #f97316, 2px 0 #f97316,
|
||||
0 1px #ea580c, 1px 1px #ea580c,
|
||||
-1px 1px #f97316, 2px 1px #f97316;
|
||||
}
|
||||
|
||||
/* ── 10. Anchor — grey iron, sinks slowly (very rare) ── */
|
||||
.anchor {
|
||||
left: 70%;
|
||||
animation: px-sink 100s linear forwards;
|
||||
box-shadow:
|
||||
0 -3px #6b7280, 1px -3px #6b7280,
|
||||
-1px -2px #6b7280, 2px -2px #6b7280,
|
||||
0 -2px #9ca3af, 1px -2px #9ca3af,
|
||||
0 -1px #4b5563, 1px -1px #4b5563,
|
||||
0 0 #4b5563, 1px 0 #4b5563,
|
||||
0 1px #4b5563, 1px 1px #4b5563,
|
||||
0 2px #374151, 1px 2px #374151,
|
||||
-2px 2px #6b7280, -1px 2px #4b5563, 2px 2px #4b5563, 3px 2px #6b7280,
|
||||
-2px 3px #374151, 3px 3px #374151,
|
||||
-1px 3px #4b5563, 2px 3px #4b5563;
|
||||
}
|
||||
|
||||
/* ── 11. Treasure — brown chest with gold gleam (stationary) ── */
|
||||
.treasure {
|
||||
top: 90%;
|
||||
left: 25%;
|
||||
animation: px-pulse-glow 10s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 -1px #92400e, 1px -1px #92400e, 2px -1px #92400e, 3px -1px #92400e,
|
||||
-1px 0 #78350f, 0 0 #78350f, 1px 0 #92400e, 2px 0 #92400e, 3px 0 #78350f, 4px 0 #78350f,
|
||||
-1px 1px #78350f, 0 1px #78350f, 1px 1px #92400e, 2px 1px #92400e, 3px 1px #78350f, 4px 1px #78350f,
|
||||
1px 0 #fbbf24, 2px 0 #fbbf24,
|
||||
1px -1px #fde68a;
|
||||
}
|
||||
|
||||
/* ── 12. Submarine — grey with yellow light (very rare), swims right ── */
|
||||
.submarine {
|
||||
top: 30%;
|
||||
animation: px-swim-right 320s linear infinite;
|
||||
box-shadow:
|
||||
3px -2px #6b7280, 3px -1px #6b7280,
|
||||
0 0 #4b5563, 1px 0 #4b5563, 2px 0 #4b5563, 3px 0 #4b5563, 4px 0 #4b5563, 5px 0 #4b5563,
|
||||
-1px 1px #374151, 0 1px #374151, 1px 1px #374151, 2px 1px #374151, 3px 1px #374151, 4px 1px #374151, 5px 1px #374151, 6px 1px #374151,
|
||||
0 2px #4b5563, 1px 2px #4b5563, 2px 2px #4b5563, 3px 2px #4b5563, 4px 2px #4b5563, 5px 2px #4b5563,
|
||||
1px 1px #fbbf24, 4px 1px #fbbf24,
|
||||
-2px 0 #9ca3af, -2px 1px #9ca3af, -2px 2px #9ca3af;
|
||||
}
|
||||
|
||||
/* ── 13. Diver — black suit, white mask, bubble trail (very rare) ── */
|
||||
.diver {
|
||||
top: 40%;
|
||||
animation: px-swim-left 180s linear infinite;
|
||||
box-shadow:
|
||||
0 -1px #f8fafc, 1px -1px #f8fafc,
|
||||
0 0 #1e293b, 1px 0 #1e293b,
|
||||
0 1px #1e293b, 1px 1px #1e293b,
|
||||
0 2px #0f172a, 1px 2px #0f172a,
|
||||
-1px 3px #1e293b, 2px 3px #1e293b,
|
||||
2px 0 #475569, 2px 1px #475569,
|
||||
-1px -2px rgba(255,255,255,0.5), -2px -3px rgba(255,255,255,0.3), -3px -4px rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
/* ── 14. Sea Anemone — pink-purple, tentacle wave (stationary) ── */
|
||||
.sea-anemone {
|
||||
top: 92%;
|
||||
left: 60%;
|
||||
animation: px-tentacle 12s ease-in-out infinite;
|
||||
box-shadow:
|
||||
-1px -2px #e879f9, 0 -2px #d946ef, 1px -2px #c026d3, 2px -2px #e879f9,
|
||||
-1px -1px #d946ef, 0 -1px #c026d3, 1px -1px #d946ef, 2px -1px #c026d3,
|
||||
0 0 #a21caf, 1px 0 #a21caf,
|
||||
0 1px #86198f, 1px 1px #86198f;
|
||||
}
|
||||
|
||||
/* ── 15. Clam — grey shell, pearl gleam inside (stationary) ── */
|
||||
.clam {
|
||||
top: 90%;
|
||||
left: 82%;
|
||||
animation: px-clam-open 16s ease-in-out infinite;
|
||||
box-shadow:
|
||||
0 -1px #9ca3af, 1px -1px #9ca3af, 2px -1px #9ca3af,
|
||||
-1px 0 #6b7280, 0 0 #6b7280, 1px 0 #6b7280, 2px 0 #6b7280, 3px 0 #6b7280,
|
||||
-1px 1px #4b5563, 0 1px #4b5563, 1px 1px #4b5563, 2px 1px #4b5563, 3px 1px #4b5563,
|
||||
0 2px #6b7280, 1px 2px #6b7280, 2px 2px #6b7280,
|
||||
1px 0 #fef3c7;
|
||||
}
|
||||
</style>
|
||||
@@ -7,3 +7,4 @@ export { default as FishSchool } from './FishSchool.vue'
|
||||
export { default as JellyfishDrift } from './JellyfishDrift.vue'
|
||||
export { default as EventOverlay } from './EventOverlay.vue'
|
||||
export { default as EdgeFade } from './EdgeFade.vue'
|
||||
export { default as PixelLife } from './PixelLife.vue'
|
||||
|
||||
@@ -14,4 +14,5 @@ export { default as PlanApproval } from './PlanApproval.vue'
|
||||
export { default as CodeBlock } from './CodeBlock.vue'
|
||||
export { default as AgentBadge } from './AgentBadge.vue'
|
||||
export { default as ResumeTerminalButton } from './ResumeTerminalButton.vue'
|
||||
export { default as VoiceMicButton } from './VoiceMicButton.vue'
|
||||
export { AquaticBackground } from './aquaticBackground'
|
||||
|
||||
Reference in New Issue
Block a user