feat: Modular aquatic background system and shared CodeBlock component
Add aquaticBackground/ module with OceanScene (unified gradient, light rays, sea floor, corals, seaweed, decorations), plus independent overlay layers (BubbleStream, FishSchool, JellyfishDrift, EventOverlay, EdgeFade). Includes event scheduling engine with 4 frequency tiers (minutes/hours/days/months) and 20 random events with localStorage persistence. Add shared CodeBlock component with copy-to-clipboard button, terminal-matched monospace font (Consolas), and proper line-height/letter-spacing. Refactor EditCard, WriteCard, TaskCard, and ToolResultBlock to use CodeBlock. Fix markdown code block alignment to match terminal rendering.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
import { useTranscriptDebug } from '@/composables/transcript-debug'
|
import { useTranscriptDebug } from '@/composables/transcript-debug'
|
||||||
import { ChatContainer, BackgroundPixelArt } from '@/components/transcript-debug'
|
import { ChatContainer, AquaticBackground } from '@/components/transcript-debug'
|
||||||
import type { AgentName } from '@/types/transcript-debug'
|
import type { AgentName } from '@/types/transcript-debug'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -44,6 +44,7 @@ const agents: { id: AgentName; label: string }[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const showSelector = ref(false)
|
const showSelector = ref(false)
|
||||||
|
const chatRef = ref<InstanceType<typeof ChatContainer> | null>(null)
|
||||||
let initialized = false
|
let initialized = false
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -72,6 +73,9 @@ const dragOffset = ref({ x: 0, y: 0 })
|
|||||||
const isResizing = ref(false)
|
const isResizing = ref(false)
|
||||||
const size = ref({ w: 480, h: 600 })
|
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
|
// Size mode: pin (small, anchored to FAB), medium (default), large
|
||||||
type SizeMode = 'pin' | 'medium' | 'large'
|
type SizeMode = 'pin' | 'medium' | 'large'
|
||||||
const sizeMode = ref<SizeMode>('medium')
|
const sizeMode = ref<SizeMode>('medium')
|
||||||
@@ -139,7 +143,7 @@ function stopSheetDrag() {
|
|||||||
|
|
||||||
function startDrag(e: MouseEvent | TouchEvent) {
|
function startDrag(e: MouseEvent | TouchEvent) {
|
||||||
if ((e.target as HTMLElement).closest('.window-controls')) return
|
if ((e.target as HTMLElement).closest('.window-controls')) return
|
||||||
if ((e.target as HTMLElement).closest('.selector-overlay')) return
|
if ((e.target as HTMLElement).closest('.header-selector')) return
|
||||||
|
|
||||||
if (isMobile.value) {
|
if (isMobile.value) {
|
||||||
if (e instanceof TouchEvent) startSheetDrag(e)
|
if (e instanceof TouchEvent) startSheetDrag(e)
|
||||||
@@ -301,6 +305,45 @@ function close() {
|
|||||||
showSelector.value = false
|
showSelector.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAtCursor(x: number, y: number) {
|
||||||
|
if (isMobile.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 })
|
||||||
|
|
||||||
function handleAgentSwitch(agent: AgentName) {
|
function handleAgentSwitch(agent: AgentName) {
|
||||||
switchAgent(agent)
|
switchAgent(agent)
|
||||||
}
|
}
|
||||||
@@ -326,6 +369,21 @@ watch(isOpen, async (open) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ZOOM KEYBOARD HANDLER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function handleZoomKey(e: KeyboardEvent) {
|
||||||
|
if (!isOpen.value || !e.ctrlKey) 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
|
// LIFECYCLE
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -333,10 +391,12 @@ watch(isOpen, async (open) => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
checkMobile()
|
checkMobile()
|
||||||
window.addEventListener('resize', checkMobile)
|
window.addEventListener('resize', checkMobile)
|
||||||
|
document.addEventListener('keydown', handleZoomKey)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
disconnectRealtime()
|
disconnectRealtime()
|
||||||
|
document.removeEventListener('keydown', handleZoomKey)
|
||||||
document.removeEventListener('mousemove', onDrag)
|
document.removeEventListener('mousemove', onDrag)
|
||||||
document.removeEventListener('mouseup', stopDrag)
|
document.removeEventListener('mouseup', stopDrag)
|
||||||
document.removeEventListener('touchmove', onDrag)
|
document.removeEventListener('touchmove', onDrag)
|
||||||
@@ -359,7 +419,8 @@ onBeforeUnmount(() => {
|
|||||||
resizing: isResizing,
|
resizing: isResizing,
|
||||||
mobile: isMobile,
|
mobile: isMobile,
|
||||||
'sheet-dragging': isDraggingSheet,
|
'sheet-dragging': isDraggingSheet,
|
||||||
'chrome-visible': showChrome
|
'chrome-visible': showChrome,
|
||||||
|
'selector-open': showSelector
|
||||||
}"
|
}"
|
||||||
:style="windowStyle"
|
:style="windowStyle"
|
||||||
@mouseenter="isHovered = true"
|
@mouseenter="isHovered = true"
|
||||||
@@ -367,7 +428,7 @@ onBeforeUnmount(() => {
|
|||||||
@focusin="isFocusWithin = true"
|
@focusin="isFocusWithin = true"
|
||||||
@focusout="isFocusWithin = false"
|
@focusout="isFocusWithin = false"
|
||||||
>
|
>
|
||||||
<div class="glass">
|
<div class="glass" :style="{ zoom: zoom !== 1 ? zoom : undefined }">
|
||||||
<!-- Mobile drag handle -->
|
<!-- Mobile drag handle -->
|
||||||
<div
|
<div
|
||||||
v-if="isMobile"
|
v-if="isMobile"
|
||||||
@@ -412,6 +473,17 @@ onBeforeUnmount(() => {
|
|||||||
<rect x="1" y="1" width="8" height="8" fill="currentColor"/>
|
<rect x="1" y="1" width="8" height="8" fill="currentColor"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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">
|
<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">
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
@@ -427,54 +499,27 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Agent/Session Selector Overlay -->
|
|
||||||
<Transition name="selector-slide">
|
|
||||||
<div v-if="showSelector" class="selector-overlay" @click.self="showSelector = false">
|
|
||||||
<div class="selector-panel">
|
|
||||||
<div class="selector-section">
|
|
||||||
<label class="selector-label">Agent</label>
|
|
||||||
<div class="agent-selector">
|
|
||||||
<button
|
|
||||||
v-for="a in agents"
|
|
||||||
:key="a.id"
|
|
||||||
:class="['agent-btn', { active: selectedAgent === a.id }]"
|
|
||||||
@click="handleAgentSwitch(a.id)"
|
|
||||||
>
|
|
||||||
{{ a.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="selector-section">
|
|
||||||
<label class="selector-label">Session</label>
|
|
||||||
<select
|
|
||||||
class="session-select"
|
|
||||||
:value="selectedSessionId || ''"
|
|
||||||
@change="handleSessionSelect(($event.target as HTMLSelectElement).value)"
|
|
||||||
:disabled="loading"
|
|
||||||
>
|
|
||||||
<option value="" disabled>Select session...</option>
|
|
||||||
<option v-for="s in sessions" :key="s.id" :value="s.id">
|
|
||||||
{{ s.firstUserMessage ? (s.firstUserMessage.length > 50 ? s.firstUserMessage.slice(0, 50) + '...' : s.firstUserMessage) : s.id.slice(0, 8) + '...' }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<span v-if="loading" class="spinner-sm"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error -->
|
||||||
<div v-if="error" class="error-bar">{{ error }}</div>
|
<div v-if="error" class="error-bar">{{ error }}</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<BackgroundPixelArt />
|
<AquaticBackground />
|
||||||
<div class="readability-overlay" />
|
<div class="readability-overlay" />
|
||||||
<ChatContainer
|
<ChatContainer
|
||||||
|
ref="chatRef"
|
||||||
v-if="conversation"
|
v-if="conversation"
|
||||||
:conversation="conversation"
|
:conversation="conversation"
|
||||||
:processing="processing"
|
:processing="processing"
|
||||||
|
:show-selector="showSelector"
|
||||||
|
:agents="agents"
|
||||||
|
:selected-agent="selectedAgent"
|
||||||
|
:sessions="sessions"
|
||||||
|
:selected-session-id="selectedSessionId"
|
||||||
|
:sessions-loading="loading"
|
||||||
@send="handleSend"
|
@send="handleSend"
|
||||||
|
@switch-agent="handleAgentSwitch"
|
||||||
|
@select-session="handleSessionSelect"
|
||||||
/>
|
/>
|
||||||
<div v-else class="empty-state">
|
<div v-else class="empty-state">
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
@@ -502,6 +547,38 @@ onBeforeUnmount(() => {
|
|||||||
transition: width 0.3s ease, height 0.3s ease, top 0.3s ease, left 0.3s ease, bottom 0.3s ease;
|
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.dragging,
|
||||||
.aero-win.resizing {
|
.aero-win.resizing {
|
||||||
transition: none;
|
transition: none;
|
||||||
@@ -534,7 +611,6 @@ onBeforeUnmount(() => {
|
|||||||
/* Transition all chrome elements smoothly */
|
/* Transition all chrome elements smoothly */
|
||||||
.titlebar,
|
.titlebar,
|
||||||
.error-bar,
|
.error-bar,
|
||||||
.selector-overlay,
|
|
||||||
.resize-handle {
|
.resize-handle {
|
||||||
transition: opacity 0.35s ease, max-height 0.35s ease, padding 0.35s ease;
|
transition: opacity 0.35s ease, max-height 0.35s ease, padding 0.35s ease;
|
||||||
}
|
}
|
||||||
@@ -561,14 +637,8 @@ onBeforeUnmount(() => {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Idle: hide selector overlay */
|
/* Chat-header: only visible when settings/selector is open */
|
||||||
.aero-win:not(.chrome-visible) .selector-overlay {
|
.aero-win:not(.selector-open) .content :deep(.chat-header) {
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Idle: slide chat-header up past titlebar and fade out */
|
|
||||||
.aero-win:not(.chrome-visible) .content :deep(.chat-header) {
|
|
||||||
opacity: 0 !important;
|
opacity: 0 !important;
|
||||||
transform: translateY(-150%) !important;
|
transform: translateY(-150%) !important;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
@@ -588,13 +658,27 @@ onBeforeUnmount(() => {
|
|||||||
pointer-events: auto !important;
|
pointer-events: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Idle: hide status bar */
|
||||||
|
.aero-win:not(.chrome-visible) .content :deep(.status-bar) {
|
||||||
|
opacity: 0 !important;
|
||||||
|
transform: translateY(100%) !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep status-bar visible when input has text */
|
||||||
|
.aero-win:not(.chrome-visible) .content :deep(.status-bar:has(~ .user-input .input-field:not(:placeholder-shown))) {
|
||||||
|
opacity: 1 !important;
|
||||||
|
transform: none !important;
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Idle: also hide selection bar */
|
/* Idle: also hide selection bar */
|
||||||
.aero-win:not(.chrome-visible) .content :deep(.selection-bar) {
|
.aero-win:not(.chrome-visible) .content :deep(.selection-bar) {
|
||||||
opacity: 0 !important;
|
opacity: 0 !important;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Idle: softer glass border */
|
/* Idle: softer glass border + dimmed glow */
|
||||||
.aero-win:not(.chrome-visible) .glass {
|
.aero-win:not(.chrome-visible) .glass {
|
||||||
border-color: rgba(255,255,255,0.02);
|
border-color: rgba(255,255,255,0.02);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
@@ -602,6 +686,11 @@ onBeforeUnmount(() => {
|
|||||||
0 8px 32px rgba(0,0,0,0.4);
|
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 */
|
/* Smooth transitions for chrome show/hide */
|
||||||
.content :deep(.chat-header) {
|
.content :deep(.chat-header) {
|
||||||
transition: opacity 0.35s ease, transform 0.35s ease !important;
|
transition: opacity 0.35s ease, transform 0.35s ease !important;
|
||||||
@@ -611,6 +700,10 @@ onBeforeUnmount(() => {
|
|||||||
transition: opacity 0.35s ease, transform 0.35s ease !important;
|
transition: opacity 0.35s ease, transform 0.35s ease !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content :deep(.status-bar) {
|
||||||
|
transition: opacity 0.35s ease, transform 0.35s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Titlebar: absolute overlay at top of .glass ── */
|
/* ── Titlebar: absolute overlay at top of .glass ── */
|
||||||
.titlebar {
|
.titlebar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -733,108 +826,6 @@ onBeforeUnmount(() => {
|
|||||||
color: #fca5a5;
|
color: #fca5a5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Selector Overlay ── */
|
|
||||||
.selector-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 28px;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 20;
|
|
||||||
background: rgba(8, 8, 12, 0.92);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
|
||||||
padding: 10px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-section {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-label {
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
color: rgba(255,255,255,0.4);
|
|
||||||
min-width: 48px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-selector {
|
|
||||||
display: flex;
|
|
||||||
background: rgba(255,255,255,0.04);
|
|
||||||
border: 1px solid rgba(255,255,255,0.08);
|
|
||||||
border-radius: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-btn {
|
|
||||||
padding: 4px 10px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: rgba(255,255,255,0.4);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-btn:not(:last-child) {
|
|
||||||
border-right: 1px solid rgba(255,255,255,0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-btn:hover {
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
color: rgba(255,255,255,0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-btn.active {
|
|
||||||
background: rgba(99, 102, 241, 0.35);
|
|
||||||
color: #c7d2fe;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-select {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 4px 8px;
|
|
||||||
background: rgba(255,255,255,0.04);
|
|
||||||
border: 1px solid rgba(255,255,255,0.08);
|
|
||||||
border-radius: 0;
|
|
||||||
color: rgba(255,255,255,0.8);
|
|
||||||
font-size: 10px;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: rgba(99, 102, 241, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-select option {
|
|
||||||
background: #0a0a10;
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner-sm {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border: 2px solid rgba(255,255,255,0.1);
|
|
||||||
border-top-color: #6366f1;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Error Bar ── */
|
/* ── Error Bar ── */
|
||||||
.error-bar {
|
.error-bar {
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
@@ -853,7 +844,7 @@ onBeforeUnmount(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
/* Background handled by BackgroundPixelArt component */
|
/* Background handled by AquaticBackground component */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark readability overlay: between ocean bg (z-index:0) and chat (z-index:1) */
|
/* Dark readability overlay: between ocean bg (z-index:0) and chat (z-index:1) */
|
||||||
@@ -900,20 +891,11 @@ onBeforeUnmount(() => {
|
|||||||
.content :deep(.messages-scroll) {
|
.content :deep(.messages-scroll) {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
padding-top: 5rem !important;
|
padding-top: 5rem !important;
|
||||||
padding-bottom: 3.5rem !important;
|
padding-bottom: 5rem !important;
|
||||||
flex: 1 !important;
|
flex: 1 !important;
|
||||||
scrollbar-gutter: stable !important;
|
scrollbar-gutter: stable !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content :deep(.chat-title-row) {
|
|
||||||
color: rgba(255,255,255,0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content :deep(.session-id) {
|
|
||||||
color: rgba(255,255,255,0.4);
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content :deep(.meta-badge) {
|
.content :deep(.meta-badge) {
|
||||||
background: rgba(255,255,255,0.04);
|
background: rgba(255,255,255,0.04);
|
||||||
color: rgba(255,255,255,0.4);
|
color: rgba(255,255,255,0.4);
|
||||||
@@ -991,12 +973,59 @@ onBeforeUnmount(() => {
|
|||||||
border-color: rgba(14, 165, 233, 0.3);
|
border-color: rgba(14, 165, 233, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* UserInput: absolute overlay at bottom, floats over messages */
|
/* Status bar: absolute overlay at very bottom */
|
||||||
.content :deep(.user-input) {
|
.content :deep(.status-bar) {
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
bottom: 0 !important;
|
bottom: 0 !important;
|
||||||
left: 0 !important;
|
left: 0 !important;
|
||||||
right: 0 !important;
|
right: 0 !important;
|
||||||
|
z-index: 4 !important;
|
||||||
|
background: rgba(0, 6, 18, 0.6) !important;
|
||||||
|
backdrop-filter: blur(8px) !important;
|
||||||
|
-webkit-backdrop-filter: blur(8px) !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: absolute overlay above status bar */
|
||||||
|
.content :deep(.user-input) {
|
||||||
|
position: absolute !important;
|
||||||
|
bottom: 20px !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
z-index: 3 !important;
|
z-index: 3 !important;
|
||||||
background: rgba(0, 6, 18, 0.5) !important;
|
background: rgba(0, 6, 18, 0.5) !important;
|
||||||
backdrop-filter: blur(8px) !important;
|
backdrop-filter: blur(8px) !important;
|
||||||
@@ -1139,16 +1168,6 @@ onBeforeUnmount(() => {
|
|||||||
transform: translateY(16px) scale(0.98);
|
transform: translateY(16px) scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.selector-slide-enter-active,
|
|
||||||
.selector-slide-leave-active {
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-slide-enter-from,
|
|
||||||
.selector-slide-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
|
|||||||
81
frontend/src/components/transcript-debug/CodeBlock.vue
Normal file
81
frontend/src/components/transcript-debug/CodeBlock.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { highlightCode } from '@/utils/markdown'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
code: string
|
||||||
|
lang?: string
|
||||||
|
maxHeight?: string
|
||||||
|
}>(), {
|
||||||
|
lang: '',
|
||||||
|
maxHeight: '250px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const highlighted = computed(() =>
|
||||||
|
highlightCode(props.code, props.lang || undefined)
|
||||||
|
)
|
||||||
|
|
||||||
|
const copyState = ref<'idle' | 'copied'>('idle')
|
||||||
|
|
||||||
|
function copy() {
|
||||||
|
navigator.clipboard.writeText(props.code).then(() => {
|
||||||
|
copyState.value = 'copied'
|
||||||
|
setTimeout(() => { copyState.value = 'idle' }, 1500)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="code-block">
|
||||||
|
<button class="copy-btn" @click.stop="copy">
|
||||||
|
{{ copyState === 'copied' ? 'Copied!' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
<pre class="code-pre" :style="{ maxHeight }" v-html="highlighted"></pre>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.code-block {
|
||||||
|
position: relative;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
background: var(--bg-secondary, #1a1a2e);
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: pre;
|
||||||
|
word-break: normal;
|
||||||
|
overflow: auto;
|
||||||
|
background: transparent;
|
||||||
|
font-family: 'Consolas', 'Lucida Console', 'SF Mono', 'Fira Code', monospace;
|
||||||
|
letter-spacing: 0;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
tab-size: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 0.15em 0.4em;
|
||||||
|
color: var(--text-muted, #94a3b8);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block:hover .copy-btn { opacity: 1; }
|
||||||
|
.copy-btn:hover { color: var(--text-primary, #e2e8f0); background: rgba(255, 255, 255, 0.1); }
|
||||||
|
</style>
|
||||||
@@ -1,43 +1,36 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { ParsedToolResult } from '@/types/transcript-debug'
|
import type { ParsedToolResult } from '@/types/transcript-debug'
|
||||||
import { highlightCode } from '@/utils/markdown'
|
import CodeBlock from './CodeBlock.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
result: ParsedToolResult
|
result: ParsedToolResult
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const highlightedContent = computed(() => {
|
const content = computed(() => props.result.content)
|
||||||
const content = props.result.content
|
|
||||||
const trimmed = content.trim()
|
const lang = computed(() => {
|
||||||
|
const trimmed = content.value.trim()
|
||||||
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
||||||
(trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
(trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(trimmed)
|
JSON.parse(trimmed)
|
||||||
return highlightCode(JSON.stringify(parsed, null, 2), 'json')
|
return 'json'
|
||||||
} catch { /* not valid JSON */ }
|
} catch { /* not valid JSON */ }
|
||||||
}
|
}
|
||||||
return highlightCode(content)
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayCode = computed(() => {
|
||||||
|
if (lang.value === 'json') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(content.value.trim()), null, 2)
|
||||||
|
} catch { /* fallback */ }
|
||||||
|
}
|
||||||
|
return content.value
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<pre class="result-content" v-html="highlightedContent"></pre>
|
<CodeBlock :code="displayCode" :lang="lang" max-height="300px" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.result-content {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.35rem 0.6rem;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.45;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: transparent;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
|
||||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onBeforeUnmount, computed } from 'vue'
|
||||||
|
import { useAquaticEvents } from './useAquaticEvents'
|
||||||
|
import { useAquaticState } from './useAquaticState'
|
||||||
|
import {
|
||||||
|
OceanScene,
|
||||||
|
BubbleStream,
|
||||||
|
FishSchool,
|
||||||
|
JellyfishDrift,
|
||||||
|
EventOverlay,
|
||||||
|
EdgeFade,
|
||||||
|
} from './layers'
|
||||||
|
|
||||||
|
const { start, stop } = useAquaticEvents()
|
||||||
|
const { timeOfDay, season, depthZone } = useAquaticState()
|
||||||
|
|
||||||
|
const rootClasses = computed(() => [
|
||||||
|
`tod-${timeOfDay.value}`,
|
||||||
|
`season-${season.value}`,
|
||||||
|
`depth-${depthZone.value}`,
|
||||||
|
])
|
||||||
|
|
||||||
|
onMounted(() => start())
|
||||||
|
onBeforeUnmount(() => stop())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="aquatic-bg" :class="rootClasses">
|
||||||
|
<!-- The world: unified background scene -->
|
||||||
|
<OceanScene />
|
||||||
|
|
||||||
|
<!-- Independent dynamic overlay layers -->
|
||||||
|
<BubbleStream />
|
||||||
|
<FishSchool />
|
||||||
|
<JellyfishDrift />
|
||||||
|
<EventOverlay />
|
||||||
|
<EdgeFade />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.aquatic-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.aquatic-bg :deep(*) {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as AquaticBackground } from './AquaticBackground.vue'
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bubble-stream">
|
||||||
|
<div class="bubbles-base" />
|
||||||
|
<div v-if="burstActive" class="bubbles-burst" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAquaticState } from '../useAquaticState'
|
||||||
|
|
||||||
|
const { activeEventModifiers } = useAquaticState()
|
||||||
|
const burstActive = computed(() => activeEventModifiers.value.has('bubble-burst'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bubble-stream {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubbles-base {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
/* Large bubbles - slow rise */
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='90' height='220' viewBox='0 0 90 220' shape-rendering='crispEdges'%3E%3Crect x='22' y='30' width='4' height='4' fill='%2367e8f9' opacity='0.22'/%3E%3Crect x='65' y='95' width='4' height='4' fill='%2367e8f9' opacity='0.18'/%3E%3Crect x='38' y='160' width='4' height='4' fill='%2367e8f9' opacity='0.24'/%3E%3Crect x='10' y='200' width='4' height='4' fill='%2367e8f9' opacity='0.16'/%3E%3Crect x='75' y='50' width='4' height='4' fill='%2367e8f9' opacity='0.14'/%3E%3C/svg%3E") repeat / 90px 220px,
|
||||||
|
/* Medium bubbles - medium rise */
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='110' height='300' viewBox='0 0 110 300' shape-rendering='crispEdges'%3E%3Crect x='30' y='40' width='2' height='2' fill='white' opacity='0.16'/%3E%3Crect x='80' y='120' width='2' height='2' fill='white' opacity='0.13'/%3E%3Crect x='15' y='190' width='2' height='2' fill='white' opacity='0.16'/%3E%3Crect x='60' y='260' width='2' height='2' fill='white' opacity='0.12'/%3E%3Crect x='95' y='70' width='2' height='2' fill='white' opacity='0.14'/%3E%3Crect x='45' y='160' width='2' height='2' fill='white' opacity='0.1'/%3E%3C/svg%3E") repeat / 110px 300px,
|
||||||
|
/* Tiny bubbles - fast rise */
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='70' height='160' viewBox='0 0 70 160' shape-rendering='crispEdges'%3E%3Crect x='15' y='20' width='2' height='2' fill='%2367e8f9' opacity='0.1'/%3E%3Crect x='50' y='60' width='2' height='2' fill='%2367e8f9' opacity='0.08'/%3E%3Crect x='30' y='100' width='2' height='2' fill='white' opacity='0.09'/%3E%3Crect x='60' y='140' width='2' height='2' fill='%2367e8f9' opacity='0.07'/%3E%3Crect x='8' y='80' width='2' height='2' fill='white' opacity='0.08'/%3E%3C/svg%3E") repeat / 70px 160px;
|
||||||
|
animation: sea-bubbles 16s linear infinite, water-sway 10s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Burst event: extra large bubbles from a point */
|
||||||
|
.bubbles-burst {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='180' viewBox='0 0 60 180' shape-rendering='crispEdges'%3E%3Crect x='25' y='20' width='6' height='6' fill='%2367e8f9' opacity='0.3'/%3E%3Crect x='15' y='70' width='6' height='6' fill='%2367e8f9' opacity='0.28'/%3E%3Crect x='35' y='110' width='6' height='6' fill='%2367e8f9' opacity='0.32'/%3E%3Crect x='20' y='150' width='6' height='6' fill='white' opacity='0.25'/%3E%3Crect x='40' y='40' width='4' height='4' fill='white' opacity='0.22'/%3E%3C/svg%3E") repeat / 60px 180px;
|
||||||
|
animation: burst-rise 6s linear infinite;
|
||||||
|
opacity: 0;
|
||||||
|
animation: burst-rise 6s linear infinite, burst-fade 8s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sea-bubbles {
|
||||||
|
from { background-position: 0 0, 30px 0, 10px 0; }
|
||||||
|
to { background-position: 0 -220px, 0 -300px, -15px -160px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes water-sway {
|
||||||
|
from { transform: translateX(-3px); }
|
||||||
|
to { transform: translateX(3px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes burst-rise {
|
||||||
|
from { background-position: 30px 0; }
|
||||||
|
to { background-position: 30px -180px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes burst-fade {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
10% { opacity: 0.8; }
|
||||||
|
80% { opacity: 0.6; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<div class="edge-fade" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.edge-fade {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-fade::before,
|
||||||
|
.edge-fade::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-fade::before {
|
||||||
|
top: 0;
|
||||||
|
height: 25%;
|
||||||
|
background: linear-gradient(to bottom, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 40%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edge-fade::after {
|
||||||
|
bottom: 0;
|
||||||
|
height: 25%;
|
||||||
|
background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 40%, transparent 100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAquaticState } from '../useAquaticState'
|
||||||
|
|
||||||
|
const { activeEventModifiers } = useAquaticState()
|
||||||
|
|
||||||
|
const eventClasses = computed(() => {
|
||||||
|
const cls: Record<string, boolean> = {}
|
||||||
|
for (const [, cssClass] of activeEventModifiers.value) {
|
||||||
|
cls[cssClass] = true
|
||||||
|
}
|
||||||
|
return cls
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="event-overlay" :class="eventClasses">
|
||||||
|
<!-- Whale shadow -->
|
||||||
|
<div v-if="eventClasses['evt-whale-shadow']" class="whale-shadow" />
|
||||||
|
|
||||||
|
<!-- Bioluminescent flash -->
|
||||||
|
<div v-if="eventClasses['evt-bioluminescent-flash']" class="bio-flash" />
|
||||||
|
|
||||||
|
<!-- Plankton drift -->
|
||||||
|
<div v-if="eventClasses['evt-plankton-drift']" class="plankton" />
|
||||||
|
|
||||||
|
<!-- Fish chase -->
|
||||||
|
<div v-if="eventClasses['evt-fish-chase']" class="fish-chase" />
|
||||||
|
|
||||||
|
<!-- Turtle crossing -->
|
||||||
|
<div v-if="eventClasses['evt-turtle-crossing']" class="turtle" />
|
||||||
|
|
||||||
|
<!-- Manta ray -->
|
||||||
|
<div v-if="eventClasses['evt-manta-ray']" class="manta" />
|
||||||
|
|
||||||
|
<!-- Color shift (gradient overlay) -->
|
||||||
|
<div v-if="eventClasses['evt-color-shift']" class="color-shift" />
|
||||||
|
|
||||||
|
<!-- Aurora underwater -->
|
||||||
|
<div v-if="eventClasses['evt-aurora-underwater']" class="aurora" />
|
||||||
|
|
||||||
|
<!-- Mythical creature -->
|
||||||
|
<div v-if="eventClasses['evt-mythical-creature']" class="mythical" />
|
||||||
|
|
||||||
|
<!-- Volcanic vent -->
|
||||||
|
<div v-if="eventClasses['evt-volcanic-vent']" class="volcanic" />
|
||||||
|
|
||||||
|
<!-- Crystal formation -->
|
||||||
|
<div v-if="eventClasses['evt-crystal-formation']" class="crystals" />
|
||||||
|
|
||||||
|
<!-- Kelp forest -->
|
||||||
|
<div v-if="eventClasses['evt-kelp-forest']" class="kelp-extra" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.event-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-overlay > div {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Whale Shadow ── */
|
||||||
|
.whale-shadow {
|
||||||
|
background:
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='40' viewBox='0 0 120 40' shape-rendering='crispEdges'%3E%3Cellipse cx='60' cy='20' rx='58' ry='18' fill='%23000' opacity='0.2'/%3E%3Cellipse cx='55' cy='20' rx='45' ry='14' fill='%23000' opacity='0.15'/%3E%3Crect x='100' y='8' width='16' height='6' fill='%23000' opacity='0.15'/%3E%3Crect x='100' y='26' width='16' height='6' fill='%23000' opacity='0.15'/%3E%3C/svg%3E") no-repeat / 180px 60px;
|
||||||
|
animation: whale-cross 20s linear forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes whale-cross {
|
||||||
|
0% { background-position: -200px 15%; opacity: 0; }
|
||||||
|
5% { opacity: 0.8; }
|
||||||
|
90% { opacity: 0.7; }
|
||||||
|
100% { background-position: calc(100% + 200px) 20%; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bioluminescent Flash ── */
|
||||||
|
.bio-flash {
|
||||||
|
background: radial-gradient(
|
||||||
|
ellipse 40% 50% at 50% 75%,
|
||||||
|
rgba(74, 222, 128, 0.12) 0%,
|
||||||
|
rgba(34, 211, 238, 0.06) 40%,
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
|
animation: bio-pulse 3s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bio-pulse {
|
||||||
|
from { opacity: 0.4; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Plankton Drift ── */
|
||||||
|
.plankton {
|
||||||
|
background:
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200' viewBox='0 0 200 200' shape-rendering='crispEdges'%3E%3Crect x='20' y='30' width='1' height='1' fill='white' opacity='0.2'/%3E%3Crect x='80' y='50' width='1' height='1' fill='white' opacity='0.15'/%3E%3Crect x='140' y='20' width='1' height='1' fill='white' opacity='0.18'/%3E%3Crect x='50' y='90' width='1' height='1' fill='%2367e8f9' opacity='0.12'/%3E%3Crect x='170' y='80' width='1' height='1' fill='white' opacity='0.16'/%3E%3Crect x='30' y='140' width='1' height='1' fill='%2367e8f9' opacity='0.14'/%3E%3Crect x='110' y='120' width='1' height='1' fill='white' opacity='0.17'/%3E%3Crect x='160' y='160' width='1' height='1' fill='white' opacity='0.13'/%3E%3Crect x='60' y='170' width='1' height='1' fill='%2367e8f9' opacity='0.15'/%3E%3Crect x='120' y='180' width='1' height='1' fill='white' opacity='0.11'/%3E%3C/svg%3E") repeat;
|
||||||
|
animation: plankton-float 12s linear forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes plankton-float {
|
||||||
|
0% { background-position: 0 0; opacity: 0; }
|
||||||
|
10% { opacity: 0.7; }
|
||||||
|
85% { opacity: 0.5; }
|
||||||
|
100% { background-position: -80px -40px; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Fish Chase ── */
|
||||||
|
.fish-chase {
|
||||||
|
opacity: 0;
|
||||||
|
animation: chase-sequence 10s linear forwards;
|
||||||
|
background:
|
||||||
|
/* Chaser fish (faster) */
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='2' height='1' fill='%23ef4444' opacity='0.6'/%3E%3Crect x='0' y='4' width='2' height='1' fill='%23ef4444' opacity='0.6'/%3E%3Crect x='2' y='1' width='6' height='4' fill='%23f87171' opacity='0.55'/%3E%3Crect x='7' y='2' width='1' height='1' fill='%23000' opacity='0.5'/%3E%3C/svg%3E") no-repeat / 20px 12px,
|
||||||
|
/* Fleeing fish (slightly ahead) */
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='1' height='1' fill='%23fbbf24' opacity='0.55'/%3E%3Crect x='0' y='3' width='1' height='1' fill='%23fbbf24' opacity='0.55'/%3E%3Crect x='1' y='0' width='5' height='5' fill='%23f59e0b' opacity='0.5'/%3E%3Crect x='5' y='1' width='1' height='1' fill='%23000' opacity='0.45'/%3E%3C/svg%3E") no-repeat / 16px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes chase-sequence {
|
||||||
|
0% { background-position: -40px 45%, -20px 44%; opacity: 0; }
|
||||||
|
5% { opacity: 0.9; }
|
||||||
|
85% { opacity: 0.8; }
|
||||||
|
100% { background-position: calc(100% + 60px) 40%, calc(100% + 40px) 42%; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Turtle Crossing ── */
|
||||||
|
.turtle {
|
||||||
|
background:
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='14' viewBox='0 0 20 14' shape-rendering='crispEdges'%3E%3Crect x='6' y='3' width='8' height='8' fill='%2316a34a' opacity='0.5'/%3E%3Crect x='7' y='4' width='6' height='6' fill='%234ade80' opacity='0.4'/%3E%3Crect x='8' y='5' width='2' height='2' fill='%2322c55e' opacity='0.35'/%3E%3Crect x='11' y='6' width='2' height='2' fill='%2322c55e' opacity='0.35'/%3E%3Crect x='14' y='6' width='4' height='2' fill='%234ade80' opacity='0.45'/%3E%3Crect x='17' y='5' width='2' height='1' fill='%2316a34a' opacity='0.4'/%3E%3Crect x='18' y='6' width='1' height='1' fill='%23000' opacity='0.3'/%3E%3Crect x='2' y='4' width='4' height='2' fill='%234ade80' opacity='0.4'/%3E%3Crect x='2' y='8' width='4' height='2' fill='%234ade80' opacity='0.4'/%3E%3Crect x='10' y='2' width='3' height='2' fill='%234ade80' opacity='0.35'/%3E%3Crect x='10' y='10' width='3' height='2' fill='%234ade80' opacity='0.35'/%3E%3C/svg%3E") no-repeat / 40px 28px;
|
||||||
|
animation: turtle-swim 25s linear forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes turtle-swim {
|
||||||
|
0% { background-position: -50px 30%; opacity: 0; }
|
||||||
|
5% { opacity: 0.8; }
|
||||||
|
90% { opacity: 0.7; }
|
||||||
|
100% { background-position: calc(100% + 50px) 35%; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Manta Ray ── */
|
||||||
|
.manta {
|
||||||
|
background:
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='30' height='12' viewBox='0 0 30 12' shape-rendering='crispEdges'%3E%3Crect x='10' y='4' width='10' height='4' fill='%23334155' opacity='0.45'/%3E%3Crect x='8' y='3' width='14' height='6' fill='%23475569' opacity='0.4'/%3E%3Crect x='4' y='2' width='6' height='3' fill='%23475569' opacity='0.35'/%3E%3Crect x='20' y='2' width='6' height='3' fill='%23475569' opacity='0.35'/%3E%3Crect x='0' y='1' width='5' height='2' fill='%23475569' opacity='0.25'/%3E%3Crect x='25' y='1' width='5' height='2' fill='%23475569' opacity='0.25'/%3E%3Crect x='4' y='7' width='6' height='3' fill='%23475569' opacity='0.35'/%3E%3Crect x='20' y='7' width='6' height='3' fill='%23475569' opacity='0.35'/%3E%3Crect x='0' y='9' width='5' height='2' fill='%23475569' opacity='0.25'/%3E%3Crect x='25' y='9' width='5' height='2' fill='%23475569' opacity='0.25'/%3E%3Crect x='14' y='5' width='2' height='1' fill='%23000' opacity='0.3'/%3E%3C/svg%3E") no-repeat / 60px 24px;
|
||||||
|
animation: manta-glide 18s linear forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes manta-glide {
|
||||||
|
0% { background-position: calc(100% + 80px) 18%; opacity: 0; }
|
||||||
|
5% { opacity: 0.8; }
|
||||||
|
90% { opacity: 0.7; }
|
||||||
|
100% { background-position: -80px 22%; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Color Shift ── */
|
||||||
|
.color-shift {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(56, 189, 248, 0.03) 0%,
|
||||||
|
rgba(14, 165, 233, 0.05) 50%,
|
||||||
|
rgba(6, 182, 212, 0.04) 100%
|
||||||
|
);
|
||||||
|
animation: color-shift-fade 30s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes color-shift-fade {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
15% { opacity: 1; }
|
||||||
|
85% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Aurora Underwater ── */
|
||||||
|
.aurora {
|
||||||
|
background:
|
||||||
|
linear-gradient(0deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(139, 92, 246, 0.04) 15%,
|
||||||
|
rgba(34, 211, 238, 0.05) 25%,
|
||||||
|
rgba(52, 211, 153, 0.04) 35%,
|
||||||
|
transparent 50%
|
||||||
|
);
|
||||||
|
animation: aurora-wave 8s ease-in-out infinite alternate, aurora-fade 300s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes aurora-wave {
|
||||||
|
from { background-position: 0 0; filter: hue-rotate(0deg); }
|
||||||
|
to { background-position: 0 -20px; filter: hue-rotate(30deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes aurora-fade {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
5% { opacity: 1; }
|
||||||
|
95% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mythical Creature (large serpent silhouette) ── */
|
||||||
|
.mythical {
|
||||||
|
background:
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='30' viewBox='0 0 160 30' shape-rendering='crispEdges'%3E%3Crect x='0' y='12' width='8' height='6' fill='%23312e81' opacity='0.2'/%3E%3Crect x='8' y='10' width='12' height='10' fill='%23312e81' opacity='0.22'/%3E%3Crect x='20' y='13' width='10' height='4' fill='%23312e81' opacity='0.2'/%3E%3Crect x='30' y='8' width='14' height='14' fill='%23312e81' opacity='0.25'/%3E%3Crect x='44' y='12' width='12' height='6' fill='%23312e81' opacity='0.2'/%3E%3Crect x='56' y='6' width='16' height='18' fill='%23312e81' opacity='0.28'/%3E%3Crect x='72' y='11' width='14' height='8' fill='%23312e81' opacity='0.22'/%3E%3Crect x='86' y='4' width='18' height='22' fill='%23312e81' opacity='0.3'/%3E%3Crect x='104' y='10' width='12' height='10' fill='%23312e81' opacity='0.25'/%3E%3Crect x='116' y='7' width='14' height='16' fill='%23312e81' opacity='0.27'/%3E%3Crect x='130' y='12' width='10' height='6' fill='%23312e81' opacity='0.2'/%3E%3Crect x='140' y='14' width='8' height='4' fill='%23312e81' opacity='0.18'/%3E%3Crect x='148' y='15' width='6' height='2' fill='%23312e81' opacity='0.15'/%3E%3Crect x='154' y='14' width='4' height='4' fill='%23312e81' opacity='0.18'/%3E%3Crect x='156' y='13' width='2' height='1' fill='%23818cf8' opacity='0.25'/%3E%3C/svg%3E") no-repeat / 240px 45px;
|
||||||
|
animation: mythical-cross 30s linear forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mythical-cross {
|
||||||
|
0% { background-position: -260px 40%; opacity: 0; }
|
||||||
|
5% { opacity: 0.7; }
|
||||||
|
90% { opacity: 0.6; }
|
||||||
|
100% { background-position: calc(100% + 260px) 35%; opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Volcanic Vent ── */
|
||||||
|
.volcanic {
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
ellipse 30% 20% at 50% 95%,
|
||||||
|
rgba(249, 115, 22, 0.15) 0%,
|
||||||
|
rgba(239, 68, 68, 0.08) 40%,
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
|
animation: volcanic-glow 4s ease-in-out infinite alternate, volcanic-fade 120s ease-in-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes volcanic-glow {
|
||||||
|
from { opacity: 0.6; filter: brightness(0.9); }
|
||||||
|
to { opacity: 1; filter: brightness(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes volcanic-fade {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
5% { opacity: 1; }
|
||||||
|
90% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Crystal Formation ── */
|
||||||
|
.crystals {
|
||||||
|
bottom: 0;
|
||||||
|
height: 25%;
|
||||||
|
top: auto;
|
||||||
|
background:
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='60' viewBox='0 0 300 60' shape-rendering='crispEdges'%3E%3Crect x='40' y='30' width='4' height='20' fill='%2367e8f9' opacity='0.25'/%3E%3Crect x='42' y='25' width='2' height='8' fill='%23a5f3fc' opacity='0.2'/%3E%3Crect x='38' y='35' width='2' height='10' fill='%2322d3ee' opacity='0.18'/%3E%3Crect x='120' y='28' width='6' height='24' fill='%23a78bfa' opacity='0.22'/%3E%3Crect x='123' y='22' width='2' height='10' fill='%23c4b5fd' opacity='0.18'/%3E%3Crect x='118' y='32' width='2' height='14' fill='%238b5cf6' opacity='0.15'/%3E%3Crect x='200' y='32' width='4' height='18' fill='%2367e8f9' opacity='0.2'/%3E%3Crect x='202' y='28' width='2' height='6' fill='white' opacity='0.15'/%3E%3Crect x='260' y='35' width='5' height='16' fill='%23a78bfa' opacity='0.2'/%3E%3Crect x='262' y='30' width='2' height='8' fill='%23c4b5fd' opacity='0.16'/%3E%3C/svg%3E") repeat-x bottom center / 300px 60px;
|
||||||
|
animation: crystal-appear 240s ease-in-out forwards, crystal-sparkle 2s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes crystal-appear {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
5% { opacity: 1; }
|
||||||
|
92% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes crystal-sparkle {
|
||||||
|
from { filter: brightness(1); }
|
||||||
|
to { filter: brightness(1.15); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Kelp Forest (extra seaweed) ── */
|
||||||
|
.kelp-extra {
|
||||||
|
background:
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='120' viewBox='0 0 300 120' shape-rendering='crispEdges'%3E%3Crect x='30' y='20' width='2' height='100' fill='%2316a34a' opacity='0.35'/%3E%3Crect x='32' y='14' width='2' height='20' fill='%2322c55e' opacity='0.3'/%3E%3Crect x='28' y='30' width='2' height='15' fill='%2315803d' opacity='0.28'/%3E%3Crect x='100' y='10' width='2' height='110' fill='%2322c55e' opacity='0.32'/%3E%3Crect x='102' y='5' width='2' height='18' fill='%234ade80' opacity='0.28'/%3E%3Crect x='98' y='25' width='2' height='12' fill='%2316a34a' opacity='0.25'/%3E%3Crect x='180' y='25' width='2' height='95' fill='%2316a34a' opacity='0.33'/%3E%3Crect x='182' y='18' width='2' height='16' fill='%2322c55e' opacity='0.28'/%3E%3Crect x='250' y='15' width='2' height='105' fill='%2322c55e' opacity='0.3'/%3E%3Crect x='252' y='8' width='2' height='14' fill='%234ade80' opacity='0.25'/%3E%3Crect x='248' y='30' width='2' height='10' fill='%2315803d' opacity='0.22'/%3E%3C/svg%3E") repeat-x bottom center / 300px 120px;
|
||||||
|
animation: kelp-fade 150s ease-in-out forwards, kelp-sway 12s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes kelp-fade {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
5% { opacity: 1; }
|
||||||
|
90% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes kelp-sway {
|
||||||
|
from { transform: skewX(-1deg); }
|
||||||
|
to { transform: skewX(1deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fish-school">
|
||||||
|
<div class="fish-main" />
|
||||||
|
<div class="fish-secondary" />
|
||||||
|
<div v-if="schoolActive" class="fish-event-school" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAquaticState } from '../useAquaticState'
|
||||||
|
|
||||||
|
const { activeEventModifiers } = useAquaticState()
|
||||||
|
const schoolActive = computed(() => activeEventModifiers.value.has('school-of-fish'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fish-school {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 7;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fish 1: orange tropical (right-facing) + Fish 2: blue indigo (left-facing) */
|
||||||
|
.fish-main {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
/* Orange tropical fish 16x10 */
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='10' viewBox='0 0 16 10' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='2' height='2' fill='%23f97316' opacity='0.65'/%3E%3Crect x='0' y='7' width='2' height='2' fill='%23f97316' opacity='0.65'/%3E%3Crect x='2' y='2' width='2' height='6' fill='%23fb923c' opacity='0.65'/%3E%3Crect x='4' y='1' width='8' height='8' fill='%23f97316' opacity='0.6'/%3E%3Crect x='7' y='1' width='2' height='8' fill='%23fef3c7' opacity='0.45'/%3E%3Crect x='5' y='0' width='4' height='1' fill='%23fb923c' opacity='0.45'/%3E%3Crect x='5' y='9' width='4' height='1' fill='%23fb923c' opacity='0.45'/%3E%3Crect x='10' y='3' width='2' height='2' fill='%23000' opacity='0.55'/%3E%3Crect x='11' y='3' width='1' height='1' fill='white' opacity='0.45'/%3E%3Crect x='12' y='5' width='2' height='1' fill='%23000' opacity='0.3'/%3E%3C/svg%3E") no-repeat / 32px 20px,
|
||||||
|
/* Blue indigo fish 12x8 */
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' shape-rendering='crispEdges'%3E%3Crect x='10' y='1' width='2' height='2' fill='%236366f1' opacity='0.55'/%3E%3Crect x='10' y='5' width='2' height='2' fill='%236366f1' opacity='0.55'/%3E%3Crect x='8' y='2' width='2' height='4' fill='%23818cf8' opacity='0.55'/%3E%3Crect x='2' y='1' width='6' height='6' fill='%236366f1' opacity='0.5'/%3E%3Crect x='2' y='2' width='2' height='2' fill='%23000' opacity='0.45'/%3E%3Crect x='2' y='2' width='1' height='1' fill='white' opacity='0.35'/%3E%3Crect x='4' y='0' width='3' height='1' fill='%23818cf8' opacity='0.4'/%3E%3C/svg%3E") no-repeat / 24px 16px;
|
||||||
|
animation: fish-swim 22s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Green fish + Yellow puffer + Red clownfish */
|
||||||
|
.fish-secondary {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
/* Green fish 10x6 (left-facing) */
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='2' height='1' fill='%2322c55e' opacity='0.5'/%3E%3Crect x='0' y='4' width='2' height='1' fill='%2322c55e' opacity='0.5'/%3E%3Crect x='2' y='1' width='1' height='4' fill='%234ade80' opacity='0.5'/%3E%3Crect x='3' y='0' width='5' height='6' fill='%2322c55e' opacity='0.45'/%3E%3Crect x='6' y='1' width='2' height='2' fill='%23000' opacity='0.4'/%3E%3Crect x='7' y='1' width='1' height='1' fill='white' opacity='0.3'/%3E%3Crect x='8' y='3' width='2' height='1' fill='%23000' opacity='0.25'/%3E%3C/svg%3E") no-repeat / 20px 12px,
|
||||||
|
/* Yellow puffer 14x12 (right-facing) */
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='12' viewBox='0 0 14 12' shape-rendering='crispEdges'%3E%3Crect x='0' y='3' width='2' height='2' fill='%23fbbf24' opacity='0.5'/%3E%3Crect x='0' y='7' width='2' height='2' fill='%23fbbf24' opacity='0.5'/%3E%3Crect x='2' y='2' width='2' height='8' fill='%23f59e0b' opacity='0.5'/%3E%3Crect x='4' y='1' width='7' height='10' fill='%23fbbf24' opacity='0.48'/%3E%3Crect x='5' y='0' width='5' height='1' fill='%23f59e0b' opacity='0.35'/%3E%3Crect x='5' y='11' width='5' height='1' fill='%23f59e0b' opacity='0.35'/%3E%3Crect x='6' y='2' width='2' height='8' fill='%23fef3c7' opacity='0.3'/%3E%3Crect x='9' y='4' width='2' height='2' fill='%23000' opacity='0.5'/%3E%3Crect x='10' y='4' width='1' height='1' fill='white' opacity='0.35'/%3E%3Crect x='11' y='6' width='2' height='1' fill='%23000' opacity='0.3'/%3E%3Crect x='4' y='3' width='1' height='1' fill='%23f59e0b' opacity='0.25'/%3E%3Crect x='4' y='5' width='1' height='1' fill='%23f59e0b' opacity='0.25'/%3E%3Crect x='4' y='7' width='1' height='1' fill='%23f59e0b' opacity='0.25'/%3E%3C/svg%3E") no-repeat / 28px 24px,
|
||||||
|
/* Red clownfish 12x8 (left-facing) */
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' shape-rendering='crispEdges'%3E%3Crect x='10' y='1' width='2' height='2' fill='%23ef4444' opacity='0.55'/%3E%3Crect x='10' y='5' width='2' height='2' fill='%23ef4444' opacity='0.55'/%3E%3Crect x='8' y='1' width='2' height='6' fill='%23f87171' opacity='0.55'/%3E%3Crect x='2' y='1' width='6' height='6' fill='%23ef4444' opacity='0.5'/%3E%3Crect x='4' y='1' width='1' height='6' fill='white' opacity='0.4'/%3E%3Crect x='7' y='1' width='1' height='6' fill='white' opacity='0.35'/%3E%3Crect x='2' y='2' width='2' height='2' fill='%23000' opacity='0.45'/%3E%3Crect x='2' y='2' width='1' height='1' fill='white' opacity='0.35'/%3E%3Crect x='2' y='0' width='4' height='1' fill='%23f87171' opacity='0.4'/%3E%3Crect x='2' y='7' width='4' height='1' fill='%23f87171' opacity='0.4'/%3E%3C/svg%3E") no-repeat / 24px 16px;
|
||||||
|
animation: fish-swim-secondary 28s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event: school of fish - many small fast fish */
|
||||||
|
.fish-event-school {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='1' height='1' fill='%2394a3b8' opacity='0.5'/%3E%3Crect x='0' y='3' width='1' height='1' fill='%2394a3b8' opacity='0.5'/%3E%3Crect x='1' y='1' width='1' height='3' fill='%23cbd5e1' opacity='0.5'/%3E%3Crect x='2' y='0' width='4' height='5' fill='%2394a3b8' opacity='0.45'/%3E%3Crect x='5' y='1' width='1' height='1' fill='%23000' opacity='0.4'/%3E%3C/svg%3E") no-repeat / 16px 10px,
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='1' height='1' fill='%2394a3b8' opacity='0.45'/%3E%3Crect x='0' y='3' width='1' height='1' fill='%2394a3b8' opacity='0.45'/%3E%3Crect x='1' y='1' width='1' height='3' fill='%23cbd5e1' opacity='0.45'/%3E%3Crect x='2' y='0' width='4' height='5' fill='%2394a3b8' opacity='0.4'/%3E%3Crect x='5' y='1' width='1' height='1' fill='%23000' opacity='0.35'/%3E%3C/svg%3E") no-repeat / 16px 10px,
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5' shape-rendering='crispEdges'%3E%3Crect x='0' y='1' width='1' height='1' fill='%2394a3b8' opacity='0.4'/%3E%3Crect x='0' y='3' width='1' height='1' fill='%2394a3b8' opacity='0.4'/%3E%3Crect x='1' y='1' width='1' height='3' fill='%23cbd5e1' opacity='0.4'/%3E%3Crect x='2' y='0' width='4' height='5' fill='%2394a3b8' opacity='0.35'/%3E%3Crect x='5' y='1' width='1' height='1' fill='%23000' opacity='0.3'/%3E%3C/svg%3E") no-repeat / 16px 10px;
|
||||||
|
animation: school-dash 8s linear forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fish-swim {
|
||||||
|
0% { background-position: -40px 22%, calc(100% + 30px) 52%; }
|
||||||
|
100% { background-position: calc(100% + 40px) 28%, -30px 48%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fish-swim-secondary {
|
||||||
|
0% { background-position: calc(100% + 25px) 68%, -35px 38%, calc(100% + 30px) 75%; }
|
||||||
|
100% { background-position: -25px 62%, calc(100% + 35px) 42%, -30px 70%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes school-dash {
|
||||||
|
0% { background-position: -20px 30%, -30px 35%, -15px 40%; opacity: 0; }
|
||||||
|
5% { opacity: 0.8; }
|
||||||
|
90% { opacity: 0.7; }
|
||||||
|
100% { background-position: calc(100% + 80px) 32%, calc(100% + 60px) 38%, calc(100% + 90px) 36%; opacity: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { svgDataUri, jellyfishSvg } from '../svgPatterns'
|
||||||
|
import { useAquaticState } from '../useAquaticState'
|
||||||
|
|
||||||
|
const { activeEventModifiers } = useAquaticState()
|
||||||
|
const bioActive = computed(() => activeEventModifiers.value.has('bioluminescent-flash'))
|
||||||
|
|
||||||
|
const jelly1Bg = svgDataUri(jellyfishSvg('#c084fc', '#d8b4fe'))
|
||||||
|
const jelly2Bg = svgDataUri(jellyfishSvg('#67e8f9', '#a5f3fc'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="jellyfish-drift" :class="{ bioluminescent: bioActive }">
|
||||||
|
<div
|
||||||
|
class="jelly jelly-1"
|
||||||
|
:style="{ backgroundImage: jelly1Bg }"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="jelly jelly-2"
|
||||||
|
:style="{ backgroundImage: jelly2Bg }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jellyfish-drift {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 8;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jelly {
|
||||||
|
position: absolute;
|
||||||
|
width: 32px;
|
||||||
|
height: 44px;
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jelly-1 {
|
||||||
|
left: 20%;
|
||||||
|
animation:
|
||||||
|
jelly-vertical 45s linear infinite,
|
||||||
|
jelly-horizontal 8s ease-in-out infinite alternate,
|
||||||
|
jelly-pulse 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jelly-2 {
|
||||||
|
left: 68%;
|
||||||
|
animation:
|
||||||
|
jelly-vertical 55s linear infinite -20s,
|
||||||
|
jelly-horizontal 10s ease-in-out infinite alternate -4s,
|
||||||
|
jelly-pulse 3.5s ease-in-out infinite -1.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jellyfish-drift.bioluminescent .jelly {
|
||||||
|
filter: drop-shadow(0 0 6px rgba(103, 232, 249, 0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jelly-vertical {
|
||||||
|
0% { top: -50px; }
|
||||||
|
100% { top: calc(100% + 50px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jelly-horizontal {
|
||||||
|
from { transform: translateX(-15px); }
|
||||||
|
to { transform: translateX(15px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jelly-pulse {
|
||||||
|
0%, 100% { transform: scaleY(1); }
|
||||||
|
50% { transform: scaleY(0.9); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* OceanScene — The unified static/dynamic background scene.
|
||||||
|
*
|
||||||
|
* Combines water gradient, light rays, sea floor, corals, seaweed, and
|
||||||
|
* decorations into a single cohesive component. Everything here is the
|
||||||
|
* "world" — the environment that all dynamic overlay layers (fish, bubbles,
|
||||||
|
* jellyfish, events) float on top of.
|
||||||
|
*
|
||||||
|
* Internal z-index stacking (within this component):
|
||||||
|
* 0 water-gradient
|
||||||
|
* 1 light-rays
|
||||||
|
* 2 sea-floor
|
||||||
|
* 3 decorations (starfish, shells)
|
||||||
|
* 4 corals
|
||||||
|
* 5 seaweed
|
||||||
|
*/
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { svgDataUri, coralSvg, seaweedSvg, starfishSvg, shellSvg } from '../svgPatterns'
|
||||||
|
import { useAquaticState } from '../useAquaticState'
|
||||||
|
|
||||||
|
const { season, activeEventModifiers } = useAquaticState()
|
||||||
|
|
||||||
|
// ── Event reactivity ──
|
||||||
|
const bloomActive = computed(() => activeEventModifiers.value.has('seasonal-bloom'))
|
||||||
|
const currentActive = computed(() => activeEventModifiers.value.has('current-change'))
|
||||||
|
const bioActive = computed(() => activeEventModifiers.value.has('bioluminescent-flash'))
|
||||||
|
|
||||||
|
// ── Season-dependent coral palettes ──
|
||||||
|
const palette = computed(() => {
|
||||||
|
switch (season.value) {
|
||||||
|
case 'spring': return { branching: ['#f472b6', '#fbcfe8'], brain: ['#fbbf24', '#fef3c7'], fan: ['#a78bfa', '#ddd6fe'] }
|
||||||
|
case 'summer': return { branching: ['#f87171', '#fda4af'], brain: ['#f97316', '#fed7aa'], fan: ['#a855f7', '#d8b4fe'] }
|
||||||
|
case 'autumn': return { branching: ['#b45309', '#d97706'], brain: ['#92400e', '#b45309'], fan: ['#78716c', '#a8a29e'] }
|
||||||
|
case 'winter': return { branching: ['#7dd3fc', '#bae6fd'], brain: ['#94a3b8', '#cbd5e1'], fan: ['#a5b4fc', '#c7d2fe'] }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Corals (reactive to season) ──
|
||||||
|
const corals = computed(() => [
|
||||||
|
{ x: 12, bottom: 13, bg: svgDataUri(coralSvg('branching', palette.value.branching[0], palette.value.branching[1])), w: 32, h: 40, delay: '0s' },
|
||||||
|
{ x: 35, bottom: 14, bg: svgDataUri(coralSvg('brain', palette.value.brain[0], palette.value.brain[1])), w: 24, h: 20, delay: '-3s' },
|
||||||
|
{ x: 55, bottom: 12, bg: svgDataUri(coralSvg('fan', palette.value.fan[0], palette.value.fan[1])), w: 28, h: 36, delay: '-7s' },
|
||||||
|
{ x: 75, bottom: 13, bg: svgDataUri(coralSvg('branching', palette.value.branching[1], palette.value.branching[0])), w: 30, h: 38, delay: '-5s' },
|
||||||
|
{ x: 92, bottom: 14, bg: svgDataUri(coralSvg('brain', palette.value.brain[1], palette.value.brain[0])), w: 22, h: 18, delay: '-9s' },
|
||||||
|
])
|
||||||
|
|
||||||
|
// ── Seaweed (reactive to current-change event) ──
|
||||||
|
const stalks = [
|
||||||
|
{ x: 10, height: 52, color: '#16a34a', accent: '#22c55e', dur: 9, delay: '0s' },
|
||||||
|
{ x: 28, height: 60, color: '#22c55e', accent: '#4ade80', dur: 11, delay: '-2s' },
|
||||||
|
{ x: 42, height: 48, color: '#15803d', accent: '#16a34a', dur: 8, delay: '-5s' },
|
||||||
|
{ x: 62, height: 56, color: '#16a34a', accent: '#4ade80', dur: 12, delay: '-3s' },
|
||||||
|
{ x: 78, height: 44, color: '#22c55e', accent: '#16a34a', dur: 10, delay: '-7s' },
|
||||||
|
{ x: 90, height: 50, color: '#15803d', accent: '#22c55e', dur: 14, delay: '-1s' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const stalkStyles = computed(() =>
|
||||||
|
stalks.map(s => ({
|
||||||
|
left: s.x + '%',
|
||||||
|
bottom: '10%',
|
||||||
|
height: s.height + 'px',
|
||||||
|
width: '10px',
|
||||||
|
backgroundImage: svgDataUri(seaweedSvg(s.height, s.color, s.accent)),
|
||||||
|
'--sway-duration': s.dur + 's',
|
||||||
|
'--sway-amount': currentActive.value ? '6deg' : '3deg',
|
||||||
|
animationDelay: s.delay,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Decorations (randomized once on mount) ──
|
||||||
|
interface DecoItem {
|
||||||
|
x: number
|
||||||
|
bottom: number
|
||||||
|
bg: string
|
||||||
|
size: [number, number]
|
||||||
|
}
|
||||||
|
const decoItems = ref<DecoItem[]>([])
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const r = () => Math.random()
|
||||||
|
decoItems.value = [
|
||||||
|
{ x: 8 + r() * 15, bottom: 8 + r() * 5, bg: svgDataUri(starfishSvg('#f97316')), size: [16, 16] },
|
||||||
|
{ x: 60 + r() * 20, bottom: 9 + r() * 4, bg: svgDataUri(starfishSvg('#fb923c')), size: [14, 14] },
|
||||||
|
{ x: 25 + r() * 10, bottom: 10 + r() * 3, bg: svgDataUri(shellSvg('#fef3c7')), size: [12, 8] },
|
||||||
|
{ x: 45 + r() * 10, bottom: 8 + r() * 4, bg: svgDataUri(shellSvg('#fde68a')), size: [10, 7] },
|
||||||
|
{ x: 82 + r() * 10, bottom: 9 + r() * 3, bg: svgDataUri(shellSvg('#fcd34d')), size: [11, 7] },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ocean-scene" :class="{ bloom: bloomActive }">
|
||||||
|
|
||||||
|
<!-- Layer 0: Water depth gradient -->
|
||||||
|
<div class="water-gradient" />
|
||||||
|
|
||||||
|
<!-- Layer 1: Surface light rays -->
|
||||||
|
<div class="light-rays" :class="{ 'bio-tint': bioActive }" />
|
||||||
|
|
||||||
|
<!-- Layer 2: Sea floor (sand, rocks, pebbles) -->
|
||||||
|
<div class="sea-floor" />
|
||||||
|
|
||||||
|
<!-- Layer 3: Decorations (starfish, shells on the floor) -->
|
||||||
|
<div class="decorations">
|
||||||
|
<div
|
||||||
|
v-for="(d, i) in decoItems"
|
||||||
|
:key="i"
|
||||||
|
class="deco-item"
|
||||||
|
:style="{
|
||||||
|
left: d.x + '%',
|
||||||
|
bottom: d.bottom + '%',
|
||||||
|
width: d.size[0] + 'px',
|
||||||
|
height: d.size[1] + 'px',
|
||||||
|
backgroundImage: d.bg,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Layer 4: Coral reef -->
|
||||||
|
<div class="coral-reef">
|
||||||
|
<div
|
||||||
|
v-for="(c, i) in corals"
|
||||||
|
:key="i"
|
||||||
|
class="coral"
|
||||||
|
:style="{
|
||||||
|
left: c.x + '%',
|
||||||
|
bottom: c.bottom + '%',
|
||||||
|
width: c.w + 'px',
|
||||||
|
height: c.h + 'px',
|
||||||
|
backgroundImage: c.bg,
|
||||||
|
animationDelay: c.delay,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Layer 5: Seaweed field -->
|
||||||
|
<div class="seaweed-field">
|
||||||
|
<div
|
||||||
|
v-for="(style, i) in stalkStyles"
|
||||||
|
:key="i"
|
||||||
|
class="seaweed-stalk"
|
||||||
|
:style="style"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ============================================================================
|
||||||
|
OCEAN SCENE — Unified background world
|
||||||
|
All scenery layers live here, working together as one cohesive environment.
|
||||||
|
The parent .aquatic-bg provides .tod-* and .season-* classes.
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.ocean-scene {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocean-scene > div {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layer 0: Water depth gradient ──────────────────────────────────── */
|
||||||
|
|
||||||
|
.water-gradient {
|
||||||
|
z-index: 0;
|
||||||
|
transition: background 2s ease;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(0, 6, 22, 0.97) 0%,
|
||||||
|
rgba(0, 12, 35, 0.95) 10%,
|
||||||
|
rgba(0, 20, 52, 0.93) 22%,
|
||||||
|
rgba(0, 30, 65, 0.90) 35%,
|
||||||
|
rgba(2, 42, 75, 0.87) 48%,
|
||||||
|
rgba(4, 52, 78, 0.84) 60%,
|
||||||
|
rgba(6, 58, 72, 0.82) 72%,
|
||||||
|
rgba(10, 55, 58, 0.80) 82%,
|
||||||
|
rgba(18, 50, 45, 0.78) 90%,
|
||||||
|
rgba(28, 48, 35, 0.76) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.tod-twilight) .water-gradient {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(15, 5, 30, 0.96) 0%,
|
||||||
|
rgba(20, 10, 45, 0.94) 10%,
|
||||||
|
rgba(18, 15, 55, 0.92) 22%,
|
||||||
|
rgba(12, 25, 65, 0.89) 35%,
|
||||||
|
rgba(8, 38, 72, 0.86) 48%,
|
||||||
|
rgba(6, 48, 75, 0.83) 60%,
|
||||||
|
rgba(8, 52, 68, 0.80) 72%,
|
||||||
|
rgba(12, 50, 55, 0.78) 82%,
|
||||||
|
rgba(20, 48, 42, 0.76) 90%,
|
||||||
|
rgba(28, 45, 32, 0.74) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.tod-day) .water-gradient {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(2, 18, 40, 0.92) 0%,
|
||||||
|
rgba(4, 28, 55, 0.90) 10%,
|
||||||
|
rgba(6, 38, 68, 0.88) 22%,
|
||||||
|
rgba(8, 48, 78, 0.85) 35%,
|
||||||
|
rgba(10, 58, 85, 0.82) 48%,
|
||||||
|
rgba(12, 65, 82, 0.79) 60%,
|
||||||
|
rgba(14, 68, 78, 0.76) 72%,
|
||||||
|
rgba(16, 62, 65, 0.74) 82%,
|
||||||
|
rgba(22, 58, 52, 0.72) 90%,
|
||||||
|
rgba(30, 55, 40, 0.70) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Depth zone shifts */
|
||||||
|
:global(.depth-surface) .water-gradient {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.depth-deep) .water-gradient {
|
||||||
|
filter: brightness(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layer 1: Light rays ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.light-rays {
|
||||||
|
z-index: 1;
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
-20deg,
|
||||||
|
transparent 0px,
|
||||||
|
transparent 80px,
|
||||||
|
rgba(103, 232, 249, 0.02) 80px,
|
||||||
|
rgba(103, 232, 249, 0.025) 84px,
|
||||||
|
transparent 84px,
|
||||||
|
transparent 200px
|
||||||
|
),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
-35deg,
|
||||||
|
transparent 0px,
|
||||||
|
transparent 120px,
|
||||||
|
rgba(56, 189, 248, 0.015) 120px,
|
||||||
|
rgba(56, 189, 248, 0.02) 123px,
|
||||||
|
transparent 123px,
|
||||||
|
transparent 300px
|
||||||
|
);
|
||||||
|
animation: light-pulse 10s ease-in-out infinite alternate;
|
||||||
|
transition: opacity 2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.tod-night) .light-rays { opacity: 0.7; }
|
||||||
|
:global(.tod-day) .light-rays { opacity: 1; }
|
||||||
|
|
||||||
|
/* Bioluminescent event: green light tint */
|
||||||
|
.light-rays.bio-tint {
|
||||||
|
background:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
-20deg,
|
||||||
|
transparent 0px,
|
||||||
|
transparent 80px,
|
||||||
|
rgba(74, 222, 128, 0.03) 80px,
|
||||||
|
rgba(74, 222, 128, 0.04) 84px,
|
||||||
|
transparent 84px,
|
||||||
|
transparent 200px
|
||||||
|
),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
-35deg,
|
||||||
|
transparent 0px,
|
||||||
|
transparent 120px,
|
||||||
|
rgba(34, 197, 94, 0.02) 120px,
|
||||||
|
rgba(34, 197, 94, 0.03) 123px,
|
||||||
|
transparent 123px,
|
||||||
|
transparent 300px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes light-pulse {
|
||||||
|
0% { opacity: 0.6; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
100% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layer 2: Sea floor ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.sea-floor {
|
||||||
|
z-index: 2;
|
||||||
|
background:
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='480' height='160' viewBox='0 0 480 160' shape-rendering='crispEdges'%3E%3Crect x='0' y='120' width='480' height='40' fill='%23c2a060' opacity='0.55'/%3E%3Crect x='0' y='116' width='480' height='6' fill='%23d4b878' opacity='0.45'/%3E%3Crect x='0' y='114' width='480' height='4' fill='%23b89850' opacity='0.25'/%3E%3Crect x='15' y='124' width='30' height='2' fill='%23a89048' opacity='0.25'/%3E%3Crect x='80' y='128' width='20' height='2' fill='%23b8a060' opacity='0.2'/%3E%3Crect x='140' y='126' width='35' height='2' fill='%23a89048' opacity='0.25'/%3E%3Crect x='220' y='130' width='25' height='2' fill='%23b8a060' opacity='0.2'/%3E%3Crect x='290' y='124' width='30' height='2' fill='%23a89048' opacity='0.22'/%3E%3Crect x='360' y='128' width='22' height='2' fill='%23b8a060' opacity='0.2'/%3E%3Crect x='420' y='126' width='28' height='2' fill='%23a89048' opacity='0.22'/%3E%3Crect x='20' y='106' width='16' height='10' fill='%23475569' opacity='0.5'/%3E%3Crect x='22' y='104' width='12' height='4' fill='%2364748b' opacity='0.4'/%3E%3Crect x='24' y='102' width='6' height='4' fill='%23718096' opacity='0.3'/%3E%3Crect x='340' y='108' width='14' height='8' fill='%23475569' opacity='0.45'/%3E%3Crect x='342' y='106' width='10' height='4' fill='%2364748b' opacity='0.35'/%3E%3Crect x='344' y='104' width='4' height='4' fill='%23718096' opacity='0.25'/%3E%3Crect x='200' y='110' width='10' height='6' fill='%23475569' opacity='0.4'/%3E%3Crect x='202' y='108' width='6' height='4' fill='%2364748b' opacity='0.3'/%3E%3Crect x='40' y='118' width='2' height='2' fill='%2364748b' opacity='0.25'/%3E%3Crect x='75' y='120' width='2' height='2' fill='%2364748b' opacity='0.2'/%3E%3Crect x='110' y='118' width='2' height='2' fill='%23475569' opacity='0.25'/%3E%3Crect x='150' y='120' width='2' height='2' fill='%2364748b' opacity='0.2'/%3E%3Crect x='215' y='118' width='2' height='2' fill='%23475569' opacity='0.22'/%3E%3Crect x='265' y='120' width='2' height='2' fill='%2364748b' opacity='0.18'/%3E%3Crect x='310' y='118' width='2' height='2' fill='%23475569' opacity='0.22'/%3E%3Crect x='355' y='120' width='2' height='2' fill='%2364748b' opacity='0.18'/%3E%3Crect x='385' y='118' width='2' height='2' fill='%23475569' opacity='0.2'/%3E%3Crect x='450' y='120' width='2' height='2' fill='%2364748b' opacity='0.18'/%3E%3C/svg%3E") repeat-x bottom center / 480px 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layer 3: Decorations (starfish, shells) ────────────────────────── */
|
||||||
|
|
||||||
|
.decorations { z-index: 3; }
|
||||||
|
|
||||||
|
.deco-item {
|
||||||
|
position: absolute;
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layer 4: Coral reef ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.coral-reef {
|
||||||
|
z-index: 4;
|
||||||
|
transition: filter 3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Seasonal bloom: corals glow and brighten */
|
||||||
|
.ocean-scene.bloom .coral-reef {
|
||||||
|
filter: brightness(1.3) saturate(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bloom also intensifies light rays */
|
||||||
|
.ocean-scene.bloom .light-rays {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coral {
|
||||||
|
position: absolute;
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
animation: coral-sway 12s ease-in-out infinite alternate;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes coral-sway {
|
||||||
|
from { transform: rotate(-1deg); }
|
||||||
|
to { transform: rotate(1deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layer 5: Seaweed field ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.seaweed-field { z-index: 5; }
|
||||||
|
|
||||||
|
.seaweed-stalk {
|
||||||
|
position: absolute;
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: bottom center;
|
||||||
|
transform-origin: bottom center;
|
||||||
|
animation: seaweed-sway var(--sway-duration, 10s) ease-in-out infinite alternate;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Current-change event also speeds up coral sway */
|
||||||
|
:global(.evt-current-change) .coral {
|
||||||
|
animation-duration: 6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes seaweed-sway {
|
||||||
|
from { transform: skewX(calc(var(--sway-amount, 3deg) * -1)); }
|
||||||
|
to { transform: skewX(var(--sway-amount, 3deg)); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// The unified background scene (gradient + light + floor + corals + seaweed + decorations)
|
||||||
|
export { default as OceanScene } from './OceanScene.vue'
|
||||||
|
|
||||||
|
// Independent dynamic overlay layers
|
||||||
|
export { default as BubbleStream } from './BubbleStream.vue'
|
||||||
|
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'
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/** Encode an SVG string to a CSS-usable data URI */
|
||||||
|
export function svgDataUri(svg: string): string {
|
||||||
|
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a pixel art fish SVG */
|
||||||
|
export function fishSvg(opts: {
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
body: string
|
||||||
|
accent: string
|
||||||
|
eye?: string
|
||||||
|
facing?: 'left' | 'right'
|
||||||
|
}): string {
|
||||||
|
const { w, h, body, accent, facing = 'right' } = opts
|
||||||
|
const eye = opts.eye ?? '#000'
|
||||||
|
// Build a generic pixel fish: body rect, tail, eye
|
||||||
|
const hw = Math.floor(w / 2)
|
||||||
|
const hh = Math.floor(h / 2)
|
||||||
|
const tailW = Math.floor(w * 0.2)
|
||||||
|
const bodyW = w - tailW
|
||||||
|
const eyeX = facing === 'right' ? bodyW - 3 : tailW + 1
|
||||||
|
const tailX = facing === 'right' ? 0 : bodyW
|
||||||
|
|
||||||
|
return `<svg xmlns='http://www.w3.org/2000/svg' width='${w}' height='${h}' viewBox='0 0 ${w} ${h}' shape-rendering='crispEdges'>`
|
||||||
|
// Tail
|
||||||
|
+ `<rect x='${tailX}' y='1' width='${tailW}' height='${Math.floor(h * 0.3)}' fill='${accent}' opacity='0.6'/>`
|
||||||
|
+ `<rect x='${tailX}' y='${h - Math.floor(h * 0.3) - 1}' width='${tailW}' height='${Math.floor(h * 0.3)}' fill='${accent}' opacity='0.6'/>`
|
||||||
|
// Body
|
||||||
|
+ `<rect x='${facing === 'right' ? tailW : 0}' y='1' width='${bodyW}' height='${h - 2}' fill='${body}' opacity='0.55'/>`
|
||||||
|
// Stripe
|
||||||
|
+ `<rect x='${hw - 1}' y='1' width='2' height='${h - 2}' fill='${accent}' opacity='0.35'/>`
|
||||||
|
// Eye
|
||||||
|
+ `<rect x='${eyeX}' y='${hh - 1}' width='2' height='2' fill='${eye}' opacity='0.5'/>`
|
||||||
|
+ `<rect x='${eyeX + (facing === 'right' ? 1 : 0)}' y='${hh - 1}' width='1' height='1' fill='white' opacity='0.35'/>`
|
||||||
|
+ `</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a coral SVG of a given type */
|
||||||
|
export function coralSvg(type: 'branching' | 'brain' | 'fan', color: string, accent: string): string {
|
||||||
|
if (type === 'branching') {
|
||||||
|
return `<svg xmlns='http://www.w3.org/2000/svg' width='16' height='20' viewBox='0 0 16 20' shape-rendering='crispEdges'>`
|
||||||
|
+ `<rect x='7' y='8' width='2' height='12' fill='${color}' opacity='0.55'/>`
|
||||||
|
+ `<rect x='5' y='6' width='2' height='8' fill='${color}' opacity='0.5'/>`
|
||||||
|
+ `<rect x='9' y='5' width='2' height='9' fill='${color}' opacity='0.5'/>`
|
||||||
|
+ `<rect x='3' y='3' width='2' height='6' fill='${accent}' opacity='0.45'/>`
|
||||||
|
+ `<rect x='11' y='2' width='2' height='7' fill='${accent}' opacity='0.45'/>`
|
||||||
|
+ `<rect x='5' y='4' width='2' height='2' fill='${accent}' opacity='0.4'/>`
|
||||||
|
+ `<rect x='9' y='3' width='2' height='2' fill='${accent}' opacity='0.4'/>`
|
||||||
|
+ `<rect x='1' y='1' width='2' height='4' fill='${accent}' opacity='0.35'/>`
|
||||||
|
+ `<rect x='13' y='0' width='2' height='5' fill='${accent}' opacity='0.35'/>`
|
||||||
|
+ `</svg>`
|
||||||
|
}
|
||||||
|
if (type === 'brain') {
|
||||||
|
return `<svg xmlns='http://www.w3.org/2000/svg' width='12' height='10' viewBox='0 0 12 10' shape-rendering='crispEdges'>`
|
||||||
|
+ `<rect x='2' y='3' width='8' height='6' rx='0' fill='${color}' opacity='0.5'/>`
|
||||||
|
+ `<rect x='3' y='2' width='6' height='2' fill='${color}' opacity='0.45'/>`
|
||||||
|
+ `<rect x='4' y='1' width='4' height='2' fill='${accent}' opacity='0.4'/>`
|
||||||
|
+ `<rect x='3' y='4' width='2' height='2' fill='${accent}' opacity='0.3'/>`
|
||||||
|
+ `<rect x='7' y='5' width='2' height='2' fill='${accent}' opacity='0.3'/>`
|
||||||
|
+ `<rect x='5' y='7' width='2' height='2' fill='${accent}' opacity='0.25'/>`
|
||||||
|
+ `</svg>`
|
||||||
|
}
|
||||||
|
// fan
|
||||||
|
return `<svg xmlns='http://www.w3.org/2000/svg' width='14' height='18' viewBox='0 0 14 18' shape-rendering='crispEdges'>`
|
||||||
|
+ `<rect x='6' y='12' width='2' height='6' fill='${color}' opacity='0.5'/>`
|
||||||
|
+ `<rect x='4' y='8' width='6' height='5' fill='${color}' opacity='0.45'/>`
|
||||||
|
+ `<rect x='3' y='5' width='8' height='4' fill='${accent}' opacity='0.4'/>`
|
||||||
|
+ `<rect x='2' y='2' width='10' height='4' fill='${accent}' opacity='0.35'/>`
|
||||||
|
+ `<rect x='4' y='0' width='6' height='3' fill='${accent}' opacity='0.3'/>`
|
||||||
|
+ `<rect x='5' y='6' width='2' height='2' fill='${color}' opacity='0.25'/>`
|
||||||
|
+ `<rect x='8' y='4' width='2' height='2' fill='${color}' opacity='0.25'/>`
|
||||||
|
+ `</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a seaweed stalk SVG */
|
||||||
|
export function seaweedSvg(height: number, color: string, accent: string): string {
|
||||||
|
const segments: string[] = []
|
||||||
|
for (let y = 0; y < height; y += 4) {
|
||||||
|
const xOff = (y / 4) % 2 === 0 ? 0 : 1
|
||||||
|
const c = y % 8 === 0 ? color : accent
|
||||||
|
const op = 0.3 + (y / height) * 0.25
|
||||||
|
segments.push(`<rect x='${1 + xOff}' y='${y}' width='2' height='4' fill='${c}' opacity='${op.toFixed(2)}'/>`)
|
||||||
|
}
|
||||||
|
// Leaf accents every 12px
|
||||||
|
for (let y = 6; y < height - 4; y += 12) {
|
||||||
|
const side = (y / 12) % 2 === 0 ? 0 : 3
|
||||||
|
segments.push(`<rect x='${side}' y='${y}' width='2' height='3' fill='${accent}' opacity='0.3'/>`)
|
||||||
|
}
|
||||||
|
return `<svg xmlns='http://www.w3.org/2000/svg' width='5' height='${height}' viewBox='0 0 5 ${height}' shape-rendering='crispEdges'>`
|
||||||
|
+ segments.join('')
|
||||||
|
+ `</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a jellyfish SVG */
|
||||||
|
export function jellyfishSvg(bell: string, tentacle: string): string {
|
||||||
|
return `<svg xmlns='http://www.w3.org/2000/svg' width='16' height='22' viewBox='0 0 16 22' shape-rendering='crispEdges'>`
|
||||||
|
// Bell
|
||||||
|
+ `<rect x='4' y='0' width='8' height='2' fill='${bell}' opacity='0.4'/>`
|
||||||
|
+ `<rect x='2' y='2' width='12' height='2' fill='${bell}' opacity='0.45'/>`
|
||||||
|
+ `<rect x='1' y='4' width='14' height='4' fill='${bell}' opacity='0.5'/>`
|
||||||
|
+ `<rect x='2' y='8' width='12' height='2' fill='${bell}' opacity='0.45'/>`
|
||||||
|
// Inner glow
|
||||||
|
+ `<rect x='5' y='4' width='6' height='3' fill='white' opacity='0.15'/>`
|
||||||
|
// Tentacles
|
||||||
|
+ `<rect x='3' y='10' width='1' height='8' fill='${tentacle}' opacity='0.35'/>`
|
||||||
|
+ `<rect x='6' y='10' width='1' height='10' fill='${tentacle}' opacity='0.3'/>`
|
||||||
|
+ `<rect x='9' y='10' width='1' height='9' fill='${tentacle}' opacity='0.32'/>`
|
||||||
|
+ `<rect x='12' y='10' width='1' height='7' fill='${tentacle}' opacity='0.28'/>`
|
||||||
|
// Tentacle wiggles
|
||||||
|
+ `<rect x='2' y='14' width='1' height='2' fill='${tentacle}' opacity='0.25'/>`
|
||||||
|
+ `<rect x='7' y='16' width='1' height='2' fill='${tentacle}' opacity='0.22'/>`
|
||||||
|
+ `<rect x='10' y='15' width='1' height='2' fill='${tentacle}' opacity='0.22'/>`
|
||||||
|
+ `</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a starfish SVG */
|
||||||
|
export function starfishSvg(color: string): string {
|
||||||
|
return `<svg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8' shape-rendering='crispEdges'>`
|
||||||
|
+ `<rect x='3' y='0' width='2' height='3' fill='${color}' opacity='0.45'/>`
|
||||||
|
+ `<rect x='0' y='3' width='3' height='2' fill='${color}' opacity='0.45'/>`
|
||||||
|
+ `<rect x='5' y='3' width='3' height='2' fill='${color}' opacity='0.45'/>`
|
||||||
|
+ `<rect x='2' y='2' width='4' height='4' fill='${color}' opacity='0.5'/>`
|
||||||
|
+ `<rect x='1' y='5' width='2' height='2' fill='${color}' opacity='0.4'/>`
|
||||||
|
+ `<rect x='5' y='5' width='2' height='2' fill='${color}' opacity='0.4'/>`
|
||||||
|
+ `<rect x='3' y='3' width='2' height='2' fill='white' opacity='0.15'/>`
|
||||||
|
+ `</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a small shell SVG */
|
||||||
|
export function shellSvg(color: string): string {
|
||||||
|
return `<svg xmlns='http://www.w3.org/2000/svg' width='6' height='4' viewBox='0 0 6 4' shape-rendering='crispEdges'>`
|
||||||
|
+ `<rect x='1' y='2' width='4' height='2' fill='${color}' opacity='0.4'/>`
|
||||||
|
+ `<rect x='2' y='1' width='2' height='1' fill='${color}' opacity='0.35'/>`
|
||||||
|
+ `<rect x='0' y='3' width='1' height='1' fill='${color}' opacity='0.3'/>`
|
||||||
|
+ `<rect x='5' y='3' width='1' height='1' fill='${color}' opacity='0.3'/>`
|
||||||
|
+ `<rect x='2' y='2' width='1' height='1' fill='white' opacity='0.15'/>`
|
||||||
|
+ `</svg>`
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export type EventFrequency = 'minutes' | 'hours' | 'days' | 'months'
|
||||||
|
export type TimeOfDay = 'day' | 'twilight' | 'night'
|
||||||
|
export type Season = 'spring' | 'summer' | 'autumn' | 'winter'
|
||||||
|
export type DepthZone = 'surface' | 'midwater' | 'deep'
|
||||||
|
|
||||||
|
export interface AquaticEvent {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
frequency: EventFrequency
|
||||||
|
/** Min interval in milliseconds */
|
||||||
|
minInterval: number
|
||||||
|
/** Max interval in milliseconds */
|
||||||
|
maxInterval: number
|
||||||
|
/** Duration of the event in milliseconds */
|
||||||
|
duration: number
|
||||||
|
/** CSS class applied to EventOverlay when active */
|
||||||
|
cssClass: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveEvent {
|
||||||
|
event: AquaticEvent
|
||||||
|
startedAt: number
|
||||||
|
endsAt: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { ref, readonly } from 'vue'
|
||||||
|
import { useAquaticState } from './useAquaticState'
|
||||||
|
import type { AquaticEvent, ActiveEvent, EventFrequency, TimeOfDay, Season, DepthZone } from './types'
|
||||||
|
|
||||||
|
// ── Event catalog ──
|
||||||
|
|
||||||
|
const MINUTE_EVENTS: AquaticEvent[] = [
|
||||||
|
{ id: 'school-of-fish', name: 'School of Fish', frequency: 'minutes', minInterval: 120_000, maxInterval: 480_000, duration: 15_000, cssClass: 'evt-school-of-fish' },
|
||||||
|
{ id: 'bubble-burst', name: 'Bubble Burst', frequency: 'minutes', minInterval: 120_000, maxInterval: 480_000, duration: 8_000, cssClass: 'evt-bubble-burst' },
|
||||||
|
{ id: 'bioluminescent-flash', name: 'Bioluminescent Flash', frequency: 'minutes', minInterval: 180_000, maxInterval: 480_000, duration: 10_000, cssClass: 'evt-bioluminescent-flash' },
|
||||||
|
{ id: 'plankton-drift', name: 'Plankton Drift', frequency: 'minutes', minInterval: 150_000, maxInterval: 420_000, duration: 12_000, cssClass: 'evt-plankton-drift' },
|
||||||
|
{ id: 'fish-chase', name: 'Fish Chase', frequency: 'minutes', minInterval: 120_000, maxInterval: 360_000, duration: 10_000, cssClass: 'evt-fish-chase' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const HOUR_EVENTS: AquaticEvent[] = [
|
||||||
|
{ id: 'whale-shadow', name: 'Whale Shadow', frequency: 'hours', minInterval: 3_600_000, maxInterval: 14_400_000, duration: 20_000, cssClass: 'evt-whale-shadow' },
|
||||||
|
{ id: 'current-change', name: 'Current Change', frequency: 'hours', minInterval: 3_600_000, maxInterval: 10_800_000, duration: 60_000, cssClass: 'evt-current-change' },
|
||||||
|
{ id: 'color-shift', name: 'Color Shift', frequency: 'hours', minInterval: 3_600_000, maxInterval: 14_400_000, duration: 30_000, cssClass: 'evt-color-shift' },
|
||||||
|
{ id: 'turtle-crossing', name: 'Turtle Crossing', frequency: 'hours', minInterval: 7_200_000, maxInterval: 14_400_000, duration: 25_000, cssClass: 'evt-turtle-crossing' },
|
||||||
|
{ id: 'manta-ray', name: 'Manta Ray', frequency: 'hours', minInterval: 5_400_000, maxInterval: 14_400_000, duration: 18_000, cssClass: 'evt-manta-ray' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DAY_EVENTS: AquaticEvent[] = [
|
||||||
|
{ id: 'day-night-shift', name: 'Day/Night Shift', frequency: 'days', minInterval: 86_400_000, maxInterval: 259_200_000, duration: 120_000, cssClass: 'evt-day-night-shift' },
|
||||||
|
{ id: 'seasonal-bloom', name: 'Seasonal Bloom', frequency: 'days', minInterval: 86_400_000, maxInterval: 259_200_000, duration: 180_000, cssClass: 'evt-seasonal-bloom' },
|
||||||
|
{ id: 'depth-change', name: 'Depth Change', frequency: 'days', minInterval: 86_400_000, maxInterval: 172_800_000, duration: 90_000, cssClass: 'evt-depth-change' },
|
||||||
|
{ id: 'kelp-forest', name: 'Kelp Forest', frequency: 'days', minInterval: 129_600_000, maxInterval: 259_200_000, duration: 150_000, cssClass: 'evt-kelp-forest' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const MONTH_EVENTS: AquaticEvent[] = [
|
||||||
|
{ id: 'aurora-underwater', name: 'Aurora Underwater', frequency: 'months', minInterval: 2_592_000_000, maxInterval: 10_368_000_000, duration: 300_000, cssClass: 'evt-aurora-underwater' },
|
||||||
|
{ id: 'mythical-creature', name: 'Mythical Creature', frequency: 'months', minInterval: 2_592_000_000, maxInterval: 10_368_000_000, duration: 30_000, cssClass: 'evt-mythical-creature' },
|
||||||
|
{ id: 'volcanic-vent', name: 'Volcanic Vent', frequency: 'months', minInterval: 2_592_000_000, maxInterval: 7_776_000_000, duration: 120_000, cssClass: 'evt-volcanic-vent' },
|
||||||
|
{ id: 'crystal-formation', name: 'Crystal Formation', frequency: 'months', minInterval: 2_592_000_000, maxInterval: 10_368_000_000, duration: 240_000, cssClass: 'evt-crystal-formation' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'aquatic-event-timestamps'
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
function pickRandom<T>(arr: T[]): T {
|
||||||
|
return arr[Math.floor(Math.random() * arr.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomBetween(min: number, max: number): number {
|
||||||
|
return min + Math.random() * (max - min)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTimestamps(): Record<string, number> {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTimestamp(frequency: EventFrequency) {
|
||||||
|
const ts = loadTimestamps()
|
||||||
|
ts[frequency] = Date.now()
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(ts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Composable ──
|
||||||
|
|
||||||
|
export function useAquaticEvents() {
|
||||||
|
const { setEventModifier, clearEventModifier, setTimeOfDay, setSeason, setDepthZone } = useAquaticState()
|
||||||
|
const activeEvents = ref<ActiveEvent[]>([])
|
||||||
|
const timerIds: number[] = []
|
||||||
|
|
||||||
|
const TIERS: { frequency: EventFrequency; events: AquaticEvent[] }[] = [
|
||||||
|
{ frequency: 'minutes', events: MINUTE_EVENTS },
|
||||||
|
{ frequency: 'hours', events: HOUR_EVENTS },
|
||||||
|
{ frequency: 'days', events: DAY_EVENTS },
|
||||||
|
{ frequency: 'months', events: MONTH_EVENTS },
|
||||||
|
]
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
const timestamps = loadTimestamps()
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
for (const tier of TIERS) {
|
||||||
|
const lastFired = timestamps[tier.frequency] || 0
|
||||||
|
const elapsed = now - lastFired
|
||||||
|
// Pick a representative event to check interval
|
||||||
|
const sample = tier.events[0]
|
||||||
|
const minWait = sample.minInterval
|
||||||
|
|
||||||
|
if (elapsed >= minWait) {
|
||||||
|
// Enough time passed — trigger one soon (5-30s from now)
|
||||||
|
const initialDelay = randomBetween(5_000, 30_000)
|
||||||
|
const id = window.setTimeout(() => {
|
||||||
|
triggerRandomFromTier(tier.frequency, tier.events)
|
||||||
|
scheduleTier(tier.frequency, tier.events)
|
||||||
|
}, initialDelay)
|
||||||
|
timerIds.push(id)
|
||||||
|
} else {
|
||||||
|
// Wait for remaining time, then start normal cycle
|
||||||
|
const remaining = minWait - elapsed
|
||||||
|
const id = window.setTimeout(() => {
|
||||||
|
scheduleTier(tier.frequency, tier.events)
|
||||||
|
}, remaining)
|
||||||
|
timerIds.push(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
timerIds.forEach(id => clearTimeout(id))
|
||||||
|
timerIds.length = 0
|
||||||
|
// Clean up active events
|
||||||
|
for (const ae of activeEvents.value) {
|
||||||
|
clearEventModifier(ae.event.id)
|
||||||
|
}
|
||||||
|
activeEvents.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleTier(frequency: EventFrequency, events: AquaticEvent[]) {
|
||||||
|
const event = pickRandom(events)
|
||||||
|
const delay = randomBetween(event.minInterval, event.maxInterval)
|
||||||
|
const id = window.setTimeout(() => {
|
||||||
|
triggerRandomFromTier(frequency, events)
|
||||||
|
scheduleTier(frequency, events)
|
||||||
|
}, delay)
|
||||||
|
timerIds.push(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerRandomFromTier(frequency: EventFrequency, events: AquaticEvent[]) {
|
||||||
|
// Filter out already-active events
|
||||||
|
const available = events.filter(e =>
|
||||||
|
!activeEvents.value.some(ae => ae.event.id === e.id)
|
||||||
|
)
|
||||||
|
if (available.length === 0) return
|
||||||
|
|
||||||
|
const event = pickRandom(available)
|
||||||
|
triggerEvent(event, frequency)
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerEvent(event: AquaticEvent, frequency: EventFrequency) {
|
||||||
|
const now = Date.now()
|
||||||
|
const active: ActiveEvent = {
|
||||||
|
event,
|
||||||
|
startedAt: now,
|
||||||
|
endsAt: now + event.duration,
|
||||||
|
}
|
||||||
|
activeEvents.value = [...activeEvents.value, active]
|
||||||
|
setEventModifier(event.id, event.cssClass)
|
||||||
|
saveTimestamp(frequency)
|
||||||
|
|
||||||
|
// Special state mutations for certain events
|
||||||
|
if (event.id === 'day-night-shift') {
|
||||||
|
const options: TimeOfDay[] = ['day', 'twilight', 'night']
|
||||||
|
setTimeOfDay(pickRandom(options))
|
||||||
|
}
|
||||||
|
if (event.id === 'seasonal-bloom') {
|
||||||
|
const options: Season[] = ['spring', 'summer', 'autumn', 'winter']
|
||||||
|
setSeason(pickRandom(options))
|
||||||
|
}
|
||||||
|
if (event.id === 'depth-change') {
|
||||||
|
const options: DepthZone[] = ['surface', 'midwater', 'deep']
|
||||||
|
setDepthZone(pickRandom(options))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule end
|
||||||
|
const endId = window.setTimeout(() => {
|
||||||
|
activeEvents.value = activeEvents.value.filter(ae => ae !== active)
|
||||||
|
clearEventModifier(event.id)
|
||||||
|
}, event.duration)
|
||||||
|
timerIds.push(endId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeEvents: readonly(activeEvents),
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { ref, readonly } from 'vue'
|
||||||
|
import type { TimeOfDay, Season, DepthZone } from './types'
|
||||||
|
|
||||||
|
// ── Module-level singleton refs ──
|
||||||
|
const depthZone = ref<DepthZone>('midwater')
|
||||||
|
const timeOfDay = ref<TimeOfDay>('night')
|
||||||
|
const season = ref<Season>(getCurrentSeason())
|
||||||
|
const activeEventModifiers = ref<Map<string, string>>(new Map())
|
||||||
|
|
||||||
|
function getCurrentSeason(): Season {
|
||||||
|
const month = new Date().getMonth()
|
||||||
|
if (month >= 2 && month <= 4) return 'spring'
|
||||||
|
if (month >= 5 && month <= 7) return 'summer'
|
||||||
|
if (month >= 8 && month <= 10) return 'autumn'
|
||||||
|
return 'winter'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAquaticState() {
|
||||||
|
function setDepthZone(zone: DepthZone) {
|
||||||
|
depthZone.value = zone
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTimeOfDay(tod: TimeOfDay) {
|
||||||
|
timeOfDay.value = tod
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSeason(s: Season) {
|
||||||
|
season.value = s
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEventModifier(eventId: string, cssClass: string) {
|
||||||
|
const next = new Map(activeEventModifiers.value)
|
||||||
|
next.set(eventId, cssClass)
|
||||||
|
activeEventModifiers.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearEventModifier(eventId: string) {
|
||||||
|
const next = new Map(activeEventModifiers.value)
|
||||||
|
next.delete(eventId)
|
||||||
|
activeEventModifiers.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
depthZone: readonly(depthZone),
|
||||||
|
timeOfDay: readonly(timeOfDay),
|
||||||
|
season: readonly(season),
|
||||||
|
activeEventModifiers: readonly(activeEventModifiers),
|
||||||
|
setDepthZone,
|
||||||
|
setTimeOfDay,
|
||||||
|
setSeason,
|
||||||
|
setEventModifier,
|
||||||
|
clearEventModifier,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,4 +11,5 @@ export { default as SystemMessage } from './SystemMessage.vue'
|
|||||||
export { default as UserInput } from './UserInput.vue'
|
export { default as UserInput } from './UserInput.vue'
|
||||||
export { default as PermissionApproval } from './PermissionApproval.vue'
|
export { default as PermissionApproval } from './PermissionApproval.vue'
|
||||||
export { default as PlanApproval } from './PlanApproval.vue'
|
export { default as PlanApproval } from './PlanApproval.vue'
|
||||||
export { default as BackgroundPixelArt } from './BackgroundPixelArt.vue'
|
export { default as CodeBlock } from './CodeBlock.vue'
|
||||||
|
export { AquaticBackground } from './aquaticBackground'
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { ParsedToolCall } from '@/types/transcript-debug'
|
import type { ParsedToolCall } from '@/types/transcript-debug'
|
||||||
import { highlightCode } from '@/utils/markdown'
|
|
||||||
import ToolResultBlock from '../ToolResultBlock.vue'
|
import ToolResultBlock from '../ToolResultBlock.vue'
|
||||||
|
import CodeBlock from '../CodeBlock.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
call: ParsedToolCall
|
call: ParsedToolCall
|
||||||
@@ -29,9 +29,6 @@ const isError = computed(() => props.call.result?.isError ?? false)
|
|||||||
|
|
||||||
const expanded = ref(false)
|
const expanded = ref(false)
|
||||||
|
|
||||||
const highlightedOld = computed(() => highlightCode(oldString.value, ext.value || undefined))
|
|
||||||
const highlightedNew = computed(() => highlightCode(newString.value, ext.value || undefined))
|
|
||||||
|
|
||||||
const oldLineCount = computed(() => oldString.value.split('\n').length)
|
const oldLineCount = computed(() => oldString.value.split('\n').length)
|
||||||
const newLineCount = computed(() => newString.value.split('\n').length)
|
const newLineCount = computed(() => newString.value.split('\n').length)
|
||||||
</script>
|
</script>
|
||||||
@@ -59,11 +56,11 @@ const newLineCount = computed(() => newString.value.split('\n').length)
|
|||||||
<div class="diff-content">
|
<div class="diff-content">
|
||||||
<div class="diff-block removed">
|
<div class="diff-block removed">
|
||||||
<div class="diff-label"><span class="diff-sign">-</span> old</div>
|
<div class="diff-label"><span class="diff-sign">-</span> old</div>
|
||||||
<pre class="diff-code" v-html="highlightedOld"></pre>
|
<CodeBlock :code="oldString" :lang="ext" max-height="180px" />
|
||||||
</div>
|
</div>
|
||||||
<div class="diff-block added">
|
<div class="diff-block added">
|
||||||
<div class="diff-label"><span class="diff-sign">+</span> new</div>
|
<div class="diff-label"><span class="diff-sign">+</span> new</div>
|
||||||
<pre class="diff-code" v-html="highlightedNew"></pre>
|
<CodeBlock :code="newString" :lang="ext" max-height="180px" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -159,17 +156,4 @@ const newLineCount = computed(() => newString.value.split('\n').length)
|
|||||||
|
|
||||||
.diff-sign { font-size: 12px; font-weight: 700; font-family: 'SF Mono', 'Fira Code', monospace; }
|
.diff-sign { font-size: 12px; font-weight: 700; font-family: 'SF Mono', 'Fira Code', monospace; }
|
||||||
|
|
||||||
.diff-code {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.35rem 0.6rem;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.45;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
max-height: 180px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { ParsedToolCall } from '@/types/transcript-debug'
|
import type { ParsedToolCall } from '@/types/transcript-debug'
|
||||||
import ToolResultBlock from '../ToolResultBlock.vue'
|
import ToolResultBlock from '../ToolResultBlock.vue'
|
||||||
|
import CodeBlock from '../CodeBlock.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
call: ParsedToolCall
|
call: ParsedToolCall
|
||||||
@@ -189,10 +190,10 @@ const hasBody = computed(() =>
|
|||||||
<!-- EXPANDED: all details -->
|
<!-- EXPANDED: all details -->
|
||||||
<template v-if="expanded">
|
<template v-if="expanded">
|
||||||
<div v-if="description" class="expand-section">
|
<div v-if="description" class="expand-section">
|
||||||
<pre class="expand-content">{{ description }}</pre>
|
<CodeBlock :code="description" max-height="200px" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="prompt" class="expand-section">
|
<div v-if="prompt" class="expand-section">
|
||||||
<pre class="expand-content">{{ prompt }}</pre>
|
<CodeBlock :code="prompt" max-height="200px" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="call.result" class="expand-section result-section">
|
<div v-if="call.result" class="expand-section result-section">
|
||||||
<ToolResultBlock :result="call.result" />
|
<ToolResultBlock :result="call.result" />
|
||||||
@@ -324,13 +325,6 @@ const hasBody = computed(() =>
|
|||||||
/* Expandable sections */
|
/* Expandable sections */
|
||||||
.expand-section { border-top: 1px solid rgba(255, 255, 255, 0.04); }
|
.expand-section { border-top: 1px solid rgba(255, 255, 255, 0.04); }
|
||||||
|
|
||||||
.expand-content {
|
|
||||||
margin: 0; padding: 0.35rem 0.6rem;
|
|
||||||
font-size: 11px; line-height: 1.45; color: var(--text-secondary);
|
|
||||||
white-space: pre-wrap; word-break: break-word;
|
|
||||||
max-height: 200px; overflow-y: auto;
|
|
||||||
background: transparent; font-family: 'SF Mono', 'Fira Code', monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-section :deep(.tool-result) { border: none; border-radius: 0; margin-top: 0; }
|
.result-section :deep(.tool-result) { border: none; border-radius: 0; margin-top: 0; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { ParsedToolCall } from '@/types/transcript-debug'
|
import type { ParsedToolCall } from '@/types/transcript-debug'
|
||||||
import { highlightCode } from '@/utils/markdown'
|
|
||||||
import ToolResultBlock from '../ToolResultBlock.vue'
|
import ToolResultBlock from '../ToolResultBlock.vue'
|
||||||
|
import CodeBlock from '../CodeBlock.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
call: ParsedToolCall
|
call: ParsedToolCall
|
||||||
@@ -27,8 +27,6 @@ const isError = computed(() => props.call.result?.isError ?? false)
|
|||||||
|
|
||||||
const expanded = ref(false)
|
const expanded = ref(false)
|
||||||
|
|
||||||
const highlightedContent = computed(() => highlightCode(content.value, ext.value || undefined))
|
|
||||||
|
|
||||||
const lineCount = computed(() => content.value.split('\n').length)
|
const lineCount = computed(() => content.value.split('\n').length)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -51,9 +49,7 @@ const lineCount = computed(() => content.value.split('\n').length)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="expanded">
|
<template v-if="expanded">
|
||||||
<div class="content-section">
|
<CodeBlock :code="content" :lang="ext" max-height="250px" />
|
||||||
<pre class="content-pre" v-html="highlightedContent"></pre>
|
|
||||||
</div>
|
|
||||||
<ToolResultBlock v-if="call.result" :result="call.result" />
|
<ToolResultBlock v-if="call.result" :result="call.result" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,21 +136,4 @@ const lineCount = computed(() => content.value.split('\n').length)
|
|||||||
.toggle-btn:hover { opacity: 0.8; }
|
.toggle-btn:hover { opacity: 0.8; }
|
||||||
.toggle-btn.active { opacity: 1; color: rgba(34, 197, 94, 0.8); }
|
.toggle-btn.active { opacity: 1; color: rgba(34, 197, 94, 0.8); }
|
||||||
|
|
||||||
/* Content */
|
|
||||||
.content-section {
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-pre {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.35rem 0.6rem;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.45;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
max-height: 250px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -233,11 +233,14 @@ export function parseMarkdown(md: string): string {
|
|||||||
// Extract fenced code blocks first (protect from inline parsing)
|
// Extract fenced code blocks first (protect from inline parsing)
|
||||||
const codeBlocks: string[] = []
|
const codeBlocks: string[] = []
|
||||||
let text = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang: string, code: string) => {
|
let text = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang: string, code: string) => {
|
||||||
const highlighted = highlightCode(code.replace(/\n$/, ''), lang || undefined)
|
const rawCode = code.replace(/\n$/, '')
|
||||||
|
const highlighted = highlightCode(rawCode, lang || undefined)
|
||||||
const langLabel = lang ? `<span class="code-lang">${esc(lang)}</span>` : ''
|
const langLabel = lang ? `<span class="code-lang">${esc(lang)}</span>` : ''
|
||||||
|
const encoded = rawCode.replace(/&/g, '&').replace(/"/g, '"')
|
||||||
|
const copyBtn = `<button class="md-copy-btn" data-code="${encoded}" onclick="(function(b){var c=b.getAttribute('data-code').replace(/&/g,'&').replace(/"/g,'\"');navigator.clipboard.writeText(c).then(function(){b.textContent='Copied!';setTimeout(function(){b.textContent='Copy'},1500)})})(this)">Copy</button>`
|
||||||
const idx = codeBlocks.length
|
const idx = codeBlocks.length
|
||||||
codeBlocks.push(
|
codeBlocks.push(
|
||||||
`<div class="md-code-block">${langLabel}<pre class="md-pre"><code>${highlighted}</code></pre></div>`
|
`<div class="md-code-block">${langLabel}${copyBtn}<pre class="md-pre"><code>${highlighted}</code></pre></div>`
|
||||||
)
|
)
|
||||||
return `\x00CODE${idx}\x00`
|
return `\x00CODE${idx}\x00`
|
||||||
})
|
})
|
||||||
@@ -413,14 +416,46 @@ export const MARKDOWN_STYLES = `
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-content .md-copy-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 0.2em 0.5em;
|
||||||
|
color: var(--text-muted, #94a3b8);
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border: none;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content .md-code-block:hover .md-copy-btn { opacity: 1; }
|
||||||
|
.md-content .md-copy-btn:hover { color: var(--text-primary, #e2e8f0); background: rgba(255,255,255,0.1); }
|
||||||
|
|
||||||
|
/* Hide lang label when copy button is visible */
|
||||||
|
.md-content .md-code-block:hover .code-lang { opacity: 0; }
|
||||||
|
|
||||||
.md-content .md-pre {
|
.md-content .md-pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.6em 0.75em;
|
padding: 0.6em 0.75em;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.55;
|
line-height: 1.2;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
font-family: 'Consolas', 'Lucida Console', 'SF Mono', 'Fira Code', monospace;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
letter-spacing: 0;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-content .md-pre code {
|
||||||
|
white-space: pre;
|
||||||
|
word-spacing: 0;
|
||||||
|
tab-size: 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-content .md-blockquote {
|
.md-content .md-blockquote {
|
||||||
|
|||||||
Reference in New Issue
Block a user