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:
2026-02-19 17:15:36 -06:00
parent 3adfd189e1
commit eb69c0b2cf
21 changed files with 1707 additions and 257 deletions

View File

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

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { default as AquaticBackground } from './AquaticBackground.vue'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, '&amp;').replace(/"/g, '&quot;')
const copyBtn = `<button class="md-copy-btn" data-code="${encoded}" onclick="(function(b){var c=b.getAttribute('data-code').replace(/&amp;/g,'&').replace(/&quot;/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 {