archy/neode-ui/src/App.vue
Dorian a44cbe478a fix(intro): audible entrance oomph, splash off deep routes, finale on intro shortcuts — with e2e cinematic suite
Three first-visit-experience bugs, all caught by the new e2e suite
(e2e/intro-experience.spec.ts — 5 tests against the demo stack on real
Chrome, instrumenting HTMLMediaElement/WebAudio/MutationObserver since
headless can't hear or see the cinematic):

- playDashboardLoadOomph was always silent: the login→dashboard route
  change closes the AudioContext (stopAllAudio), and the oomph only
  fetched the dead context. It now recreates one, riding the login
  click's sticky user activation.
- A direct /login load in the demo hijacked into the splash: App.vue
  read route.path before the router resolved the initial navigation, so
  every deep-route boot looked like a root boot. The splash decision now
  reads window.location.pathname.
- Replayed intros ran mute (enableCinematicSounds override) and the
  intro's shortcut exits skipped the dashboard finale (flag now armed on
  both demo and login exits).

Verified: 5/5 e2e, 673/673 unit, vue-tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:13:39 +01:00

573 lines
23 KiB
Vue

<template>
<div id="app">
<!-- Splash Screen (only on first visit) -->
<SplashScreen v-if="showSplash" @complete="handleSplashComplete" />
<!-- Main App Content - only show after splash and routing is complete -->
<div v-if="!showSplash && !isReady" class="min-h-screen bg-black" />
<RouterView v-else-if="!showSplash && isReady" />
<!-- Spotlight command palette (Cmd+K / Ctrl+K) -->
<SpotlightSearch />
<!-- CLI popup (F key) -->
<CLIPopup />
<!-- App launcher overlay (iframe popup) -->
<AppLauncherOverlay />
<!-- Global toast notifications -->
<ToastStack />
<!-- Screensaver -->
<Screensaver />
<!-- Help guide modal (from spotlight) -->
<HelpGuideModal
:show="spotlightStore.helpModal.show"
:title="spotlightStore.helpModal.title"
:content="spotlightStore.helpModal.content"
:related-path="spotlightStore.helpModal.relatedPath"
@close="spotlightStore.closeHelpModal()"
/>
<!-- PWA Update Prompt -->
<PWAUpdatePrompt />
<!-- PWA Install Prompt (Install app, not just Add to Home Screen) -->
<PWAInstallPrompt />
<!-- Global "mesh device detected" setup flow (fires on any page) -->
<MeshDeviceSetupModal />
<!-- Nudge to back up the Lightning seed once a wallet exists (any page) -->
<LndSeedBackupPrompt />
<!-- Global persistent audio player (bottom bar) -->
<GlobalAudioPlayer />
<!-- Toast notifications - top right, glass style, any page -->
<Teleport to="body">
<Transition name="toast">
<div
v-if="toastMessage.show"
@click="messageToast.dismissToastAndOpenMessages"
class="fixed top-20 right-4 left-4 z-[100] w-auto max-w-md cursor-pointer rounded-xl p-4 transition-all hover:border-white/30 hover:shadow-2xl md:top-6 md:right-6 md:left-auto md:max-w-md toast-glass"
>
<div class="flex items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-orange-500/20">
<svg class="h-5 w-5 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white">New message</p>
<p class="mt-0.5 text-sm text-white/70 line-clamp-2">{{ toastMessage.text }}</p>
<p class="mt-1 text-xs text-orange-400">Click to view</p>
</div>
<button
@click.stop="messageToast.closeToast"
aria-label="Dismiss notification"
class="-mt-1 -mr-1 shrink-0 rounded-full p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white/80"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import SplashScreen from './components/SplashScreen.vue'
import PWAUpdatePrompt from './components/PWAUpdatePrompt.vue'
import PWAInstallPrompt from './components/PWAInstallPrompt.vue'
import SpotlightSearch from './components/SpotlightSearch.vue'
import CLIPopup from './components/CLIPopup.vue'
import AppLauncherOverlay from './components/AppLauncherOverlay.vue'
import ToastStack from './components/ToastStack.vue'
import Screensaver from './components/Screensaver.vue'
import HelpGuideModal from './components/HelpGuideModal.vue'
import GlobalAudioPlayer from './components/GlobalAudioPlayer.vue'
import MeshDeviceSetupModal from './components/mesh/MeshDeviceSetupModal.vue'
import LndSeedBackupPrompt from './components/LndSeedBackupPrompt.vue'
import { useMeshStore } from './stores/mesh'
import { useControllerNav } from '@/composables/useControllerNav'
import { playKeyboardTypingSound } from '@/composables/useLoginSounds'
import { useSpotlightStore } from '@/stores/spotlight'
import { useCLIStore } from '@/stores/cli'
import { useMessageToast } from '@/composables/useMessageToast'
import { useAppStore } from '@/stores/app'
import { useScreensaverStore } from '@/stores/screensaver'
import { useUIModeStore } from '@/stores/uiMode'
import { startRemoteRelay, stopRemoteRelay } from '@/api/remote-relay'
import { shouldShowIntroSplash } from '@/utils/introSplash'
const router = useRouter()
const screensaverStore = useScreensaverStore()
const spotlightStore = useSpotlightStore()
const cliStore = useCLIStore()
const appStore = useAppStore()
const uiModeStore = useUIModeStore()
const messageToast = useMessageToast()
const toastMessage = messageToast.toastMessage
useControllerNav()
function syncKioskSafeArea() {
if (typeof document === 'undefined') return
const isKiosk = localStorage.getItem('kiosk') === 'true'
|| new URLSearchParams(window.location.search).has('kiosk')
const rawSafeArea = localStorage.getItem('archipelago_kiosk_safe_area_px') || '0'
const safeArea = /^\d{1,3}$/.test(rawSafeArea) ? Number(rawSafeArea) : 0
const rawSafeAreaX = localStorage.getItem('archipelago_kiosk_safe_area_x_px') || rawSafeArea
const rawSafeAreaY = localStorage.getItem('archipelago_kiosk_safe_area_y_px') || rawSafeArea
const safeAreaX = /^\d{1,3}$/.test(rawSafeAreaX) ? Number(rawSafeAreaX) : safeArea
const safeAreaY = /^\d{1,3}$/.test(rawSafeAreaY) ? Number(rawSafeAreaY) : safeArea
document.documentElement.classList.toggle('kiosk-safe-area', isKiosk && (safeAreaX > 0 || safeAreaY > 0))
if (isKiosk && (safeAreaX > 0 || safeAreaY > 0)) {
document.documentElement.style.setProperty('--kiosk-safe-area-x', `${safeAreaX}px`)
document.documentElement.style.setProperty('--kiosk-safe-area-y', `${safeAreaY}px`)
} else {
document.documentElement.style.removeProperty('--kiosk-safe-area-x')
document.documentElement.style.removeProperty('--kiosk-safe-area-y')
}
}
// Start/stop message polling and remote relay when auth state changes
watch(() => appStore.isAuthenticated, (authenticated) => {
if (authenticated) {
messageToast.startPolling()
screensaverStore.resetInactivityTimer()
// Don't start relay on kiosk — kiosk gets input via xdotool (system-level),
// relay would duplicate every keystroke/click as DOM events
const isKiosk = localStorage.getItem('kiosk') === 'true'
|| new URLSearchParams(window.location.search).has('kiosk')
if (!isKiosk) {
startRemoteRelay()
}
} else {
messageToast.stopPolling()
toastMessage.value = { show: false, text: '', fromPubkey: '' }
screensaverStore.clearInactivityTimer()
screensaverStore.deactivate()
stopRemoteRelay()
}
}, { immediate: true })
// Reset screensaver inactivity on user activity (when authenticated)
function onUserActivity() {
if (appStore.isAuthenticated && !screensaverStore.isActive) {
screensaverStore.resetInactivityTimer()
}
}
function onKeyDown(e: KeyboardEvent) {
const isMac = navigator.platform.toUpperCase().includes('MAC')
const mod = isMac ? e.metaKey : e.ctrlKey
// Cmd+K / Ctrl+K only (modifier required - avoids accidental trigger when typing)
const target = e.target as HTMLElement
const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
if (mod && e.key === 'k') {
e.preventDefault()
spotlightStore.toggle()
return
}
// F key - CLI popup (skip when in input or modifier held)
if ((e.key === 'f' || e.key === 'F') && !isInput && !mod && !e.altKey) {
e.preventDefault()
cliStore.toggle()
return
}
// Cmd+1/2/3 - switch UI mode (skip when in input)
if (mod && !isInput && appStore.isAuthenticated) {
if (e.key === '1') { e.preventDefault(); uiModeStore.setMode('easy'); router.push('/dashboard'); return }
if (e.key === '2') { e.preventDefault(); uiModeStore.setMode('gamer'); router.push('/dashboard'); return }
if (e.key === '3') { e.preventDefault(); router.push('/dashboard/chat'); return }
}
// Cmd+M / Ctrl+M - cycle UI mode (skip when in input)
if (mod && (e.key === 'm' || e.key === 'M') && !isInput && appStore.isAuthenticated) {
e.preventDefault()
uiModeStore.cycleMode()
router.push('/dashboard')
return
}
// 's' key activates screensaver when authenticated (skip if typing in input)
if (e.key === 's' || e.key === 'S') {
if (!isInput && appStore.isAuthenticated && !screensaverStore.isActive && !screensaverStore.isSuppressed) {
e.preventDefault()
screensaverStore.activate()
}
}
// Keyboard typing sound - plays on any character typed in inputs (global)
if (isInput && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
playKeyboardTypingSound()
}
}
const route = useRoute()
// Start with splash hidden — onMounted decides whether to show it
const showSplash = ref(false)
const isReady = ref(false)
let modalOverlayObserver: MutationObserver | null = null
let lockedScrollY = 0
let previousBodyStyles: Partial<CSSStyleDeclaration> = {}
let bodyLockedForModal = false
let modalTouchY: number | null = null
function hasBlockingOverlay() {
if (typeof document === 'undefined') return false
return Array.from(document.querySelectorAll<HTMLElement>('.fixed.inset-0'))
.some((el) => {
const style = window.getComputedStyle(el)
const rect = el.getBoundingClientRect()
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& style.pointerEvents !== 'none'
&& rect.width > 0
&& rect.height > 0
})
}
function visibleBlockingOverlays() {
if (typeof document === 'undefined') return []
return Array.from(document.querySelectorAll<HTMLElement>('.fixed.inset-0'))
.filter((el) => {
const style = window.getComputedStyle(el)
const rect = el.getBoundingClientRect()
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& style.pointerEvents !== 'none'
&& rect.width > 0
&& rect.height > 0
})
}
function closestOverlay(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return null
return visibleBlockingOverlays().find((overlay) => overlay.contains(target)) || null
}
function canScrollInsideOverlay(target: EventTarget | null, overlay: HTMLElement, deltaY: number) {
if (!(target instanceof HTMLElement)) return false
let el: HTMLElement | null = target
while (el && overlay.contains(el)) {
const style = window.getComputedStyle(el)
const canScrollY = /(auto|scroll)/.test(style.overflowY)
&& el.scrollHeight > el.clientHeight
if (canScrollY) {
if (deltaY < 0 && el.scrollTop > 0) return true
if (deltaY > 0 && el.scrollTop + el.clientHeight < el.scrollHeight - 1) return true
}
if (el === overlay) break
el = el.parentElement
}
return false
}
function containModalWheel(ev: WheelEvent) {
if (!bodyLockedForModal) return
const overlay = closestOverlay(ev.target)
if (!overlay || !canScrollInsideOverlay(ev.target, overlay, ev.deltaY)) {
ev.preventDefault()
}
}
function containModalTouchStart(ev: TouchEvent) {
modalTouchY = ev.touches[0]?.clientY ?? null
}
function containModalTouchMove(ev: TouchEvent) {
if (!bodyLockedForModal) return
const currentY = ev.touches[0]?.clientY
if (currentY === undefined || modalTouchY === null) {
ev.preventDefault()
return
}
const deltaY = modalTouchY - currentY
modalTouchY = currentY
const overlay = closestOverlay(ev.target)
if (!overlay || !canScrollInsideOverlay(ev.target, overlay, deltaY)) {
ev.preventDefault()
}
}
function lockBodyForModal() {
if (bodyLockedForModal || typeof document === 'undefined') return
lockedScrollY = window.scrollY || document.documentElement.scrollTop || 0
previousBodyStyles = {
position: document.body.style.position,
top: document.body.style.top,
left: document.body.style.left,
right: document.body.style.right,
width: document.body.style.width,
overflow: document.body.style.overflow,
}
document.body.style.position = 'fixed'
document.body.style.top = `-${lockedScrollY}px`
document.body.style.left = '0'
document.body.style.right = '0'
document.body.style.width = '100%'
document.body.style.overflow = 'hidden'
document.documentElement.classList.add('modal-scroll-locked')
bodyLockedForModal = true
}
function unlockBodyForModal() {
if (!bodyLockedForModal || typeof document === 'undefined') return
document.body.style.position = previousBodyStyles.position || ''
document.body.style.top = previousBodyStyles.top || ''
document.body.style.left = previousBodyStyles.left || ''
document.body.style.right = previousBodyStyles.right || ''
document.body.style.width = previousBodyStyles.width || ''
document.body.style.overflow = previousBodyStyles.overflow || ''
document.documentElement.classList.remove('modal-scroll-locked')
window.scrollTo(0, lockedScrollY)
previousBodyStyles = {}
bodyLockedForModal = false
modalTouchY = null
}
function syncModalBodyLock() {
if (hasBlockingOverlay()) lockBodyForModal()
else unlockBodyForModal()
}
/**
* Determine if splash screen should be shown
* Splash is skipped if:
* - User has already seen the intro
* - User is on a direct route (refresh/bookmark)
*/
// Fix Chromium backdrop-filter rendering bug: when tab loses/regains focus,
// the compositor fails to repaint backdrop-filter layers over animated
// fixed-position overlays (body::before/after with mix-blend-mode).
// On return: strip backdrop-filter via class, wait a frame, then restore.
function onVisibilityChange() {
if (document.hidden) {
document.documentElement.classList.add('tab-hidden')
} else {
// Step 1: strip backdrop-filter while animations stay paused (tab-hidden)
document.documentElement.classList.add('no-backdrop')
// Step 2: restore backdrop-filter over static content (clean compositor rebuild)
// Use setTimeout — Chromium batches rAFs on tab return
setTimeout(() => {
document.documentElement.classList.remove('no-backdrop')
// Step 3: resume animations after backdrop-filter layers are established
requestAnimationFrame(() => {
document.documentElement.classList.remove('tab-hidden')
})
}, 50)
}
}
onMounted(async () => {
syncKioskSafeArea()
// Light app-wide mesh poll so a freshly plugged-in radio surfaces the
// setup modal on any page (the Mesh view's own poll takes over there).
useMeshStore().startGlobalDetection()
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('keydown', onKeyDown, true)
window.addEventListener('mousemove', onUserActivity)
window.addEventListener('mousedown', onUserActivity)
window.addEventListener('keydown', onUserActivity)
window.addEventListener('touchstart', onUserActivity)
window.addEventListener('message', onShareToMeshMessage)
document.addEventListener('wheel', containModalWheel, { capture: true, passive: false })
document.addEventListener('touchstart', containModalTouchStart, { capture: true, passive: true })
document.addEventListener('touchmove', containModalTouchMove, { capture: true, passive: false })
modalOverlayObserver = new MutationObserver(() => {
requestAnimationFrame(syncModalBodyLock)
})
modalOverlayObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'style'],
})
syncModalBodyLock()
let seenIntro = localStorage.getItem('neode_intro_seen') === '1'
const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1'
if (fromBoot) sessionStorage.removeItem('archipelago_from_boot')
// One-shot "Replay intro" request from the login screen — must survive the
// auto-re-mark below (which otherwise instantly suppresses the replay on any
// onboarded node).
let replayRequested = sessionStorage.getItem('archipelago_replay_intro') === '1'
if (replayRequested) sessionStorage.removeItem('archipelago_replay_intro')
// The route object still holds the router's START_LOCATION ('/') here —
// the initial navigation resolves asynchronously — so a deep-route load
// (e.g. a direct /login visit) would masquerade as a root boot. The URL
// bar is the only truthful source this early.
const bootPath = window.location.pathname
// Public demo: every fresh boot at the root (first visit or a browser
// refresh) starts with the typing splash for the full effect. In-session SPA
// navigation never remounts App, and deep-route refreshes keep their place.
const { IS_DEMO } = await import('@/composables/useDemoIntro')
if (IS_DEMO && bootPath === '/') replayRequested = true
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
const splashCandidate = !seenIntro
&& (fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
if (splashCandidate && onboardingComplete !== true) {
try {
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
onboardingComplete = await checkOnboardingStatus()
} catch {
onboardingComplete = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
}
}
if (!replayRequested && !seenIntro && onboardingComplete === true) {
try { localStorage.setItem('neode_intro_seen', '1') } catch { /* noop */ }
seenIntro = true
}
if (import.meta.env.DEV) console.log('[App] onMounted — seenIntro:', seenIntro, 'fromBoot:', fromBoot, 'onboardingComplete:', onboardingComplete)
if (shouldShowIntroSplash({
seenIntro,
routePath: bootPath,
fromBoot,
devMode: import.meta.env.VITE_DEV_MODE,
onboardingComplete,
replayRequested,
})) {
// The intro is definitely playing — unmute the whole cinematic (speech,
// synthwave, pops) even on browsers whose localStorage says onboarding is
// complete; the sound gate otherwise silences replays.
const { enableCinematicSounds } = await import('@/composables/useLoginSounds')
enableCinematicSounds()
// Kick off the intro video download NOW: the splash's <video> element only
// mounts ~20s in (after the typing sequence), and on a cold cache the file
// would otherwise start fetching mid-sequence and stutter. A detached
// <video preload=auto> is used because Chromium does not support
// <link rel=preload as=video> — the media cache then serves the splash's
// element, which uses the identical URL.
try {
const warm = document.createElement('video')
warm.muted = true
warm.preload = 'auto'
warm.src = '/assets/video/video-intro.mp4?v=8'
;(window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm = warm
} catch { /* ignore */ }
// Coming from boot screen — show the full splash intro (Enter to Exit → typing → logo)
showSplash.value = true
} else {
// Already seen intro, direct route, or boot mode (boot screen handles intro)
// Set isReady BEFORE hiding splash to prevent flash of partial content
await router.isReady()
isReady.value = true
showSplash.value = false
document.body.classList.add('splash-complete')
}
})
onBeforeUnmount(() => {
document.documentElement.classList.remove('kiosk-safe-area')
document.documentElement.style.removeProperty('--kiosk-safe-area-x')
document.documentElement.style.removeProperty('--kiosk-safe-area-y')
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('keydown', onKeyDown, true)
window.removeEventListener('mousemove', onUserActivity)
window.removeEventListener('mousedown', onUserActivity)
window.removeEventListener('keydown', onUserActivity)
window.removeEventListener('touchstart', onUserActivity)
window.removeEventListener('message', onShareToMeshMessage)
document.removeEventListener('wheel', containModalWheel, { capture: true })
document.removeEventListener('touchstart', containModalTouchStart, { capture: true })
document.removeEventListener('touchmove', containModalTouchMove, { capture: true })
modalOverlayObserver?.disconnect()
modalOverlayObserver = null
unlockBodyForModal()
})
/**
* Phase 3c: marketplace app iframes share files into mesh chats by POSTing
* to /api/share-to-mesh then postMessaging the CID back to this parent
* window. We stash it in sessionStorage + route to /mesh; Mesh.vue reads the
* stash on mount and stages it as a pending attachment.
*/
function onShareToMeshMessage(ev: MessageEvent) {
// Same-origin senders only (matches Chat.vue's handler) — otherwise any
// embedded frame can force-navigate the UI to /mesh with a staged CID.
if (ev.origin !== window.location.origin) return
const data = ev.data as { type?: string; cid?: string } | null
if (!data || data.type !== 'share-to-mesh' || !data.cid) return
try {
sessionStorage.setItem('archipelago_share_to_mesh', JSON.stringify(data))
} catch {
/* quota — fall through */
}
if (route.path !== '/mesh') {
router.push('/mesh')
} else {
// Already on /mesh — dispatch a synthetic event so the view picks it up.
window.dispatchEvent(new CustomEvent('archipelago:share-to-mesh'))
}
}
/**
* Handle splash screen completion
* Routes user directly to appropriate screen based on onboarding status (from backend)
*/
async function handleSplashComplete() {
showSplash.value = false
document.body.classList.add('splash-complete')
isReady.value = true
sessionStorage.setItem('archipelago_from_splash', '1')
const devMode = import.meta.env.VITE_DEV_MODE
if (devMode === 'setup' || devMode === 'existing') {
router.push('/login').catch(() => {})
return
}
// Demo: the cinematic always continues into the onboarding intro page —
// the mock backend reports "onboarded", which would otherwise route
// straight to /login and cut the sequence short.
{
const { IS_DEMO } = await import('@/composables/useDemoIntro')
if (IS_DEMO) {
router.push('/onboarding/intro').catch(() => {})
return
}
}
try {
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
const seenOnboarding = await checkOnboardingStatus()
if (seenOnboarding === true) {
router.push('/login').catch(() => {})
return
}
if (seenOnboarding === false) {
router.push('/onboarding/intro').catch(() => {})
return
}
// Backend unreachable after retries. Prefer the localStorage
// cache on THIS browser (if a prior successful check set it) —
// otherwise defer to RootRedirect which polls + retries rather
// than forcing an already-onboarded user through the wizard.
if (localStorage.getItem('neode_onboarding_complete') === '1') {
router.push('/login').catch(() => {})
} else {
router.push('/').catch(() => {})
}
} catch {
// Do NOT default to /onboarding/intro here. RootRedirect has retry
// + polling + boot-screen handling; let it decide.
router.push('/').catch(() => {})
}
}
</script>
<style>
/* Global styles are in style.css */
</style>