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:
2026-02-20 12:12:53 -06:00
parent b7f03a777b
commit 220d595568
19 changed files with 2221 additions and 358 deletions

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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;

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'

View File

@@ -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'