Files
agent-ui/frontend/src/components/FloatingTranscriptDebug.vue
josedario87 a56796a1be feat: unified hook notifier, agent auto-detection, terminal transition UI
- Add hooks/notify.ps1 as single hook handler for all events
- Refactor settings.local.json to use notify.ps1 instead of inline PS
- Add Notification hook, auto-detect agent from session_id/transcript
- Rename agent 'main' to 'claude' across server routes and terminal
- Add loading overlay and error state for terminal switching transitions
- Add transitionError ref to useTranscriptDebug composable
2026-02-21 04:33:42 -06:00

1966 lines
72 KiB
Vue

<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, NewSessionModal } from '@/components/transcript-debug'
import type { AgentName } from '@/types/transcript-debug'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// ============================================================================
// TRANSCRIPT DEBUG STATE (composable)
// ============================================================================
const {
selectedAgent,
sessions,
selectedSessionId,
conversation,
loading,
transitioning,
transitionError,
error,
isRealtime,
processing,
ephemeral,
terminalReady,
hookMeta,
openTerminals,
activeTerminalSessionId,
init,
switchAgent,
selectSession,
createNewSession,
startTerminal,
parkCurrentTerminal,
fetchSessionContent,
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' },
{ id: 'claude', label: 'Claude' }
]
const showSelector = ref(false)
const showNewSessionModal = ref(false)
const chatRef = ref<InstanceType<typeof ChatContainer> | null>(null)
let initialized = false
// ============================================================================
// CHROME VISIBILITY (idle mode: hide UI chrome when not interacting)
// ============================================================================
const isHovered = ref(false)
const isFocusWithin = ref(false)
const showChrome = computed(() =>
isHovered.value || isFocusWithin.value || isDragging.value ||
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
// ============================================================================
const windowRef = ref<HTMLElement | null>(null)
const isDragging = ref(false)
const position = ref({ x: 0, y: 0 })
const hasCustomPosition = ref(false)
const dragOffset = ref({ x: 0, y: 0 })
// Resize state
const isResizing = ref(false)
const size = ref({ w: 480, h: 600 })
// Zoom level for content scaling
const zoom = ref(1)
// Size mode: pin (small, anchored to FAB), medium (default), large
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))
}
// Scroll nav mode: 'scrollbar' | 'buttons' | 'none'
type ScrollNavMode = 'scrollbar' | 'buttons' | 'none'
const savedScrollNav = localStorage.getItem('transcript-scroll-nav') as ScrollNavMode | null
const scrollNavMode = ref<ScrollNavMode>(
savedScrollNav && ['scrollbar', 'buttons', 'none'].includes(savedScrollNav) ? savedScrollNav : 'buttons'
)
function setScrollNavMode(val: ScrollNavMode) {
scrollNavMode.value = val
localStorage.setItem('transcript-scroll-nav', val)
}
// Scroll jump percent
const savedScrollJump = localStorage.getItem('transcript-scroll-jump')
const scrollJumpPercent = ref(savedScrollJump !== null ? parseInt(savedScrollJump) : 50)
function setScrollJumpPercent(val: number) {
scrollJumpPercent.value = val
localStorage.setItem('transcript-scroll-jump', String(val))
}
function scrollJump(direction: 'up' | 'down') {
const el = (chatRef.value as any)?.$el?.querySelector('.messages-scroll') as HTMLElement | null
if (!el) return
const amount = el.clientHeight * (scrollJumpPercent.value / 100)
el.scrollBy({ top: direction === 'up' ? -amount : amount, behavior: 'smooth' })
}
function scrollToEdge(edge: 'top' | 'bottom') {
const el = (chatRef.value as any)?.$el?.querySelector('.messages-scroll') as HTMLElement | null
if (!el) return
el.scrollTo({ top: edge === 'top' ? 0 : el.scrollHeight, behavior: 'smooth' })
}
// Force mobile (bottom sheet) mode on desktop
const forceMobile = ref(false)
const effectiveMobile = computed(() => isMobile.value || forceMobile.value)
function toggleForceMobile() {
if (isMobile.value) return // already native mobile
forceMobile.value = !forceMobile.value
if (forceMobile.value) {
// Enter mobile: set sheet height based on current sizeMode
const mobileSnaps: Record<SizeMode, number> = { pin: 20, medium: 55, large: 100 }
sheetHeight.value = mobileSnaps[sizeMode.value]
hasCustomPosition.value = false
}
}
function cycleSizeMode() {
const modes: SizeMode[] = ['pin', 'medium', 'large']
const i = modes.indexOf(sizeMode.value)
sizeMode.value = modes[(i + 1) % modes.length]
localStorage.setItem('transcript-size-mode', sizeMode.value)
hasCustomPosition.value = false
// In mobile mode, also update sheet height
if (effectiveMobile.value) {
const mobileSnaps: Record<SizeMode, number> = { pin: 20, medium: 55, large: 100 }
sheetHeight.value = mobileSnaps[sizeMode.value]
}
}
// Mobile bottom sheet state
const isMobile = ref(false)
const sheetHeight = ref(55)
// ============================================================================
// MOBILE DETECTION
// ============================================================================
function checkMobile() {
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0
const isSmallScreen = window.innerWidth <= 1024
isMobile.value = isTouchDevice && isSmallScreen
}
// ============================================================================
// WINDOW DRAG
// ============================================================================
function startDrag(e: MouseEvent) {
if (effectiveMobile.value) return
isDragging.value = true
const rect = windowRef.value?.getBoundingClientRect()
if (rect) {
if (!hasCustomPosition.value) {
position.value = { x: rect.left, y: rect.top }
}
dragOffset.value = { x: e.clientX - rect.left, y: e.clientY - rect.top }
}
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
}
function onDrag(e: MouseEvent) {
if (!isDragging.value) return
const w = windowRef.value?.offsetWidth || 480
const h = windowRef.value?.offsetHeight || 600
const minX = -w * 0.75
const maxX = window.innerWidth - w * 0.25
const minY = -h * 0.75
const maxY = window.innerHeight - h * 0.25
position.value = {
x: Math.max(minX, Math.min(e.clientX - dragOffset.value.x, maxX)),
y: Math.max(minY, Math.min(e.clientY - dragOffset.value.y, maxY))
}
}
function stopDrag() {
isDragging.value = false
hasCustomPosition.value = true
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
}
// ============================================================================
// WINDOW RESIZE
// ============================================================================
const resizeStart = ref({ x: 0, y: 0, w: 0, h: 0 })
function startResize(e: MouseEvent) {
e.preventDefault()
e.stopPropagation()
isResizing.value = true
resizeStart.value = { x: e.clientX, y: e.clientY, w: size.value.w, h: size.value.h }
document.addEventListener('mousemove', onResize)
document.addEventListener('mouseup', stopResize)
}
function onResize(e: MouseEvent) {
if (!isResizing.value) return
size.value = {
w: Math.max(360, Math.min(resizeStart.value.w + e.clientX - resizeStart.value.x, window.innerWidth - 40)),
h: Math.max(300, Math.min(resizeStart.value.h + e.clientY - resizeStart.value.y, window.innerHeight - 40))
}
}
function stopResize() {
isResizing.value = false
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
}
// ============================================================================
// COMPUTED STYLE
// ============================================================================
const sizeModePresets: Record<SizeMode, { w: number; h: number }> = {
pin: { w: 240, h: 300 },
medium: { w: 480, h: 600 },
large: { w: 800, h: 760 }
}
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',
width: '100%',
}
style.bottom = '0'
if (isFullScreen) {
style.top = `${headerH}px`
style.height = 'auto'
} else {
style.height = `${sheetHeight.value}dvh`
}
return style
}
const preset = sizeModePresets[sizeMode.value]
// Custom position from dragging (uses current resize size or preset)
if (hasCustomPosition.value) {
const w = sizeMode.value === 'medium' ? size.value.w : preset.w
const h = sizeMode.value === 'medium' ? size.value.h : preset.h
return {
width: `${w}px`,
height: `${h}px`,
top: `${position.value.y}px`,
left: `${position.value.x}px`,
bottom: 'auto',
right: 'auto'
}
}
// Pin: anchored to FAB button (bottom-left corner aligned)
if (sizeMode.value === 'pin') {
return {
width: `${preset.w}px`,
height: `${preset.h}px`,
bottom: '20px',
left: '80px'
}
}
// Large: centered-ish, generous size
if (sizeMode.value === 'large') {
return {
width: `${preset.w}px`,
height: `${preset.h}px`,
bottom: '16px',
left: '90px'
}
}
// Medium (default)
return {
width: `${size.value.w}px`,
height: `${size.value.h}px`,
bottom: '16px',
left: '90px'
}
})
// ============================================================================
// ACTIONS
// ============================================================================
function close() {
isOpen.value = false
showSelector.value = false
}
function openAtCursor(x: number, y: number) {
if (effectiveMobile.value) {
isOpen.value = !isOpen.value
return
}
if (isOpen.value) {
isOpen.value = false
return
}
const preset = sizeModePresets[sizeMode.value]
const w = sizeMode.value === 'medium' ? size.value.w : preset.w
const h = sizeMode.value === 'medium' ? size.value.h : preset.h
const pad = 8
const vw = window.innerWidth
const vh = window.innerHeight
// Respect app header bar (~40px + safe-area)
const headerEl = document.querySelector('.app-header') as HTMLElement | null
const topBarrier = headerEl ? headerEl.getBoundingClientRect().bottom + pad : pad + 40
// Cursor at bottom-center of window: window extends upward from cursor
let left = x - w / 2
let top = y - h
// Clamp horizontally
left = Math.max(pad, Math.min(left, vw - w - pad))
// Clamp vertically: never above header, never below viewport
top = Math.max(topBarrier, Math.min(top, vh - h - pad))
position.value = { x: left, y: top }
hasCustomPosition.value = true
isOpen.value = true
nextTick(() => {
windowRef.value?.querySelector<HTMLTextAreaElement>('.input-field')?.focus()
})
}
defineExpose({ openAtCursor, openTerminals, activeTerminalSessionId, switchToTerminal, handleCreateSession })
function handleAgentSwitch(agent: AgentName) {
switchAgent(agent)
}
function handleSessionSelect(sessionId: string) {
selectSession(sessionId)
showSelector.value = false
}
function handleSend(message: string) {
voice.clearTranscript()
sendPrompt(message)
}
function handleCreateSession() {
showNewSessionModal.value = true
}
async function handleModalCreateNew(agent: AgentName, initialPrompt: string) {
showNewSessionModal.value = false
if (agent !== selectedAgent.value) {
await switchAgent(agent)
}
createNewSession(initialPrompt || undefined)
}
async function handleModalResume(sessionId: string, agent: AgentName) {
showNewSessionModal.value = false
if (agent !== selectedAgent.value) {
await switchAgent(agent)
}
// Load transcript + create terminal with --resume
parkCurrentTerminal()
selectedSessionId.value = sessionId
await fetchSessionContent(sessionId)
await startTerminal(sessionId)
}
// ============================================================================
// WATCHERS
// ============================================================================
watch(isOpen, async (open) => {
if (open && !initialized) {
initialized = true
await nextTick()
init()
}
})
// ============================================================================
// KEYBOARD SHORTCUTS (Ctrl+1..5 terminal switch, Ctrl+/- zoom)
// ============================================================================
function handleGlobalKeydown(e: KeyboardEvent) {
if (!e.ctrlKey) return
// Ctrl+1..5 → switch to terminal by index
const num = parseInt(e.key)
if (num >= 1 && num <= 5) {
const terminal = openTerminals.value[num - 1]
if (!terminal) return
e.preventDefault()
if (!isOpen.value) isOpen.value = true
switchToTerminal(terminal.sessionId)
return
}
// Zoom shortcuts (only when open)
if (!isOpen.value) return
if (e.key === '+' || e.key === '=') {
e.preventDefault()
zoom.value = Math.min(2, +(zoom.value + 0.1).toFixed(1))
} else if (e.key === '-') {
e.preventDefault()
zoom.value = Math.max(0.5, +(zoom.value - 0.1).toFixed(1))
}
}
// ============================================================================
// LIFECYCLE
// ============================================================================
onMounted(async () => {
checkMobile()
window.addEventListener('resize', checkMobile)
document.addEventListener('keydown', handleGlobalKeydown)
oceanLifeTimer = setInterval(tickOceanLife, 20000)
tickOceanLife()
await voice.init()
// Terminal registry is now synced via centralized session state WS (no polling needed)
})
onBeforeUnmount(() => {
if (oceanLifeTimer) clearInterval(oceanLifeTimer)
disconnectRealtime()
voice.cleanup()
document.removeEventListener('keydown', handleGlobalKeydown)
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
window.removeEventListener('resize', checkMobile)
})
</script>
<template>
<Teleport to="body">
<Transition name="win-slide">
<div
v-show="isOpen"
ref="windowRef"
class="aero-win"
:class="{
dragging: isDragging,
resizing: isResizing,
mobile: effectiveMobile,
'chrome-visible': showChrome,
'selector-open': showSelector,
'nav-scrollbar': scrollNavMode === 'scrollbar',
'nav-buttons': scrollNavMode === 'buttons',
'nav-none': scrollNavMode === 'none'
}"
:style="windowStyle"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
@focusin="isFocusWithin = true"
@focusout="isFocusWithin = false"
>
<div class="glass" :style="{ zoom: zoom !== 1 ? zoom : undefined }">
<!-- Thin drag edge (replaces full titlebar drag) -->
<div
v-if="!effectiveMobile"
class="drag-edge"
@mousedown="startDrag"
></div>
<!-- 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"
:terminals="openTerminals"
:active-session-id="activeTerminalSessionId"
:model="conversation?.model"
:version="conversation?.version"
@switch-terminal="switchToTerminal"
@close-terminal="closeTerminal"
/>
</div>
<div class="window-controls">
<button
v-if="!isMobile"
@click.stop="toggleForceMobile"
class="mobile-btn"
:class="{ active: forceMobile }"
title="Toggle mobile panel"
>
<!-- Phone/panel-from-bottom icon -->
<svg width="10" height="10" viewBox="0 0 10 10" shape-rendering="crispEdges">
<rect x="2" y="0" width="6" height="10" fill="currentColor" opacity="0.25"/>
<rect x="3" y="1" width="4" height="7" fill="currentColor"/>
<rect x="4" y="9" width="2" height="1" fill="currentColor" opacity="0.5"/>
</svg>
</button>
<button @click.stop="cycleSizeMode" class="size-btn" :title="`Size: ${sizeMode}`">
<!-- Pin icon: small square bottom-left -->
<svg v-if="sizeMode === 'pin'" width="10" height="10" viewBox="0 0 10 10" shape-rendering="crispEdges">
<rect x="0" y="6" width="4" height="4" fill="currentColor"/>
</svg>
<!-- Medium icon: medium square centered -->
<svg v-else-if="sizeMode === 'medium'" width="10" height="10" viewBox="0 0 10 10" shape-rendering="crispEdges">
<rect x="2" y="2" width="6" height="6" fill="currentColor" opacity="0.35"/>
<rect x="3" y="3" width="4" height="4" fill="currentColor"/>
</svg>
<!-- Large icon: full square -->
<svg v-else width="10" height="10" viewBox="0 0 10 10" shape-rendering="crispEdges">
<rect x="0" y="0" width="10" height="10" fill="currentColor" opacity="0.35"/>
<rect x="1" y="1" width="8" height="8" fill="currentColor"/>
</svg>
</button>
<button
@click.stop="chatRef?.collapseAllExceptLast()"
:class="{ active: chatRef?.allCollapsed }"
class="collapse-all-btn"
title="Collapse all except last"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline v-if="chatRef?.allCollapsed" points="6 9 12 15 18 9"/>
<template v-else>
<polyline points="18 15 12 9 6 15"/>
</template>
</svg>
</button>
<button @click.stop="chatRef?.toggleSelectMode()" :class="{ active: chatRef?.selectMode }" title="Select messages">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline v-if="chatRef?.selectMode" points="20 6 9 17 4 12"/>
<template v-else>
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</template>
</svg>
</button>
<button @click.stop="showSelector = !showSelector" :class="{ active: showSelector }" title="Agent/Session">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<button class="x" @click="close" title="Close">
<svg width="8" height="8" viewBox="0 0 10 10">
<line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5"/>
<line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5"/>
</svg>
</button>
</div>
</div>
<!-- Error -->
<div v-if="error" class="error-bar">{{ error }}</div>
<!-- Content -->
<div class="content">
<AquaticBackground />
<div class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
<Transition name="terminal-loading">
<div v-if="transitioning" class="terminal-loading-overlay">
<div class="terminal-loading-spinner" />
</div>
</Transition>
<Transition name="terminal-loading">
<div v-if="transitionError" class="terminal-error-overlay" @click="transitionError = null">
<div class="terminal-error-content">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f87171" stroke-width="2">
<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 class="terminal-error-msg">{{ transitionError }}</span>
<span class="terminal-error-hint">Click to dismiss</span>
</div>
</div>
</Transition>
<ChatContainer
ref="chatRef"
v-if="conversation"
:conversation="conversation"
:processing="processing"
:terminal-ready="terminalReady"
:terminal="ephemeral"
:show-selector="showSelector"
:agents="agents"
:selected-agent="selectedAgent"
: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"
:scroll-jump-percent="scrollJumpPercent"
:scroll-nav-mode="scrollNavMode"
:hook-permission-mode="hookMeta.permissionMode"
@send="handleSend"
@switch-agent="handleAgentSwitch"
@select-session="handleSessionSelect"
@create-session="handleCreateSession"
@close-session="closeTerminal"
@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"
@update:scroll-jump-percent="setScrollJumpPercent"
@update:scroll-nav-mode="setScrollNavMode"
/>
<div v-else class="empty-state">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span>Select a session to begin</span>
<small>{{ sessions.length }} sessions available</small>
</div>
</div>
<!-- Scroll arrows (visible only in idle mode) -->
<Transition name="scroll-arrows">
<div v-if="showChrome && conversation && scrollNavMode === 'buttons'" class="scroll-arrows">
<!-- Top: double chevron up + wave accent -->
<button class="scroll-arrow sa-surface" @click="scrollToEdge('top')" title="Scroll to top">
<svg width="20" height="22" viewBox="0 0 20 22" shape-rendering="crispEdges">
<!-- Wave line -->
<rect x="0" y="0" width="4" height="2" fill="#0ea5e9" opacity="0.5"/>
<rect x="4" y="1" width="3" height="2" fill="#22d3ee" opacity="0.4"/>
<rect x="7" y="0" width="6" height="2" fill="#0ea5e9" opacity="0.5"/>
<rect x="13" y="1" width="3" height="2" fill="#22d3ee" opacity="0.4"/>
<rect x="16" y="0" width="4" height="2" fill="#0ea5e9" opacity="0.5"/>
<rect x="1" y="2" width="2" height="1" fill="white" opacity="0.15"/>
<rect x="9" y="2" width="2" height="1" fill="white" opacity="0.12"/>
<!-- Chevron 1 -->
<rect x="9" y="6" width="2" height="2" fill="#67e8f9" opacity="0.75"/>
<rect x="7" y="8" width="2" height="2" fill="#67e8f9" opacity="0.6"/>
<rect x="11" y="8" width="2" height="2" fill="#67e8f9" opacity="0.6"/>
<rect x="5" y="10" width="2" height="2" fill="#67e8f9" opacity="0.45"/>
<rect x="13" y="10" width="2" height="2" fill="#67e8f9" opacity="0.45"/>
<!-- Chevron 2 -->
<rect x="9" y="13" width="2" height="2" fill="#67e8f9" opacity="0.55"/>
<rect x="7" y="15" width="2" height="2" fill="#67e8f9" opacity="0.4"/>
<rect x="11" y="15" width="2" height="2" fill="#67e8f9" opacity="0.4"/>
<rect x="5" y="17" width="2" height="2" fill="#67e8f9" opacity="0.3"/>
<rect x="13" y="17" width="2" height="2" fill="#67e8f9" opacity="0.3"/>
</svg>
</button>
<!-- Up: single chevron + bubbles -->
<button class="scroll-arrow sa-up" @click="scrollJump('up')" title="Scroll up">
<svg width="20" height="16" viewBox="0 0 20 16" shape-rendering="crispEdges">
<!-- Chevron -->
<rect x="9" y="2" width="2" height="2" fill="#67e8f9" opacity="0.75"/>
<rect x="7" y="4" width="2" height="2" fill="#67e8f9" opacity="0.6"/>
<rect x="11" y="4" width="2" height="2" fill="#67e8f9" opacity="0.6"/>
<rect x="5" y="6" width="2" height="2" fill="#67e8f9" opacity="0.45"/>
<rect x="13" y="6" width="2" height="2" fill="#67e8f9" opacity="0.45"/>
<!-- Bubble accents -->
<rect x="15" y="1" width="2" height="2" fill="#67e8f9" opacity="0.25"/>
<rect x="16" y="1" width="1" height="1" fill="white" opacity="0.2"/>
<rect x="3" y="3" width="1" height="1" fill="#67e8f9" opacity="0.18"/>
<!-- Trail -->
<rect x="9" y="11" width="2" height="1" fill="#67e8f9" opacity="0.18"/>
<rect x="11" y="13" width="1" height="1" fill="#67e8f9" opacity="0.12"/>
</svg>
</button>
<!-- Down: single chevron + bubbles -->
<button class="scroll-arrow sa-down" @click="scrollJump('down')" title="Scroll down">
<svg width="20" height="16" viewBox="0 0 20 16" shape-rendering="crispEdges">
<!-- Trail -->
<rect x="8" y="2" width="1" height="1" fill="#67e8f9" opacity="0.12"/>
<rect x="9" y="4" width="2" height="1" fill="#67e8f9" opacity="0.18"/>
<!-- Chevron -->
<rect x="5" y="7" width="2" height="2" fill="#67e8f9" opacity="0.45"/>
<rect x="13" y="7" width="2" height="2" fill="#67e8f9" opacity="0.45"/>
<rect x="7" y="9" width="2" height="2" fill="#67e8f9" opacity="0.6"/>
<rect x="11" y="9" width="2" height="2" fill="#67e8f9" opacity="0.6"/>
<rect x="9" y="11" width="2" height="2" fill="#67e8f9" opacity="0.75"/>
<!-- Bubble accents -->
<rect x="3" y="12" width="2" height="2" fill="#67e8f9" opacity="0.25"/>
<rect x="4" y="12" width="1" height="1" fill="white" opacity="0.2"/>
<rect x="16" y="10" width="1" height="1" fill="#67e8f9" opacity="0.18"/>
</svg>
</button>
<!-- Bottom: double chevron down + sand -->
<button class="scroll-arrow sa-seabed" @click="scrollToEdge('bottom')" title="Scroll to bottom">
<svg width="20" height="22" viewBox="0 0 20 22" shape-rendering="crispEdges">
<!-- Chevron 1 (pointing down) -->
<rect x="5" y="1" width="2" height="2" fill="#67e8f9" opacity="0.3"/>
<rect x="13" y="1" width="2" height="2" fill="#67e8f9" opacity="0.3"/>
<rect x="7" y="3" width="2" height="2" fill="#67e8f9" opacity="0.35"/>
<rect x="11" y="3" width="2" height="2" fill="#67e8f9" opacity="0.35"/>
<rect x="9" y="5" width="2" height="2" fill="#67e8f9" opacity="0.45"/>
<!-- Chevron 2 (pointing down) -->
<rect x="5" y="8" width="2" height="2" fill="#67e8f9" opacity="0.5"/>
<rect x="13" y="8" width="2" height="2" fill="#67e8f9" opacity="0.5"/>
<rect x="7" y="10" width="2" height="2" fill="#67e8f9" opacity="0.65"/>
<rect x="11" y="10" width="2" height="2" fill="#67e8f9" opacity="0.65"/>
<rect x="9" y="12" width="2" height="2" fill="#67e8f9" opacity="0.75"/>
<!-- Sand floor -->
<rect x="0" y="17" width="20" height="2" fill="#d4a06a" opacity="0.3"/>
<rect x="0" y="19" width="20" height="3" fill="#c4956a" opacity="0.35"/>
<rect x="4" y="17" width="1" height="1" fill="#e8c088" opacity="0.25"/>
<rect x="12" y="18" width="1" height="1" fill="#e8c088" opacity="0.2"/>
<rect x="17" y="17" width="1" height="1" fill="#e8c088" opacity="0.22"/>
</svg>
</button>
</div>
</Transition>
<!-- Resize handle -->
<div v-if="!effectiveMobile" class="resize-handle" @mousedown="startResize"></div>
</div>
</div>
</Transition>
<NewSessionModal
:visible="showNewSessionModal"
:agents="agents"
:current-agent="selectedAgent"
@close="showNewSessionModal = false"
@create-new="handleModalCreateNew"
@resume="handleModalResume"
/>
</Teleport>
</template>
<style scoped>
.aero-win {
position: fixed;
min-width: 200px;
min-height: 200px;
z-index: 9999;
transition: width 0.3s ease, height 0.3s ease, top 0.3s ease, left 0.3s ease, bottom 0.3s ease;
}
/* ── Dynamic glow from ocean background ── */
.aero-win::before {
content: '';
position: absolute;
inset: -12px;
z-index: -1;
border-radius: 18px;
background: linear-gradient(
180deg,
rgba(0, 12, 35, 0.35) 0%,
rgba(0, 30, 65, 0.30) 25%,
rgba(4, 52, 78, 0.35) 50%,
rgba(6, 58, 72, 0.30) 70%,
rgba(18, 50, 45, 0.35) 100%
);
filter: blur(18px);
opacity: 0.7;
transition: opacity 0.5s ease, filter 0.5s ease;
pointer-events: none;
}
.aero-win:hover::before,
.aero-win.chrome-visible::before {
opacity: 1;
filter: blur(22px);
}
.aero-win.dragging::before,
.aero-win.resizing::before {
transition: none;
}
.aero-win.dragging,
.aero-win.resizing {
transition: none;
}
.glass {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: rgba(0, 0, 0, 0.78);
backdrop-filter: blur(16px) saturate(1.2);
-webkit-backdrop-filter: blur(16px) saturate(1.2);
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.05);
box-shadow:
0 0 0 1px rgba(0,0,0,0.6),
0 16px 56px rgba(0,0,0,0.6),
0 4px 16px rgba(0,0,0,0.4);
overflow: hidden;
transition: border-color 0.35s ease, box-shadow 0.35s ease;
}
/* ══════════════════════════════════════════════════════════════════════════
IDLE MODE: When not hovering/focused, hide all chrome.
Only the messages + ocean background fill the entire window.
══════════════════════════════════════════════════════════════════════════ */
/* Transition all chrome elements smoothly */
.titlebar,
.error-bar,
.resize-handle {
transition: opacity 0.35s ease, max-height 0.35s ease, padding 0.35s ease;
}
/* Idle: hide drag edge */
.aero-win:not(.chrome-visible) .drag-edge {
pointer-events: none;
}
/* Idle: slide titlebar up and fade out */
.aero-win:not(.chrome-visible) .titlebar {
opacity: 0;
transform: translateY(-100%);
pointer-events: none;
}
/* Idle: hide error bar */
.aero-win:not(.chrome-visible) .error-bar {
opacity: 0;
max-height: 0;
padding: 0;
border-bottom-width: 0;
overflow: hidden;
}
/* Idle: hide resize handle */
.aero-win:not(.chrome-visible) .resize-handle {
opacity: 0;
pointer-events: none;
}
/* Chat-header: only visible when settings/selector is open */
.aero-win:not(.selector-open) .content :deep(.chat-header) {
opacity: 0 !important;
transform: translateY(-150%) !important;
pointer-events: none !important;
}
/* Idle: slide bottom-overlay down and fade out */
.aero-win:not(.chrome-visible) .content :deep(.bottom-overlay) {
opacity: 0 !important;
transform: translateY(100%) !important;
pointer-events: none !important;
}
/* Keep bottom-overlay visible when textarea has text */
.aero-win:not(.chrome-visible) .content :deep(.bottom-overlay:has(.input-field:not(:placeholder-shown))) {
opacity: 1 !important;
transform: none !important;
pointer-events: auto !important;
}
/* Idle: also hide selection bar */
.aero-win:not(.chrome-visible) .content :deep(.selection-bar) {
opacity: 0 !important;
pointer-events: none !important;
}
/* Idle: softer glass border + dimmed glow */
.aero-win:not(.chrome-visible) .glass {
border-color: rgba(255,255,255,0.02);
box-shadow:
0 0 0 1px rgba(0,0,0,0.3),
0 8px 32px rgba(0,0,0,0.4);
}
.aero-win:not(.chrome-visible)::before {
opacity: 0.4;
filter: blur(14px);
}
/* Smooth transitions for chrome show/hide */
.content :deep(.chat-header) {
transition: opacity 0.35s ease, transform 0.35s ease !important;
}
.content :deep(.bottom-overlay) {
transition: opacity 0.35s ease, transform 0.35s ease !important;
}
/* ── Drag edge: thin strip at top for window dragging ── */
.drag-edge {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 5px;
z-index: 11;
cursor: grab;
}
.drag-edge:active {
cursor: grabbing;
}
.aero-win.dragging .drag-edge {
cursor: grabbing;
}
/* ── Titlebar: absolute overlay at top of .glass ── */
.titlebar {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
height: 28px;
padding: 0 5px 0 8px;
background:
/* Pixel art sea surface: sky, moon, waves, fish, bubbles */
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='28' viewBox='0 0 120 28' shape-rendering='crispEdges'%3E%3Crect x='0' y='0' width='120' height='12' fill='%230a1628' opacity='0.5'/%3E%3Crect x='0' y='12' width='120' height='16' fill='%230c2d4a' opacity='0.45'/%3E%3Crect x='8' y='3' width='1' height='1' fill='white' opacity='0.3'/%3E%3Crect x='25' y='5' width='1' height='1' fill='white' opacity='0.22'/%3E%3Crect x='45' y='2' width='1' height='1' fill='white' opacity='0.18'/%3E%3Crect x='70' y='4' width='1' height='1' fill='white' opacity='0.28'/%3E%3Crect x='95' y='6' width='1' height='1' fill='white' opacity='0.22'/%3E%3Crect x='110' y='3' width='1' height='1' fill='white' opacity='0.18'/%3E%3Crect x='36' y='7' width='1' height='1' fill='%23fde68a' opacity='0.15'/%3E%3Crect x='85' y='2' width='3' height='3' fill='%23fef3c7' opacity='0.3'/%3E%3Crect x='86' y='1' width='2' height='1' fill='%23fef3c7' opacity='0.2'/%3E%3Crect x='86' y='5' width='2' height='1' fill='%23fde68a' opacity='0.15'/%3E%3Crect x='50' y='4' width='4' height='2' fill='%23475569' opacity='0.15'/%3E%3Crect x='51' y='3' width='2' height='1' fill='%23475569' opacity='0.1'/%3E%3Crect x='8' y='10' width='4' height='2' fill='%2322d3ee' opacity='0.3'/%3E%3Crect x='12' y='11' width='10' height='2' fill='%230ea5e9' opacity='0.25'/%3E%3Crect x='22' y='10' width='6' height='2' fill='%2322d3ee' opacity='0.3'/%3E%3Crect x='28' y='11' width='12' height='2' fill='%230ea5e9' opacity='0.25'/%3E%3Crect x='40' y='10' width='4' height='2' fill='%2322d3ee' opacity='0.3'/%3E%3Crect x='44' y='11' width='14' height='2' fill='%230ea5e9' opacity='0.25'/%3E%3Crect x='58' y='10' width='6' height='2' fill='%2322d3ee' opacity='0.3'/%3E%3Crect x='64' y='11' width='10' height='2' fill='%230ea5e9' opacity='0.25'/%3E%3Crect x='74' y='10' width='4' height='2' fill='%2322d3ee' opacity='0.3'/%3E%3Crect x='78' y='11' width='12' height='2' fill='%230ea5e9' opacity='0.25'/%3E%3Crect x='90' y='10' width='6' height='2' fill='%2322d3ee' opacity='0.3'/%3E%3Crect x='96' y='11' width='14' height='2' fill='%230ea5e9' opacity='0.25'/%3E%3Crect x='110' y='10' width='4' height='2' fill='%2322d3ee' opacity='0.3'/%3E%3Crect x='114' y='11' width='6' height='2' fill='%230ea5e9' opacity='0.25'/%3E%3Crect x='0' y='11' width='8' height='2' fill='%230ea5e9' opacity='0.25'/%3E%3Crect x='8' y='10' width='2' height='1' fill='white' opacity='0.12'/%3E%3Crect x='22' y='10' width='2' height='1' fill='white' opacity='0.1'/%3E%3Crect x='40' y='10' width='2' height='1' fill='white' opacity='0.12'/%3E%3Crect x='58' y='10' width='2' height='1' fill='white' opacity='0.1'/%3E%3Crect x='74' y='10' width='2' height='1' fill='white' opacity='0.12'/%3E%3Crect x='90' y='10' width='2' height='1' fill='white' opacity='0.1'/%3E%3Crect x='110' y='10' width='2' height='1' fill='white' opacity='0.12'/%3E%3Crect x='0' y='14' width='120' height='2' fill='%230369a1' opacity='0.18'/%3E%3Crect x='0' y='18' width='120' height='2' fill='%23075985' opacity='0.12'/%3E%3Crect x='0' y='22' width='120' height='2' fill='%230c4a6e' opacity='0.08'/%3E%3Crect x='15' y='16' width='3' height='1' fill='%23f97316' opacity='0.3'/%3E%3Crect x='14' y='16' width='1' height='1' fill='%23fb923c' opacity='0.2'/%3E%3Crect x='55' y='20' width='3' height='1' fill='%23818cf8' opacity='0.22'/%3E%3Crect x='58' y='20' width='1' height='1' fill='%23a78bfa' opacity='0.15'/%3E%3Crect x='100' y='17' width='3' height='1' fill='%2322c55e' opacity='0.22'/%3E%3Crect x='99' y='17' width='1' height='1' fill='%234ade80' opacity='0.15'/%3E%3Crect x='30' y='15' width='1' height='1' fill='white' opacity='0.1'/%3E%3Crect x='65' y='22' width='1' height='1' fill='white' opacity='0.08'/%3E%3Crect x='82' y='14' width='1' height='1' fill='white' opacity='0.1'/%3E%3C/svg%3E") repeat-x left top / auto 100%,
rgba(0, 8, 20, 0.3);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
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;
color: rgba(255,255,255,0.6);
font: 500 10px/1 'Courier New', monospace;
}
.window-controls {
position: relative;
z-index: 1;
display: flex;
gap: 2px;
}
.window-controls button {
width: 20px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 0;
color: rgba(255,255,255,0.4);
cursor: pointer;
transition: all 0.15s;
}
.window-controls button:hover {
background: rgba(255,255,255,0.1);
color: rgba(255,255,255,0.8);
}
.window-controls button.active {
background: rgba(99, 102, 241, 0.2);
border-color: rgba(99, 102, 241, 0.3);
color: #a5b4fc;
}
.window-controls .mobile-btn {
color: #a78bfa;
}
.window-controls .mobile-btn:hover {
color: #c4b5fd;
background: rgba(167, 139, 250, 0.15);
border-color: rgba(167, 139, 250, 0.25);
}
.window-controls .mobile-btn.active {
background: rgba(167, 139, 250, 0.2);
border-color: rgba(167, 139, 250, 0.3);
color: #c4b5fd;
}
.window-controls .collapse-all-btn {
color: #2dd4bf;
}
.window-controls .collapse-all-btn:hover {
color: #5eead4;
background: rgba(45, 212, 191, 0.15);
border-color: rgba(45, 212, 191, 0.25);
}
.window-controls .collapse-all-btn.active {
background: rgba(45, 212, 191, 0.2);
border-color: rgba(45, 212, 191, 0.3);
color: #5eead4;
}
.window-controls .size-btn {
color: #0ea5e9;
}
.window-controls .size-btn:hover {
color: #38bdf8;
background: rgba(14, 165, 233, 0.15);
border-color: rgba(14, 165, 233, 0.25);
}
.window-controls button.x:hover {
background: rgba(239, 68, 68, 0.3);
border-color: rgba(239, 68, 68, 0.4);
color: #fca5a5;
}
/* ── Error Bar ── */
.error-bar {
padding: 4px 10px;
background: rgba(239, 68, 68, 0.1);
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
color: #fca5a5;
font-size: 10px;
font-family: 'Courier New', monospace;
flex-shrink: 0;
}
/* ── Content ── */
.content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
/* Background handled by AquaticBackground component */
}
/* Dark readability overlay: between ocean bg (z-index:0) and chat (z-index:1) */
.readability-overlay {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
opacity: 0;
transition: opacity 0.35s ease;
}
.aero-win.chrome-visible .readability-overlay {
opacity: 1;
}
/* ── Terminal switching loading overlay ── */
.terminal-loading-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(4px);
}
.terminal-loading-spinner {
width: 28px;
height: 28px;
border: 2.5px solid rgba(255, 255, 255, 0.15);
border-top-color: rgba(255, 255, 255, 0.7);
border-radius: 50%;
animation: tl-spin 0.7s linear infinite;
}
@keyframes tl-spin {
to { transform: rotate(360deg); }
}
.terminal-error-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(6px);
cursor: pointer;
}
.terminal-error-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
max-width: 80%;
padding: 1.2rem 1.5rem;
background: rgba(30, 30, 30, 0.85);
border: 1px solid rgba(248, 113, 113, 0.3);
border-radius: 10px;
}
.terminal-error-msg {
font-size: 12px;
color: #fca5a5;
text-align: center;
line-height: 1.4;
word-break: break-word;
}
.terminal-error-hint {
font-size: 10px;
color: rgba(255, 255, 255, 0.35);
}
.terminal-loading-enter-active { transition: opacity 0.15s ease; }
.terminal-loading-leave-active { transition: opacity 0.25s ease; }
.terminal-loading-enter-from,
.terminal-loading-leave-to { opacity: 0; }
/* Override ChatContainer backgrounds: glass-transparent */
.content :deep(.chat-container) {
background: transparent !important;
border: none !important;
border-radius: 0 !important;
position: relative;
z-index: 1;
flex: 1 !important;
min-height: 0 !important;
}
/* Chat header: absolute overlay below titlebar, floats over messages */
.content :deep(.chat-header) {
position: absolute !important;
top: 28px !important;
left: 0 !important;
right: 0 !important;
z-index: 3 !important;
background: rgba(0, 6, 18, 0.5) !important;
backdrop-filter: blur(8px) !important;
-webkit-backdrop-filter: blur(8px) !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.06) !important;
padding: 0.3rem 0.6rem !important;
}
/* Messages: fill entire container, pad top/bottom for overlaid titlebar+header / input */
.content :deep(.messages-scroll) {
background: transparent !important;
padding-top: 5rem !important;
padding-bottom: 5rem !important;
flex: 1 !important;
}
/* Hide scrollbar for buttons and none modes */
.aero-win:not(.nav-scrollbar) .content :deep(.messages-scroll) {
scrollbar-width: none !important;
}
.aero-win:not(.nav-scrollbar) .content :deep(.messages-scroll)::-webkit-scrollbar {
display: none !important;
}
/* Pixel art scrollbar (only in scrollbar mode) */
.aero-win.nav-scrollbar .content :deep(.messages-scroll) {
scrollbar-gutter: stable !important;
}
.aero-win.nav-scrollbar .content :deep(.messages-scroll)::-webkit-scrollbar {
width: 8px;
}
/* Idle: hide scrollbar visuals (gutter stays) */
.aero-win.nav-scrollbar:not(.chrome-visible) .content :deep(.messages-scroll)::-webkit-scrollbar-track {
background: transparent !important;
}
.aero-win.nav-scrollbar:not(.chrome-visible) .content :deep(.messages-scroll)::-webkit-scrollbar-thumb {
background: transparent !important;
border-color: transparent !important;
}
.aero-win.nav-scrollbar .content :deep(.messages-scroll)::-webkit-scrollbar-track {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8' shape-rendering='crispEdges'%3E%3Crect x='0' y='0' width='8' height='8' fill='%230c2d4a' opacity='0.4'/%3E%3Crect x='2' y='2' width='2' height='2' fill='%23075985' opacity='0.15'/%3E%3Crect x='6' y='6' width='2' height='2' fill='%23075985' opacity='0.1'/%3E%3C/svg%3E") repeat;
}
.aero-win.nav-scrollbar .content :deep(.messages-scroll)::-webkit-scrollbar-thumb {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='16' viewBox='0 0 8 16' shape-rendering='crispEdges'%3E%3Crect x='0' y='0' width='8' height='16' fill='%230ea5e9' opacity='0.3'/%3E%3Crect x='2' y='2' width='4' height='2' fill='%2322d3ee' opacity='0.25'/%3E%3Crect x='2' y='6' width='4' height='2' fill='%2367e8f9' opacity='0.2'/%3E%3Crect x='2' y='10' width='4' height='2' fill='%2322d3ee' opacity='0.25'/%3E%3Crect x='2' y='14' width='4' height='2' fill='%2367e8f9' opacity='0.15'/%3E%3C/svg%3E") repeat;
border: 1px solid rgba(14, 165, 233, 0.15);
}
.aero-win.nav-scrollbar .content :deep(.messages-scroll)::-webkit-scrollbar-thumb:hover {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='16' viewBox='0 0 8 16' shape-rendering='crispEdges'%3E%3Crect x='0' y='0' width='8' height='16' fill='%230ea5e9' opacity='0.45'/%3E%3Crect x='2' y='2' width='4' height='2' fill='%2322d3ee' opacity='0.35'/%3E%3Crect x='2' y='6' width='4' height='2' fill='%2367e8f9' opacity='0.3'/%3E%3Crect x='2' y='10' width='4' height='2' fill='%2322d3ee' opacity='0.35'/%3E%3Crect x='2' y='14' width='4' height='2' fill='%2367e8f9' opacity='0.25'/%3E%3C/svg%3E") repeat;
border-color: rgba(14, 165, 233, 0.3);
}
.content :deep(.meta-badge) {
background: rgba(255,255,255,0.04);
color: rgba(255,255,255,0.4);
border-radius: 0;
font-family: 'Courier New', monospace;
}
.content :deep(.meta-badge.model) {
background: rgba(99, 102, 241, 0.1);
color: #a5b4fc;
}
.content :deep(.meta-cwd),
.content :deep(.meta-duration),
.content :deep(.meta-count) {
color: rgba(255,255,255,0.25);
}
.content :deep(.copy-id-btn) {
color: rgba(255,255,255,0.25);
}
.content :deep(.copy-id-btn:hover) {
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.6);
}
.content :deep(.select-mode-btn) {
border-color: rgba(255,255,255,0.08);
color: rgba(255,255,255,0.35);
border-radius: 0;
}
.content :deep(.select-mode-btn:hover) {
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.7);
}
.content :deep(.select-mode-btn.active) {
background: rgba(99, 102, 241, 0.25);
border-color: rgba(99, 102, 241, 0.35);
color: #c7d2fe;
}
/* Bottom overlay: absolute container for lifecycle + input + status */
.content :deep(.bottom-overlay) {
position: absolute !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
z-index: 3 !important;
display: flex !important;
flex-direction: column !important;
background: rgba(0, 6, 18, 0.5) !important;
backdrop-filter: blur(8px) !important;
-webkit-backdrop-filter: blur(8px) !important;
border-top: 1px solid rgba(255, 255, 255, 0.06) !important;
}
.content :deep(.status-bar) {
background: transparent !important;
border-top: 1px solid rgba(255, 255, 255, 0.04) !important;
border-bottom: none !important;
padding: 0.15rem 0.5rem !important;
}
.content :deep(.status-id) {
color: rgba(255,255,255,0.35) !important;
}
.content :deep(.status-bar .copy-id-btn) {
color: rgba(255,255,255,0.25) !important;
}
.content :deep(.status-bar .copy-id-btn:hover) {
color: rgba(255,255,255,0.6) !important;
}
.content :deep(.status-bar .meta-badge) {
border-radius: 0 !important;
font-family: 'Courier New', monospace !important;
}
.content :deep(.status-bar .meta-badge.model) {
background: rgba(99, 102, 241, 0.1) !important;
color: #a5b4fc !important;
}
.content :deep(.status-bar .meta-badge.version) {
background: rgba(255,255,255,0.04) !important;
color: rgba(255,255,255,0.3) !important;
}
.content :deep(.status-bar .meta-count),
.content :deep(.status-bar .meta-duration) {
color: rgba(255,255,255,0.2) !important;
}
/* UserInput: inside bottom-overlay, no absolute positioning needed */
.content :deep(.user-input) {
background: transparent !important;
border-top: 1px solid rgba(255, 255, 255, 0.04) !important;
padding: 0.3rem 0.5rem !important;
}
/* Lifecycle ribbon: inside bottom-overlay, flows naturally above user-input */
.content :deep(.lifecycle-ribbon) {
background: transparent !important;
pointer-events: none !important;
}
/* Dark overlay on input-container so text is readable */
.content :deep(.input-container) {
background: rgba(0, 6, 18, 0.8) !important;
border-color: rgba(14, 165, 233, 0.1) !important;
border-radius: 0 !important;
padding: 0.3rem 0.4rem !important;
}
.content :deep(.input-container:focus-within) {
border-color: rgba(14, 165, 233, 0.25) !important;
background: rgba(0, 6, 18, 0.85) !important;
}
.content :deep(.input-field) {
color: rgba(255,255,255,0.85);
font-family: 'Courier New', monospace;
font-size: 12px;
}
/* Send button: pixel art daytime ocean, no border */
.content :deep(.send-btn) {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28' shape-rendering='crispEdges'%3E%3Crect width='28' height='6' fill='%2387ceeb'/%3E%3Crect y='6' width='28' height='4' fill='%2356b3d9'/%3E%3Crect y='10' width='28' height='4' fill='%232d9abf'/%3E%3Crect y='14' width='28' height='4' fill='%231a7fa5'/%3E%3Crect y='18' width='28' height='4' fill='%23106888'/%3E%3Crect y='22' width='28' height='6' fill='%23c2b280'/%3E%3Crect x='24' y='4' width='3' height='3' fill='%23fffde0' opacity='0.8'/%3E%3Crect x='25' y='3' width='2' height='1' fill='%23fffde0' opacity='0.5'/%3E%3Crect x='2' y='5' width='4' height='2' fill='white' opacity='0.35'/%3E%3Crect x='10' y='4' width='6' height='2' fill='white' opacity='0.25'/%3E%3Crect x='20' y='6' width='3' height='1' fill='white' opacity='0.2'/%3E%3Crect x='5' y='12' width='3' height='2' fill='%23f97316' opacity='0.7'/%3E%3Crect x='4' y='13' width='1' height='1' fill='%23fdba74' opacity='0.5'/%3E%3Crect x='18' y='16' width='2' height='1' fill='%232563eb' opacity='0.5'/%3E%3Crect x='20' y='16' width='1' height='1' fill='%2393c5fd' opacity='0.4'/%3E%3Crect x='8' y='18' width='1' height='1' fill='white' opacity='0.2'/%3E%3Crect x='22' y='12' width='1' height='1' fill='white' opacity='0.2'/%3E%3Crect x='14' y='20' width='1' height='1' fill='white' opacity='0.15'/%3E%3Crect x='3' y='23' width='4' height='3' fill='%23059669' opacity='0.5'/%3E%3Crect x='4' y='22' width='2' height='1' fill='%2310b981' opacity='0.4'/%3E%3Crect x='20' y='24' width='3' height='2' fill='%23059669' opacity='0.4'/%3E%3Crect x='12' y='25' width='3' height='2' fill='%23ec4899' opacity='0.45'/%3E%3Crect x='13' y='24' width='2' height='1' fill='%23f472b6' opacity='0.35'/%3E%3C/svg%3E") !important;
border: none !important;
border-radius: 0 !important;
width: 28px !important;
height: 28px !important;
color: white !important;
image-rendering: pixelated;
box-shadow: none !important;
transition: color 0.15s ease !important;
}
.content :deep(.send-btn:hover:not(:disabled)) {
color: #fffde0 !important;
filter: none !important;
box-shadow: 0 0 12px rgba(135, 206, 235, 0.5), 0 0 4px rgba(255, 253, 224, 0.3) !important;
}
.content :deep(.send-btn:disabled) {
opacity: 0.25 !important;
filter: saturate(0.3) !important;
}
/* Selection bar */
.content :deep(.selection-bar) {
background: rgba(8, 8, 12, 0.92);
border-color: rgba(255,255,255,0.06);
border-radius: 0;
}
.content :deep(.selection-count) {
color: rgba(255,255,255,0.4);
font-family: 'Courier New', monospace;
}
.content :deep(.selection-btn.toggle-all) {
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.5);
border-radius: 0;
}
.content :deep(.message-wrapper.selected) {
background: rgba(99, 102, 241, 0.06);
}
/* ── Empty State ── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 8px;
color: rgba(255,255,255,0.3);
position: relative;
z-index: 1;
}
.empty-state svg {
opacity: 0.2;
}
.empty-state span {
font-size: 11px;
font-family: 'Courier New', monospace;
color: rgba(255,255,255,0.35);
letter-spacing: 0.5px;
}
.empty-state small {
font-size: 10px;
font-family: 'Courier New', monospace;
color: rgba(255,255,255,0.2);
}
/* ── Resize Handle ── */
.resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' shape-rendering='crispEdges'%3E%3Crect x='12' y='12' width='2' height='2' fill='%23818cf8' opacity='0.25'/%3E%3Crect x='8' y='12' width='2' height='2' fill='%23818cf8' opacity='0.15'/%3E%3Crect x='12' y='8' width='2' height='2' fill='%23818cf8' opacity='0.15'/%3E%3Crect x='4' y='12' width='2' height='2' fill='%23818cf8' opacity='0.08'/%3E%3Crect x='12' y='4' width='2' height='2' fill='%23818cf8' opacity='0.08'/%3E%3Crect x='8' y='8' width='2' height='2' fill='%23818cf8' opacity='0.08'/%3E%3C/svg%3E") no-repeat center center;
border-radius: 0 0 10px 0;
}
.resize-handle:hover {
background:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' shape-rendering='crispEdges'%3E%3Crect x='12' y='12' width='2' height='2' fill='%23a5b4fc' opacity='0.4'/%3E%3Crect x='8' y='12' width='2' height='2' fill='%23a5b4fc' opacity='0.25'/%3E%3Crect x='12' y='8' width='2' height='2' fill='%23a5b4fc' opacity='0.25'/%3E%3Crect x='4' y='12' width='2' height='2' fill='%23a5b4fc' opacity='0.15'/%3E%3Crect x='12' y='4' width='2' height='2' fill='%23a5b4fc' opacity='0.15'/%3E%3Crect x='8' y='8' width='2' height='2' fill='%23a5b4fc' opacity='0.15'/%3E%3C/svg%3E") no-repeat center center;
}
.aero-win.resizing {
user-select: none;
}
.aero-win.resizing .content {
pointer-events: none;
}
/* ── Transitions ── */
.win-slide-enter-active,
.win-slide-leave-active {
transition: all .2s ease;
}
.win-slide-enter-from,
.win-slide-leave-to {
opacity: 0;
transform: translateY(16px) scale(0.98);
}
/* ══════════════════════════════════════════════════════════════════════════
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); }
}
/* ── Mobile ── */
.aero-win.mobile {
min-width: unset;
min-height: unset;
max-width: 100%;
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 {
border-radius: 16px 16px 0 0;
padding-bottom: env(safe-area-inset-bottom, 0);
}
.aero-win.mobile .resize-handle {
display: none;
}
.aero-win.mobile .content {
flex: 1;
min-height: 100px;
touch-action: pan-y;
overflow: hidden;
}
.aero-win.mobile.win-slide-enter-active {
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1) !important;
}
.aero-win.mobile.win-slide-leave-active {
transition: transform 0.25s cubic-bezier(0.4, 0, 1, 1) !important;
}
.aero-win.mobile.win-slide-enter-from,
.aero-win.mobile.win-slide-leave-to {
transform: translateY(100%) !important;
opacity: 1 !important;
}
/* ── Scroll arrows (aquatic pixel art) ── */
.scroll-arrows {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 4px;
z-index: 5;
pointer-events: auto;
}
.scroll-arrow {
display: flex;
align-items: center;
justify-content: center;
min-width: 30px;
min-height: 24px;
border: 1px solid rgba(14, 165, 233, 0.12);
background: rgba(0, 10, 30, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
cursor: pointer;
padding: 0;
transition: all 0.2s ease;
}
.scroll-arrow:hover {
background: rgba(14, 165, 233, 0.08);
border-color: rgba(14, 165, 233, 0.3);
transform: scale(1.1);
}
.scroll-arrow:active {
transform: scale(0.92);
}
.scroll-arrow.sa-surface svg { animation: sa-float 3s ease-in-out infinite; }
.scroll-arrow.sa-up svg { animation: sa-bob-up 2.5s ease-in-out infinite; }
.scroll-arrow.sa-down svg { animation: sa-bob-down 2.5s ease-in-out infinite; }
.scroll-arrow.sa-seabed svg { animation: sa-sway 4s ease-in-out infinite; }
@keyframes sa-float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-1px); } }
@keyframes sa-bob-up { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-1.5px); } }
@keyframes sa-bob-down { 0%,100% { transform: translateY(0); } 50% { transform: translateY(1.5px); } }
@keyframes sa-sway { 0%,100% { transform: translateX(0); } 50% { transform: translateX(0.5px); } }
/* Transition for scroll arrows appear/disappear */
.scroll-arrows-enter-active,
.scroll-arrows-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.scroll-arrows-enter-from,
.scroll-arrows-leave-to {
opacity: 0;
transform: translateY(-50%) translateX(8px);
}
</style>