Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:50 +00:00
commit b67e1527a2
2068 changed files with 472303 additions and 0 deletions
@@ -0,0 +1,157 @@
<template>
<Teleport to="body">
<!-- Lifecycle / Offline Banner.
Server restart/shutdown is deliberate shown immediately. A plain
connection blip is debounced (showConnIssue) so transient sub-grace
reconnects don't flash. -->
<Transition name="conn-banner">
<div
v-if="(showLifecycle || showConnectionLost)"
class="conn-banner-overlay"
>
<div class="path-option-card px-6 py-3 border-l-4 border-yellow-500 inline-flex items-center gap-2 text-yellow-200 shadow-2xl">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span class="font-medium">
{{ isRestarting ? 'Server is restarting...' : isShuttingDown ? 'Server is shutting down...' : 'Connection lost' }}
</span>
</div>
</div>
</Transition>
<!-- Reconnecting Banner (debounced) -->
<Transition name="conn-banner">
<div
v-if="showReconnecting"
class="conn-banner-overlay"
>
<div class="path-option-card px-6 py-3 border-l-4 border-blue-500 inline-flex items-center gap-2 text-blue-200 shadow-2xl">
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span class="font-medium">Reconnecting...</span>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, ref, watch, onUnmounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { IS_DEMO } from '@/composables/useDemoIntro'
const store = useAppStore()
const isOffline = computed(() => store.isOffline)
const isRestarting = computed(() => store.isRestarting)
const isShuttingDown = computed(() => store.isShuttingDown)
// A deliberate server lifecycle transition (restart/shutdown) is real and
// user-initiated — surface it immediately, no debounce.
const isLifecycleTransition = computed(() => isRestarting.value || isShuttingDown.value)
const showLifecycle = computed(() => isLifecycleTransition.value && store.isAuthenticated)
// A plain connection blip (offline or reconnecting, not a lifecycle transition).
// The overwhelming majority recover within a second or two (load spikes,
// Tailscale/relay TCP resets), so showing the banner instantly makes a healthy
// node read as unstable. Debounce: only surface after the issue persists past a
// grace window; hide immediately on recovery.
const hasConnIssue = computed(
() => (store.isReconnecting || isOffline.value) && !isLifecycleTransition.value
)
const SHOW_DELAY_MS = 2500
// Right after the page loads or the tab returns to the foreground, a dead
// WebSocket is the NORMAL state (browsers kill sockets in background tabs;
// first paint races the initial connect). Reconnecting takes longer than
// the steady-state grace on real links — radio wake-up, TLS, proxies — so
// the 2.5s window made every tab-return flash "Connection lost" on a
// perfectly healthy node. Give those moments a much longer runway; keep
// the short window for genuine mid-session drops.
const RESUME_GRACE_WINDOW_MS = 15000
const RESUME_SHOW_DELAY_MS = 10000
const showConnIssue = ref(false)
let pendingTimer: ReturnType<typeof setTimeout> | null = null
let lastResumeAt = Date.now() // mount counts as a resume (initial connect)
function onVisibilityResume() {
if (!document.hidden) lastResumeAt = Date.now()
}
document.addEventListener('visibilitychange', onVisibilityResume)
function clearTimer() {
if (pendingTimer) {
clearTimeout(pendingTimer)
pendingTimer = null
}
}
watch(
hasConnIssue,
(issue) => {
clearTimer()
// The demo runs against a local mock — a connection banner there is
// meaningless noise on what should be a flawless showcase.
if (IS_DEMO) return
if (issue) {
const delay = Date.now() - lastResumeAt < RESUME_GRACE_WINDOW_MS
? RESUME_SHOW_DELAY_MS
: SHOW_DELAY_MS
pendingTimer = setTimeout(() => {
showConnIssue.value = true
pendingTimer = null
}, delay)
} else {
// Recovered before the grace window elapsed — hide at once.
showConnIssue.value = false
}
},
{ immediate: true }
)
onUnmounted(() => {
clearTimer()
document.removeEventListener('visibilitychange', onVisibilityResume)
})
// Debounced visual states the template renders.
const showReconnecting = computed(
() => showConnIssue.value && store.isReconnecting && store.isAuthenticated
)
const showConnectionLost = computed(
() => showConnIssue.value && isOffline.value && !store.isReconnecting && store.isAuthenticated
)
</script>
<style scoped>
/* Float the connection banners over the UI instead of occupying layout space
* (which previously pushed the whole dashboard down when reconnecting).
* Pinned top-center, clear of the status bar via the safe-area inset that the
* Android companion app injects (--safe-area-top), falling back to env(). */
.conn-banner-overlay {
position: fixed;
top: calc(1rem + var(--safe-area-top, env(safe-area-inset-top, 0px)));
left: 50%;
z-index: 60;
transform: translateX(-50%);
max-width: calc(100% - 2rem);
pointer-events: none; /* purely informational — never intercept taps */
}
.conn-banner-enter-active,
.conn-banner-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.conn-banner-enter-from,
.conn-banner-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(-8px);
}
.conn-banner-enter-to,
.conn-banner-leave-from {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
</style>
@@ -0,0 +1,291 @@
<template>
<!-- Persistent Mobile Tabs for Apps/Marketplace -->
<div
v-if="showAppsTabs && !isAppSessionActive"
class="md:hidden fixed top-0 left-0 right-0 z-40 px-4 pb-2 glass-piece mobile-top-tabs"
:class="{ 'glass-throw-mobile-tabs': showZoomIn }"
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); transform: translateZ(0); padding-top: calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 16px);"
>
<div class="mode-switcher mode-switcher-full">
<RouterLink
to="/dashboard/apps"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps/')) && route.query.tab !== 'services' && route.query.tab !== 'websites' }"
@click.prevent="router.push({ path: '/dashboard/apps', query: {} })"
>My Apps</RouterLink>
<RouterLink
to="/dashboard/discover"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/marketplace' || route.path.startsWith('/dashboard/marketplace/') || route.path === '/dashboard/discover' }"
>App Store</RouterLink>
<RouterLink
to="/dashboard/apps?tab=services"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': route.query.tab === 'services' || route.query.tab === 'websites' }"
@click.prevent="router.push({ path: '/dashboard/apps', query: { tab: 'services' } })"
>Services</RouterLink>
</div>
</div>
<!-- Persistent Mobile Tabs for Network/Cloud -->
<div
v-if="showNetworkTabs && !isAppSessionActive"
class="md:hidden fixed left-0 right-0 z-40 px-4 pb-2 glass-piece mobile-top-tabs"
:class="{ 'glass-throw-mobile-tabs-2': showZoomIn }"
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); transform: translateZ(0);"
:style="{ top: showAppsTabs ? '80px' : '0', paddingTop: showAppsTabs ? '16px' : 'calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 16px)' }"
>
<div class="mode-switcher mode-switcher-full">
<RouterLink
to="/dashboard/web5"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/web5' || route.path.startsWith('/dashboard/web5/') }"
>Web5</RouterLink>
<RouterLink
to="/dashboard/cloud"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/cloud' || route.path.startsWith('/dashboard/cloud/') }"
>Cloud</RouterLink>
<RouterLink
to="/dashboard/server"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/server' || route.path.startsWith('/dashboard/server/') }"
>Network</RouterLink>
<RouterLink
to="/dashboard/mesh"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/mesh' || route.path.startsWith('/dashboard/mesh/') }"
>Mesh</RouterLink>
</div>
</div>
<!-- Mobile Bottom Tab Bar (hidden when app is open fullscreen) -->
<nav
v-if="!isAppSessionActive"
ref="mobileTabBar"
data-mobile-tab-bar
:aria-label="t('dashboard.mobileNav')"
class="md:hidden fixed bottom-0 left-0 right-0 border-t border-glass-border shadow-glass z-50 glass-piece"
:class="{ 'glass-throw-tabbar': showZoomIn }"
style="background: rgba(0, 0, 0, 0.25); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); padding-bottom: var(--safe-area-bottom, env(safe-area-inset-bottom, 0px));"
>
<div class="flex justify-around items-center px-2 py-3 relative">
<RouterLink
v-for="item in mobileNavItems"
:key="item.path"
:to="item.path"
aria-current-value="page"
@click="appLauncher.closePanel()"
class="flex items-center justify-center w-14 h-14 rounded-xl text-white/70 transition-all duration-300 relative z-10"
:class="{
'nav-tab-active': item.isCombined
? (item.path === '/dashboard/apps'
? (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/discover') || route.path.includes('/app-session'))
: item.path === '/dashboard/web5'
? (route.path.includes('/web5') || route.path.includes('/federation') || route.path.includes('/mesh'))
: (route.path.includes('/cloud') || route.path.includes('/server')))
: undefined
}"
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
>
<svg v-if="item.icon === 'web5'" class="w-6 h-6 transition-all duration-300" aria-hidden="true" fill="currentColor" viewBox="0 0 1631 1624">
<path fill-rule="evenodd" clip-rule="evenodd" d="M914.932 359.228H916.229V715.252H1630.47V1088.98H1451.41V1267.98H1274.33V1445H1093.31V1624H715.534V1264.77H714.237V908.748H0V535.02H179.051V356.025H356.135V178.996H537.154V0H914.932V359.228ZM916.229 1425.33H1073.64V1248.31H1254.66V1071.28H1431.74V913.918H916.229V1425.33ZM556.83 375.695H375.811V552.723H198.727V710.082H714.237V198.666H556.83V375.695Z" />
</svg>
<svg v-else class="w-6 h-6 transition-all duration-300" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in getIconPath(item.icon)"
:key="index"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
/>
</svg>
</RouterLink>
<!-- Chat launcher -->
<button
@click="router.push('/dashboard/chat')"
class="chat-launcher-btn-mobile flex items-center justify-center w-14 h-14 rounded-xl transition-all duration-300 relative z-10"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
</svg>
</button>
</div>
</nav>
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useUIModeStore } from '@/stores/uiMode'
interface NavItem {
path: string
label: string
icon: string
isCombined?: boolean
}
defineProps<{
showZoomIn: boolean
}>()
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const appLauncher = useAppLauncherStore()
const uiMode = useUIModeStore()
const mobileTabBar = ref<HTMLElement | null>(null)
const MOBILE_LAYOUT_MAX_WIDTH = 920
const viewportWidth = ref(typeof window === 'undefined' ? 1024 : window.innerWidth)
// App sessions own their mobile controls, so the nav hides while one is open.
// Mobile launches now use the store-driven panel (no route change) to keep the
// background tab intact, so treat an active panel the same as a routed session.
const isAppSessionActive = computed(() => route.name === 'app-session' || !!appLauncher.panelAppId)
// Show persistent tabs for Apps/Marketplace on mobile
const showAppsTabs = computed(() => {
if (typeof window === 'undefined') return false
if (viewportWidth.value > MOBILE_LAYOUT_MAX_WIDTH) return false
return route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/discover')
})
// Show persistent tabs for Network/Cloud on mobile
const showNetworkTabs = computed(() => {
if (typeof window === 'undefined') return false
if (viewportWidth.value > MOBILE_LAYOUT_MAX_WIDTH) return false
if (route.name === 'cloud-folder') return false
return route.path.includes('/server') || route.path.includes('/cloud') || route.path.includes('/web5') || route.path.includes('/mesh')
})
// Top padding for content div to clear fixed mobile tab overlays.
// Includes safe area inset for Android (read from CSS custom property set by WebView).
const safeAreaTop = ref(0)
function readSafeAreaTop() {
if (typeof window === 'undefined') return
const val = getComputedStyle(document.documentElement).getPropertyValue('--safe-area-top').trim()
if (val) safeAreaTop.value = parseInt(val, 10) || 0
}
const mobileTabPaddingTop = computed(() => {
if (typeof window === 'undefined' || viewportWidth.value > MOBILE_LAYOUT_MAX_WIDTH) return 0
const sat = safeAreaTop.value
if (showAppsTabs.value && showNetworkTabs.value) return 160 + sat
if (showAppsTabs.value || showNetworkTabs.value) return 80 + sat
return 0
})
defineExpose({
showAppsTabs,
showNetworkTabs,
mobileTabPaddingTop,
})
function updateTabBarHeight() {
if (typeof window === 'undefined') return
const el = mobileTabBar.value
// offsetHeight is 0 when the bar is hidden (desktop `md:hidden`) or not yet
// laid out. Writing `--mobile-tab-bar-height: 0px` would DEFEAT the `, 88px`
// fallback baked into the `.mobile-scroll-pad` clearance calc (an explicit
// 0px is still "set"), so the fixed tab bar ends up covering the last row of
// content — the Cloud/files "bottom elements cut off" bug. Only write a real
// measured height; otherwise remove the var so the fallback applies.
if (el && el.offsetHeight > 0) {
document.documentElement.style.setProperty('--mobile-tab-bar-height', `${el.offsetHeight}px`)
} else {
document.documentElement.style.removeProperty('--mobile-tab-bar-height')
}
}
function onResize() {
viewportWidth.value = window.innerWidth
updateTabBarHeight()
}
function onInsetsInjected() {
readSafeAreaTop()
updateTabBarHeight()
}
onMounted(() => {
updateTabBarHeight()
// Re-measure after the first paint: on mount the bar may not have its final
// laid-out height yet (fonts/safe-area padding still settling), which would
// leave the clearance var short.
requestAnimationFrame(updateTabBarHeight)
readSafeAreaTop()
window.addEventListener('resize', onResize)
// The Android WebView injects --safe-area-top asynchronously and fires this
// event when it lands. An authenticated session mounts the dashboard BEFORE
// the injection (fresh installs mount after login, long after it), so a
// one-shot read here bakes in 0 and content slides under the growing fixed
// tab bar — the update-install-only overlap bug.
window.addEventListener('archy-insets', onInsetsInjected)
// Fallback retry ladder for APKs that predate the event.
for (const delay of [500, 1500, 3000, 6000]) {
setTimeout(onInsetsInjected, delay)
}
})
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize)
window.removeEventListener('archy-insets', onInsetsInjected)
})
// Re-measure on route changes
watch(() => route.path, () => {
nextTick(() => {
updateTabBarHeight()
})
})
const gamerMobileNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps', isCombined: true },
{ path: '/dashboard/web5', label: 'Web5', icon: 'web5', isCombined: true },
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
]
const easyMobileNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps' },
{ path: '/dashboard/cloud', label: 'Cloud', icon: 'cloud' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
]
const chatMobileNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
]
const mobileNavItems = computed(() => {
if (uiMode.isEasy) return easyMobileNav
if (uiMode.isChat) return chatMobileNav
return gamerMobileNav
})
function getIconPath(iconName: string): string[] {
const icons: Record<string, string[]> = {
home: ['M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6'],
apps: ['M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z'],
cloud: ['M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'],
server: ['M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01'],
web5: ['M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9'],
mesh: ['M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01M5.636 13.636a9 9 0 0112.728 0M1.5 10.5a14 14 0 0121 0'],
fleet: ['M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2'],
chat: ['M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'],
settings: [
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
'M15 12a3 3 0 11-6 0 3 3 0 016 0z',
],
}
return icons[iconName] || []
}
</script>
@@ -0,0 +1,68 @@
<template>
<RouterView v-slot="{ Component, route }">
<Transition :name="getTransitionName(route)">
<KeepAlive :max="KEEP_ALIVE_MAX" :include="keepAliveIncludes">
<component
:is="wrapperFor(route.path)"
:key="route.path"
:mobile-tab-padding-top="mobileTabPaddingTop"
:needs-mobile-back-button-space="needsMobileBackButtonSpace"
>
<component
:is="Component"
:class="isFullBleedPath(route.path) ? undefined : 'view-container flex-none'"
/>
</component>
</KeepAlive>
</Transition>
</RouterView>
</template>
<script setup lang="ts">
// The KeepAlive host extracted from Dashboard.vue's nested RouterView (D-01),
// restructured after the Task 3 checkpoint failure (broken margins + missing
// slide transitions) to restore the pre-02-02 rendered DOM exactly.
//
// Structural invariants — the tests in __tests__/keepAliveTabs.test.ts pin
// these, and dashboard-styles.css is the contract they serve:
//
// 1. The <Transition> child is a keyed `div.view-wrapper` that sits directly
// in the RouterView slot (so it is the direct child of
// `.perspective-container` in Dashboard.vue). Every transition in
// dashboard-styles.css is a compound selector
// (`.slide-up-enter-active.view-wrapper`, `.depth-forward-enter-from.view-wrapper`,
// …), so the transition classes and `view-wrapper` MUST share one element,
// with no intermediate wrapper flattening the 3D perspective or clipping
// slide movement.
// 2. The per-route wrapper shapes (full-bleed chat/mesh vs. the default
// padded/scrollable shape) live INSIDE `div.view-wrapper` — `view-wrapper`
// is `absolute inset-0` and must never be applied to the view's own root
// inside the padded wrapper (that pins the view over the page padding:
// the broken-margins regression).
// 3. KeepAlive caching is reconciled with (1) and (2) by making the keyed
// `div.view-wrapper` the root of a statically-defined per-route wrapper
// component (dashboardViewWrappers.ts). KeepAlive sits between
// <Transition> and the keyed wrapper vnode — the canonical composition —
// and `:include` (matching the wrappers' static names, derived from
// KEEP_ALIVE_PATHS) decides which wrappers are instance-cached. Caching
// the wrapper caches its whole subtree, including the routed view.
// 4. KeepAlive itself is never keyed, toggled with v-if, or nested under a
// per-route element — any of those tears down its instance cache.
//
// Scroll behavior: each route's scroll container lives inside its keyed
// wrapper, so non-kept routes reset to top on entry (the pre-02-02 behavior)
// and the kept-alive tab's scroll container is part of its cached subtree —
// no manual scroll-retention bookkeeping needed.
import { RouterView } from 'vue-router'
import { useRouteTransitions } from './useRouteTransitions'
import { KEEP_ALIVE_MAX } from './keepAliveRoutes'
import { isFullBleedPath, keepAliveIncludeNames, wrapperFor } from './dashboardViewWrappers'
defineProps<{
mobileTabPaddingTop: number | null
needsMobileBackButtonSpace: boolean
}>()
const { getTransitionName } = useRouteTransitions()
const keepAliveIncludes = keepAliveIncludeNames()
</script>
@@ -0,0 +1,191 @@
<template>
<aside
v-show="!chatFullscreen"
data-controller-zone="sidebar"
class="hidden md:flex w-[256px] h-screen flex-shrink-0 sticky top-0 relative flex-col z-10"
:class="{ 'sidebar-animate': showZoomIn }"
>
<div class="sidebar-shell">
<div class="sidebar-inner flex flex-col h-full min-h-0">
<div class="sidebar-logo flex items-center gap-3 mb-8 p-6 pb-0 shrink-0">
<AnimatedLogo />
<div class="min-w-0 flex-1">
<h2 class="text-lg font-semibold text-white truncate">{{ serverName }}</h2>
<p class="text-xs text-white/60">{{ $ver(version) }}</p>
</div>
</div>
<nav class="sidebar-nav flex-1 min-h-0 overflow-y-auto overscroll-contain space-y-2 px-6 py-4" :aria-label="t('dashboard.mainNav')">
<RouterLink
v-for="(item, idx) in desktopNavItems"
:key="item.path"
:to="item.path"
aria-current-value="page"
class="sidebar-nav-item flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
:class="{ 'nav-tab-active': item.isCombined && (route.path.includes('/apps') || route.path.includes('/marketplace') || route.path.includes('/discover') || route.path.includes('/app-session') || (item.path === '/dashboard/apps' && !!appLauncher.panelAppId)) }"
:exact-active-class="item.isCombined ? undefined : 'nav-tab-active'"
@click="appLauncher.closePanel()"
:style="{ '--nav-stagger': idx }"
>
<svg v-if="item.icon === 'web5'" class="w-5 h-5" aria-hidden="true" fill="currentColor" viewBox="0 0 1631 1624">
<path fill-rule="evenodd" clip-rule="evenodd" d="M914.932 359.228H916.229V715.252H1630.47V1088.98H1451.41V1267.98H1274.33V1445H1093.31V1624H715.534V1264.77H714.237V908.748H0V535.02H179.051V356.025H356.135V178.996H537.154V0H914.932V359.228ZM916.229 1425.33H1073.64V1248.31H1254.66V1071.28H1431.74V913.918H916.229V1425.33ZM556.83 375.695H375.811V552.723H198.727V710.082H714.237V198.666H556.83V375.695Z" />
</svg>
<svg v-else class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-for="(path, index) in getIconPath(item.icon)"
:key="index"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
:d="path"
/>
</svg>
<span>{{ item.label }}</span>
<span
v-if="item.path === '/dashboard/web5' && web5Badge.pendingRequestCount > 0"
class="ml-auto w-5 h-5 flex items-center justify-center rounded-full bg-orange-500 text-white text-[10px] font-bold"
>{{ web5Badge.pendingRequestCount }}</span>
<span
v-if="item.path === '/dashboard/mesh' && meshStore.totalUnread > 0"
class="ml-auto w-5 h-5 flex items-center justify-center rounded-full bg-orange-500 text-white text-[10px] font-bold"
>{{ meshStore.totalUnread }}</span>
</RouterLink>
<!-- Chat launcher button -->
<button
@click="router.push('/dashboard/chat')"
class="chat-launcher-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-300"
>
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-for="(path, index) in getIconPath('chat')" :key="index" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="path" />
</svg>
<span>AIUI</span>
</button>
<!-- Logout - styled as nav item, below Settings -->
<button
@click="$emit('logout')"
class="sidebar-logout-btn w-full flex items-center gap-3 px-4 py-3 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors"
>
<svg class="w-5 h-5" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
<span>Logout</span>
</button>
</nav>
<div class="sidebar-bottom shrink-0">
<div class="sidebar-controller px-6 pb-2">
<ControllerIndicator />
<CompanionIndicator />
</div>
<!-- Online status -->
<div class="px-6 pb-2">
<div class="rounded-lg bg-white/5 border border-white/10 px-4 py-2.5">
<OnlineStatusPill />
</div>
</div>
<!-- Mode switcher -->
<div class="px-6 pb-6">
<ModeSwitcher />
</div>
</div>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useAppLauncherStore } from '@/stores/appLauncher'
import { useUIModeStore } from '@/stores/uiMode'
import { useWeb5BadgeStore } from '@/stores/web5Badge'
import { useMeshStore } from '@/stores/mesh'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import OnlineStatusPill from '@/components/OnlineStatusPill.vue'
import ControllerIndicator from '@/components/ControllerIndicator.vue'
import CompanionIndicator from '@/components/CompanionIndicator.vue'
import ModeSwitcher from '@/components/ModeSwitcher.vue'
interface NavItem {
path: string
label: string
icon: string
isCombined?: boolean
}
defineProps<{
showZoomIn: boolean
}>()
defineEmits<{
logout: []
}>()
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const store = useAppStore()
const appLauncher = useAppLauncherStore()
const uiMode = useUIModeStore()
const web5Badge = useWeb5BadgeStore()
const meshStore = useMeshStore()
const chatFullscreen = computed(() => route.path === '/dashboard/chat')
const serverName = computed(() => store.serverName)
const version = computed(() => store.serverInfo?.version || '0.0.0')
const gamerDesktopNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/apps', label: 'Apps', icon: 'apps', isCombined: true },
{ path: '/dashboard/cloud', label: 'Cloud', icon: 'cloud' },
{ path: '/dashboard/mesh', label: 'Mesh', icon: 'mesh' },
{ path: '/dashboard/server', label: 'Network', icon: 'server' },
{ path: '/dashboard/web5', label: 'Web5', icon: 'web5' },
// { path: '/dashboard/fleet', label: 'Fleet', icon: 'fleet' }, // Hidden for beta
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
]
const easyDesktopNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/apps', label: 'My Apps', icon: 'apps' },
{ path: '/dashboard/cloud', label: 'Cloud', icon: 'cloud' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
]
const chatDesktopNav: NavItem[] = [
{ path: '/dashboard', label: 'Home', icon: 'home' },
{ path: '/dashboard/apps', label: 'My Apps', icon: 'apps' },
{ path: '/dashboard/settings', label: 'Settings', icon: 'settings' },
]
const desktopNavItems = computed(() => {
if (uiMode.isEasy) return easyDesktopNav
if (uiMode.isChat) return chatDesktopNav
return gamerDesktopNav
})
function getIconPath(iconName: string): string[] {
const icons: Record<string, string[]> = {
home: ['M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6'],
apps: ['M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z'],
marketplace: ['M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z'],
cloud: ['M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'],
server: ['M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01'],
web5: ['M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9'],
mesh: ['M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01M5.636 13.636a9 9 0 0112.728 0M1.5 10.5a14 14 0 0121 0'],
fleet: ['M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2'],
chat: ['M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z'],
settings: [
'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z',
'M15 12a3 3 0 11-6 0 3 3 0 016 0z',
],
}
return icons[iconName] || []
}
</script>
@@ -0,0 +1,96 @@
<template>
<div
v-if="healthNotifications.length > 0"
class="fixed right-4 z-[200] flex flex-col gap-2 max-w-sm"
style="top: calc(var(--safe-area-top, env(safe-area-inset-top, 0px)) + 16px);"
>
<div
v-for="notif in healthNotifications"
:key="notif.id"
class="p-3 rounded-xl border backdrop-blur-lg shadow-lg"
:class="notif.level === 'error'
? 'bg-red-500/15 border-red-500/30'
: notif.level === 'warning'
? 'bg-yellow-500/15 border-yellow-500/30'
: 'bg-blue-500/15 border-blue-500/30'"
>
<div class="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mt-0.5 shrink-0" :class="notif.level === 'error' ? 'text-red-400' : notif.level === 'warning' ? 'text-yellow-400' : 'text-blue-400'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white">{{ notif.title }}</p>
<p class="text-xs text-white/60 mt-0.5">{{ notif.message }}</p>
</div>
<button
class="text-white/40 hover:text-white/80 transition-colors shrink-0"
@click="dismissNotification(notif.id)"
>
<svg class="w-4 h-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>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useAppStore } from '@/stores/app'
const store = useAppStore()
const HEALTH_NOTIFICATION_MAX_AGE_MS = 30 * 60 * 1000
const GENERIC_NOTIFICATION_MAX_AGE_MS = 10 * 60 * 1000
const dismissedNotifications = ref<Set<string>>(new Set())
const healthNotifications = computed(() => {
const notifs = store.data?.notifications ?? []
const packages = store.data?.['package-data'] ?? {}
const visible = notifs.filter((n) => {
if (dismissedNotifications.value.has(n.id)) return false
const appId = n.app_id || appIdFromNotificationTitle(n.title)
if (appId) {
if (isOlderThan(n.timestamp, HEALTH_NOTIFICATION_MAX_AGE_MS)) return false
const pkg = packages[appId]
if (!pkg) return false
if (pkg.health !== 'unhealthy') return false
if (pkg.state === 'removing' || pkg.state === 'stopped' || pkg.state === 'exited') return false
} else if (isOlderThan(n.timestamp, GENERIC_NOTIFICATION_MAX_AGE_MS)) {
return false
}
return true
})
// Deduplicate: keep only the latest notification per container/title
const seen = new Map<string, typeof visible[0]>()
for (const n of visible) {
seen.set(n.title, n)
}
return [...seen.values()].slice(-3)
})
function dismissNotification(id: string) {
// Dismiss all notifications with the same title (container name)
const notif = (store.data?.notifications ?? []).find(n => n.id === id)
if (notif) {
for (const n of store.data?.notifications ?? []) {
if (n.title === notif.title) dismissedNotifications.value.add(n.id)
}
}
dismissedNotifications.value.add(id)
}
function appIdFromNotificationTitle(title: string): string | undefined {
const suffix = ' is unhealthy'
return title.endsWith(suffix) ? title.slice(0, -suffix.length) : undefined
}
function isOlderThan(timestamp: string, maxAgeMs: number): boolean {
const ts = Date.parse(timestamp)
return Number.isFinite(ts) && Date.now() - ts > maxAgeMs
}
</script>
@@ -0,0 +1,137 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/stores/app'
import { PackageState, type DataModel, type PackageDataEntry } from '@/types/api'
import HealthNotifications from '../HealthNotifications.vue'
function makePkg(id: string, state: PackageState = PackageState.Running, health: string | null = 'healthy'): PackageDataEntry {
return {
state,
health,
manifest: {
id,
title: id,
version: '1.0.0',
description: { short: '', long: '' },
'release-notes': '',
license: '',
'wrapper-repo': '',
'upstream-repo': '',
'support-site': '',
'marketing-site': '',
'donation-url': null,
interfaces: { main: { ui: true } },
} as unknown as PackageDataEntry['manifest'],
}
}
function makeData(pkg?: PackageDataEntry, timestamp = new Date().toISOString()): DataModel {
return {
'server-info': {
id: 'node',
version: '1.0.0',
name: null,
pubkey: '',
'status-info': {
restarting: false,
'shutting-down': false,
updated: false,
'backup-progress': null,
'update-progress': null,
},
'lan-address': null,
'tor-address': null,
unread: 0,
'wifi-ssids': [],
'zram-enabled': false,
'seed-backed': false,
},
'package-data': pkg ? { indeedhub: pkg } : {},
notifications: [{
id: 'health-1',
level: 'error',
title: 'indeedhub is unhealthy',
message: 'indeedhub health check failed',
timestamp,
app_id: 'indeedhub',
}],
ui: {
name: null,
'ack-welcome': '',
marketplace: {
'selected-hosts': [],
'known-hosts': {},
},
theme: 'dark',
},
}
}
describe('HealthNotifications', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
vi.useRealTimers()
})
it('shows active unhealthy package notifications', () => {
const store = useAppStore(pinia)
store.data = makeData(makePkg('indeedhub', PackageState.Running, 'unhealthy'))
const wrapper = mount(HealthNotifications, {
global: {
plugins: [pinia],
},
})
expect(wrapper.text()).toContain('indeedhub is unhealthy')
})
it('hides stale package notifications once health recovers', () => {
const store = useAppStore(pinia)
store.data = makeData(makePkg('indeedhub', PackageState.Running, 'healthy'))
const wrapper = mount(HealthNotifications, {
global: {
plugins: [pinia],
},
})
expect(wrapper.text()).not.toContain('indeedhub is unhealthy')
})
it('hides package notifications while an app is being removed', () => {
const store = useAppStore(pinia)
store.data = makeData(makePkg('indeedhub', PackageState.Removing, 'unhealthy'))
const wrapper = mount(HealthNotifications, {
global: {
plugins: [pinia],
},
})
expect(wrapper.text()).not.toContain('indeedhub is unhealthy')
})
it('hides old package health notifications on reload even if the app is still unhealthy', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
const store = useAppStore(pinia)
store.data = makeData(
makePkg('indeedhub', PackageState.Running, 'unhealthy'),
new Date('2026-06-10T11:20:00Z').toISOString(),
)
const wrapper = mount(HealthNotifications, {
global: {
plugins: [pinia],
},
})
expect(wrapper.text()).not.toContain('indeedhub is unhealthy')
})
})
@@ -0,0 +1,870 @@
// 02-04: instance caching turned on for every audited main tab. This file
// covers the activate/deactivate lifecycle contract every registered view
// must honor (Task 1) and the widened registration set + eviction (Task 2).
//
// Two kinds of coverage:
// - A small synthetic consumer component, built with the exact same
// idempotent onActivated/onDeactivated idiom every real view in this plan
// uses (see Home.vue/Chat.vue/Apps.vue/Server.vue/Mesh.vue/Web5.vue/
// Cloud.vue), proves the *pattern* in isolation.
// - At least one assertion against a real converted view (Server.vue's
// vpnPollInterval), mounted inside a real <KeepAlive>, with fake timers.
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter, type RouteRecordRaw } from 'vue-router'
import { createPinia } from 'pinia'
import { KeepAlive, defineComponent, h, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DashboardRouterView from '../DashboardRouterView.vue'
import { KEEP_ALIVE_MAX, KEEP_ALIVE_PATHS, shouldKeepAlive } from '../keepAliveRoutes'
import { keepAliveIncludeNames, KEEP_WRAP_PREFIX } from '../dashboardViewWrappers'
import { rpcClient } from '@/api/rpc-client'
import Server from '@/views/Server.vue'
import Web5 from '@/views/web5/Web5.vue'
import Fleet from '@/views/Fleet.vue'
import FipsNetworkCard from '@/views/server/FipsNetworkCard.vue'
import Web5Monitoring from '@/views/web5/Web5Monitoring.vue'
// Hoisted by vitest — must live at module scope, not inside a test body, or
// Server.vue's/Web5.vue's own module-level `import { rpcClient } from
// '@/api/rpc-client'` would already be bound to the real implementation by
// the time a test ran. A Proxy fallback covers every rpcClient method either
// real view calls (Web5.vue's onMounted/armWeb5Live() reaches several beyond
// the four Server.vue needs) without having to enumerate them all by name.
vi.mock('@/api/rpc-client', () => {
const base: Record<string, unknown> = {
call: vi.fn().mockResolvedValue({}),
vpnStatus: vi.fn().mockResolvedValue({ connected: false }),
dnsStatus: vi.fn().mockResolvedValue({ provider: 'system', resolv_conf_servers: [], doh_enabled: false }),
diskStatus: vi.fn().mockResolvedValue({ encrypted: false, warnings: [] }),
resolveDid: vi.fn().mockResolvedValue({}),
detectUsbDevices: vi.fn().mockResolvedValue({ devices: [] }),
getNodeDid: vi.fn().mockResolvedValue({}),
}
return {
rpcClient: new Proxy(base, {
get(target, prop: string) {
if (prop in target) return target[prop]
const fn = vi.fn().mockResolvedValue({})
target[prop] = fn
return fn
},
}),
}
})
vi.mock('@/stores/app', () => ({ useAppStore: () => ({ packages: {} }) }))
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
describe('keepAliveLifecycle: activate/deactivate contract (synthetic consumer)', () => {
let mountCount: number
let activateCount: number
let deactivateCount: number
let unmountCount: number
let pollCallCount: number
let subscribeCount: number
let unsubscribeCount: number
let listenerAddCount: number
let listenerRemoveCount: number
function makeSubscription() {
subscribeCount++
return () => { unsubscribeCount++ }
}
function onWindowEvent() { /* no-op handler identity for add/remove counting */ }
// Mirrors the idiom established across Home.vue/Chat.vue/Server.vue/etc:
// idempotent arm (clears/removes any existing handle first), started in
// onActivated (which Vue also fires on first mount), torn down in
// onDeactivated, with onBeforeUnmount left in place for the non-cached path.
const Consumer = defineComponent({
name: 'LifecycleConsumer',
setup() {
onMounted(() => { mountCount++ })
let pollInterval: ReturnType<typeof setInterval> | null = null
let unsubscribe: (() => void) | null = null
function arm() {
activateCount++
if (pollInterval) clearInterval(pollInterval)
pollCallCount++ // immediate invocation on (re)activation
pollInterval = setInterval(() => { pollCallCount++ }, 1000)
if (unsubscribe) unsubscribe()
unsubscribe = makeSubscription()
window.removeEventListener('resize', onWindowEvent)
window.addEventListener('resize', onWindowEvent)
listenerAddCount++
}
function disarm() {
deactivateCount++
if (pollInterval) { clearInterval(pollInterval); pollInterval = null }
if (unsubscribe) { unsubscribe(); unsubscribe = null }
window.removeEventListener('resize', onWindowEvent)
listenerRemoveCount++
}
onActivated(() => arm())
onDeactivated(() => disarm())
onBeforeUnmount(() => { disarm(); unmountCount++ })
return () => h('div', 'consumer')
},
})
const Other = defineComponent({ render: () => h('div', 'other') })
function mountHost() {
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(Consumer, { key: 'consumer' }) : h(Other, { key: 'other' }),
)
},
})
return mount(Host)
}
beforeEach(() => {
vi.useFakeTimers()
mountCount = 0; activateCount = 0; deactivateCount = 0; unmountCount = 0
pollCallCount = 0; subscribeCount = 0; unsubscribeCount = 0
listenerAddCount = 0; listenerRemoveCount = 0
})
afterEach(() => {
vi.useRealTimers()
})
it('deactivating clears the polling interval — its callback is not invoked again while off screen', async () => {
const wrapper = mountHost()
await wrapper.vm.$nextTick()
const callsAtActivation = pollCallCount
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
vi.advanceTimersByTime(5000)
expect(pollCallCount).toBe(callsAtActivation) // no further ticks while deactivated
wrapper.unmount()
})
it('reactivating restarts the interval and immediately invokes the loader once, so the first frame is not interval-stale', async () => {
const wrapper = mountHost()
await wrapper.vm.$nextTick()
const callsAtFirstActivation = pollCallCount
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
// Immediate call on reactivation, before any timer has elapsed.
expect(pollCallCount).toBe(callsAtFirstActivation + 1)
wrapper.unmount()
})
it('deactivating unsubscribes a websocket handle; reactivating re-subscribes exactly once', async () => {
const wrapper = mountHost()
await wrapper.vm.$nextTick()
expect(subscribeCount).toBe(1)
expect(unsubscribeCount).toBe(0)
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
expect(unsubscribeCount).toBe(1)
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
expect(subscribeCount).toBe(2) // re-subscribed exactly once, not twice
wrapper.unmount()
})
it('deactivating removes a window listener; reactivating adds it back exactly once', async () => {
const wrapper = mountHost()
await wrapper.vm.$nextTick()
expect(listenerAddCount).toBe(1)
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
expect(listenerRemoveCount).toBe(1)
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
expect(listenerAddCount).toBe(2) // added back exactly once, not twice
wrapper.unmount()
})
it('unmounting (a non-cached mount path) still tears everything down', async () => {
const wrapper = mountHost()
await wrapper.vm.$nextTick()
wrapper.unmount()
expect(unmountCount).toBe(1)
expect(unsubscribeCount).toBe(1)
expect(listenerRemoveCount).toBeGreaterThanOrEqual(1)
const callsAtUnmount = pollCallCount
vi.advanceTimersByTime(5000)
expect(pollCallCount).toBe(callsAtUnmount) // no interval survives real unmount
})
it('two consecutive activations never double-arm a timer, subscription or listener', async () => {
const wrapper = mountHost()
await wrapper.vm.$nextTick()
expect(activateCount).toBe(1)
expect(subscribeCount).toBe(1)
// Vue only calls onActivated on an actual (re)activation transition, so
// simulate the double-activation risk directly: arm() is idempotent by
// construction (clears/removes before re-adding), proven by driving two
// deactivate/activate round trips back-to-back with no intervening tick.
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
expect(activateCount).toBe(3) // initial + two round trips
expect(subscribeCount).toBe(3) // never more than one live subscription at a time
expect(unsubscribeCount).toBe(2) // one per deactivation, none doubled
// Only one interval is ever live: advancing time ticks it exactly once
// per 1000ms, never twice, proving no double-arm survived the round trips.
const before = pollCallCount
vi.advanceTimersByTime(1000)
expect(pollCallCount).toBe(before + 1)
wrapper.unmount()
})
it('a one-shot intro flag set on first entry is still set exactly once across three visits to the same tab', async () => {
let onceFlagSetCount = 0
const OnceConsumer = defineComponent({
name: 'OnceFlagConsumer',
setup() {
onMounted(() => { onceFlagSetCount++ }) // once-per-session bucket: onMounted only, never onActivated
return () => h('div', 'once')
},
})
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(OnceConsumer, { key: 'once' }) : h(Other, { key: 'other' }),
)
},
})
const wrapper = mount(Host)
await wrapper.vm.$nextTick()
expect(onceFlagSetCount).toBe(1)
// Visit 2
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
expect(onceFlagSetCount).toBe(1)
// Visit 3
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
expect(onceFlagSetCount).toBe(1)
wrapper.unmount()
})
it('an entry-scoped connection-timeout timer is re-armed on each re-entry and cleared on each exit, so it never fires against a stale visit', async () => {
// Mirrors Apps.vue's connectionTimer idiom exactly (arm in onActivated,
// idempotent; clear in onDeactivated; onBeforeUnmount clears too).
let timerFiredCount = 0
const ConnectionTimeoutConsumer = defineComponent({
name: 'ConnectionTimeoutConsumer',
setup() {
let timer: ReturnType<typeof setTimeout> | undefined
function arm() {
if (timer) clearTimeout(timer)
timer = setTimeout(() => { timerFiredCount++ }, 15000)
}
function disarm() {
if (timer) clearTimeout(timer)
}
onActivated(() => arm())
onDeactivated(() => disarm())
onBeforeUnmount(() => disarm())
return () => h('div', 'conn')
},
})
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(ConnectionTimeoutConsumer, { key: 'conn' }) : h(Other, { key: 'other' }),
)
},
})
const wrapper = mount(Host)
await wrapper.vm.$nextTick()
// Leave well before the 15s guard fires — it must not fire against a
// stale (now off-screen) visit.
vi.advanceTimersByTime(10000)
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
vi.advanceTimersByTime(10000) // would have fired by now if left armed
expect(timerFiredCount).toBe(0)
// Re-enter — the guard re-arms fresh; it should not fire before its own
// full 15s has elapsed again from THIS entry.
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
vi.advanceTimersByTime(10000)
expect(timerFiredCount).toBe(0)
vi.advanceTimersByTime(5000)
expect(timerFiredCount).toBe(1)
wrapper.unmount()
})
})
describe('keepAliveLifecycle: real converted view (Server.vue vpnPollInterval)', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("a real view's poll interval is not invoked while deactivated, and is invoked once on reactivation", async () => {
const Other = defineComponent({ render: () => h('div', 'other') })
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show
? h(Server, { key: 'server' })
: h(Other, { key: 'other' }),
)
},
})
const wrapper = mount(Host, {
global: {
plugins: [createPinia()],
stubs: {
QuickActionsCard: true,
TorServicesCard: true,
ServerModals: true,
FipsNetworkCard: true,
},
},
})
await flushPromises()
const vpnStatusMock = vi.mocked(rpcClient.vpnStatus)
const callsAtActivation = vpnStatusMock.mock.calls.length
expect(callsAtActivation).toBeGreaterThan(0) // immediate call on first activation
// Deactivate — the 15s poll must not keep calling vpnStatus while off
// screen, regardless of how long the deactivation lasts (armVpnPoll's
// setInterval is cleared by disarmVpnPoll on deactivate).
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
vi.advanceTimersByTime(20000)
await flushPromises()
expect(vpnStatusMock.mock.calls.length).toBe(callsAtActivation)
// Reactivate — two independent effects each call vpnStatus once here:
// armVpnPoll's immediate first tick (always fires on activation), and
// (02-06) networkRes's own onActivated revalidation, since 20s exceeds
// networkRes's explicit 10s TTL (server.network-summary, the "fast
// tier" per 02-06-SUMMARY.md) and its fetcher also calls vpnStatus.
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
await flushPromises()
expect(vpnStatusMock.mock.calls.length).toBe(callsAtActivation + 2)
wrapper.unmount()
})
})
// 02-11 gap closure: three child components/composables inside the
// KeepAlive'd Fleet/Server/Web5 subtrees armed a setInterval poll in
// onMounted and only ever disarmed it in onUnmounted/onBeforeUnmount — a
// no-op before 02-04's KeepAlive registration (the owning view was
// destroyed on every tab-away, so onUnmounted fired every time) and a
// permanent, session-long background-RPC leak afterward, invisible to
// 02-04's own audit because it grepped each top-level view file, not the
// composables/child components it delegates to. These pin the fix: no
// poll activity while deactivated, resumed on reactivation — the exact
// contract `Server.vue vpnPollInterval` above already proves for the view
// files 02-04 did audit directly.
describe('keepAliveLifecycle: 02-11 gap closure — leaked background pollers in un-audited child composables', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("Fleet.vue's useFleetData() poll (telemetry.fleet-status/-alerts) is not invoked while deactivated, and resumes on reactivation", async () => {
const Other = defineComponent({ render: () => h('div', 'other') })
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(Fleet, { key: 'fleet' }) : h(Other, { key: 'other' }),
)
},
})
const wrapper = mount(Host, {
global: {
plugins: [createPinia()],
stubs: {
FleetOverviewCards: true,
FleetNodeGrid: true,
FleetAlerts: true,
FleetNodeDetail: true,
FleetContainerMatrix: true,
BackButton: true,
},
},
})
await flushPromises()
const callMock = vi.mocked(rpcClient.call)
const fleetCallCount = () =>
callMock.mock.calls.filter(([arg]) => (arg as { method?: string }).method?.startsWith('telemetry.fleet-')).length
const callsAtActivation = fleetCallCount()
expect(callsAtActivation).toBeGreaterThan(0) // refreshAll() on the initial mount
// Deactivate — the 60s poll must not keep firing while off screen.
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
vi.advanceTimersByTime(180_000) // 3x the poll interval
await flushPromises()
expect(fleetCallCount()).toBe(callsAtActivation)
// Reactivate — armFleetPoll() re-arms the interval; no call is expected
// synchronously on activation itself (unlike Server's vpnPollInterval,
// this composable's poll has no "immediate tick" — it only ticks on the
// next 60s boundary), but a tick after reactivation must fire again.
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
await flushPromises()
vi.advanceTimersByTime(60_000)
await flushPromises()
expect(fleetCallCount()).toBeGreaterThan(callsAtActivation)
wrapper.unmount()
})
it("FipsNetworkCard.vue's 15s fips.status poll is not invoked while deactivated, and resumes on reactivation", async () => {
const Other = defineComponent({ render: () => h('div', 'other') })
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(FipsNetworkCard, { key: 'fips' }) : h(Other, { key: 'other' }),
)
},
})
const wrapper = mount(Host, { global: { plugins: [createPinia()] } })
await flushPromises()
const callMock = vi.mocked(rpcClient.call)
const fipsCallCount = () =>
callMock.mock.calls.filter(([arg]) => (arg as { method?: string }).method === 'fips.status').length
const callsAtActivation = fipsCallCount()
expect(callsAtActivation).toBeGreaterThan(0) // useCachedResource's own immediate fetch on mount
// Deactivate — the manual 15s poll must not keep calling fips.status
// while off screen, regardless of how long the deactivation lasts.
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
vi.advanceTimersByTime(45_000) // 3x the poll interval
await flushPromises()
expect(fipsCallCount()).toBe(callsAtActivation)
// Reactivate — armFipsPoll's immediate setInterval arm plus
// useCachedResource's own onActivated revalidation (stale past its 15s
// TTL) both contribute; the decisive assertion is simply "more than
// zero new calls after reactivation", not an exact count, since the two
// effects' relative ordering is an implementation detail.
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
await flushPromises()
expect(fipsCallCount()).toBeGreaterThan(callsAtActivation)
wrapper.unmount()
})
it("Web5Monitoring.vue's 30s system.stats poll is not invoked while deactivated, and resumes on reactivation", async () => {
const Other = defineComponent({ render: () => h('div', 'other') })
const Host = defineComponent({
setup() {
const show = ref(true)
return { show }
},
render() {
return h(KeepAlive, null, () =>
this.show ? h(Web5Monitoring, { key: 'web5mon' }) : h(Other, { key: 'other' }),
)
},
})
// homeStatus.ts's refreshSystemStats() assigns numeric fields straight
// from the RPC response (e.g. `stats.cpuPercent = res.cpu_usage_percent`)
// with no fallback — the file-wide default `call` mock resolving `{}`
// leaves `cpuPercent` as `undefined`, and the template's `.toFixed(0)`
// throws. Scoped to this test only (restored to the file-wide default in
// reverse order after) so no other test's call-count assertions shift.
const callMock = vi.mocked(rpcClient.call)
const priorImpl = callMock.getMockImplementation()
callMock.mockImplementation(async (arg: unknown) => {
const method = (arg as { method?: string }).method
if (method === 'system.stats') {
return {
cpu_usage_percent: 12,
mem_used_bytes: 1,
mem_total_bytes: 2,
disk_used_bytes: 1,
disk_total_bytes: 2,
uptime_secs: 100,
}
}
return {}
})
const wrapper = mount(Host, {
global: {
plugins: [createPinia()],
stubs: { RouterLink: true },
},
})
await flushPromises()
const statsCallCount = () =>
callMock.mock.calls.filter(([arg]) => (arg as { method?: string }).method === 'system.stats').length
const callsAtActivation = statsCallCount()
expect(callsAtActivation).toBeGreaterThan(0) // loadStats() on the initial mount
// Deactivate — the 30s poll must not keep calling system.stats while
// off screen (previously ran forever once Web5 was first visited).
;(wrapper.vm as unknown as { show: boolean }).show = false
await wrapper.vm.$nextTick()
vi.advanceTimersByTime(90_000) // 3x the poll interval
await flushPromises()
expect(statsCallCount()).toBe(callsAtActivation)
// Reactivate — armWeb5MonitoringPoll() calls loadStats() immediately.
;(wrapper.vm as unknown as { show: boolean }).show = true
await wrapper.vm.$nextTick()
await flushPromises()
expect(statsCallCount()).toBeGreaterThan(callsAtActivation)
wrapper.unmount()
// Restore the file-wide default so later tests' `call` behavior is
// unaffected — never `mockReset()`, which would also wipe the queued
// implementation the top-level `vi.mock('@/api/rpc-client', ...)`
// factory set up, leaving `call()` return `undefined` instead of a
// resolved promise for every test that runs after this one.
callMock.mockImplementation(priorImpl ?? (async () => ({})))
})
})
describe('keepAliveRoutes: widened registration set (02-04)', () => {
it('shouldKeepAlive returns true for every registered main-tab path and false for every detail path, including detail paths whose prefix matches a registered path', () => {
expect(shouldKeepAlive({ path: '/dashboard/apps' })).toBe(true)
expect(shouldKeepAlive({ path: '/dashboard/apps/bitcoin' })).toBe(false)
expect(shouldKeepAlive({ path: '/dashboard/marketplace' })).toBe(true)
expect(shouldKeepAlive({ path: '/dashboard/marketplace/x' })).toBe(false)
expect(shouldKeepAlive({ path: '/dashboard/cloud' })).toBe(true)
expect(shouldKeepAlive({ path: '/dashboard/cloud/x' })).toBe(false)
expect(shouldKeepAlive({ path: '/dashboard/server' })).toBe(true)
expect(shouldKeepAlive({ path: '/dashboard/server/openwrt' })).toBe(false)
expect(shouldKeepAlive({ path: '/dashboard/web5' })).toBe(true)
expect(shouldKeepAlive({ path: '/dashboard/web5/credentials' })).toBe(false)
// Settings is in TAB_ORDER but withheld (unaudited child sections) —
// registering it is neither required nor safe yet (see keepAliveRoutes.ts).
expect(shouldKeepAlive({ path: '/dashboard/settings' })).toBe(false)
expect(shouldKeepAlive({ path: '/dashboard/settings/update' })).toBe(false)
// Discover (App Store's second tab) is not in TAB_ORDER but is registered
// explicitly — measured `remount storm` in 02-FINDINGS.md.
expect(shouldKeepAlive({ path: '/dashboard/discover' })).toBe(true)
})
it('registers every TAB_ORDER path measured Remounted:true or unmeasured, per the literal exclusion rule (only a measured Remounted:false excludes)', () => {
for (const path of ['/dashboard', '/dashboard/apps', '/dashboard/marketplace', '/dashboard/cloud', '/dashboard/mesh', '/dashboard/server', '/dashboard/web5', '/dashboard/fleet', '/dashboard/chat']) {
expect(shouldKeepAlive({ path })).toBe(true)
}
})
it('visiting more distinct registered tabs than KEEP_ALIVE_MAX leaves exactly KEEP_ALIVE_MAX instances resident — the least recently used is evicted', async () => {
const paths = Array.from(KEEP_ALIVE_PATHS).slice(0, KEEP_ALIVE_MAX + 2)
expect(paths.length).toBe(KEEP_ALIVE_MAX + 2)
const firstPath = paths[0]
if (!firstPath) throw new Error('expected at least one registered path')
const remainingPaths = paths.slice(1)
const mountCounts: Record<string, number> = {}
const routes: RouteRecordRaw[] = paths.map((path) => {
mountCounts[path] = 0
const Stub = defineComponent({
name: `EvictionStub:${path}`,
setup() {
onMounted(() => { mountCounts[path] = (mountCounts[path] ?? 0) + 1 })
return () => h('div', { class: 'eviction-stub' }, path)
},
})
return { path, component: Stub }
})
const router = createRouter({ history: createMemoryHistory(), routes })
router.push(firstPath)
await router.isReady()
const wrapper = mount(DashboardRouterView, {
props: { mobileTabPaddingTop: null, needsMobileBackButtonSpace: false },
global: { plugins: [router] },
})
await flushPromises()
expect(mountCounts[firstPath]).toBe(1)
// Visit every remaining registered path in order — KEEP_ALIVE_MAX + 1
// more distinct instances, so the cache (capped at KEEP_ALIVE_MAX) must
// evict the least-recently-used one (firstPath) to stay within budget.
for (const path of remainingPaths) {
await router.push(path)
await flushPromises()
}
expect(mountCounts[firstPath]).toBe(1) // not yet revisited — still just the one mount
// Revisiting the evicted path proves eviction: it remounts (count -> 2)
// rather than merely reactivating (which would leave the count at 1).
await router.push(firstPath)
await flushPromises()
expect(mountCounts[firstPath]).toBe(2)
wrapper.unmount()
})
})
// 02-09 gap closure: Task 1 named the cause of the apparent "Server.vue
// genuinely remounts" gap as a proven probe-measurement artifact (see
// 02-FINDINGS.md's "## Server KeepAlive Root Cause (gap closure)" section)
// rather than a real KeepAlive/lifecycle defect — an authoritative,
// independent `document.elementFromPoint()` hit-test signal on the deployed
// a test node build directly contradicted the naive selector-match probe's
// "remounted" verdict for both Server.vue and Web5.vue, and a companion
// diagnostic found the ORIGINAL stamped root still connected and visible
// under a different (unpicked) DOM match. No source change to
// DashboardRouterView.vue / dashboardViewWrappers.ts / keepAliveRoutes.ts /
// Server.vue's KeepAlive/lifecycle wiring is made — these tests pin the
// architecture's CURRENT, unmodified behavior as the regression guard Task
// 2's `<behavior>` calls for, using Vue's own component-instance identity
// (`vm.$.uid`) rather than a CSS selector, which is exactly the class of
// signal that sidesteps the generic-`.view-container`-selector ambiguity
// Task 1 found responsible for the false "remounted" reading in the first
// place.
describe('keepAliveLifecycle: 02-09 gap closure — Server/Web5 round-trip survival, measured via component-instance identity (not a CSS selector)', () => {
function mountRealRouterView(startPath: string) {
const SettingsStub = defineComponent({ name: 'SettingsStub', render: () => h('div', 'settings') })
const routes: RouteRecordRaw[] = [
{ path: '/dashboard/server', name: 'server', component: Server },
{ path: '/dashboard/web5', name: 'web5', component: Web5 },
{ path: '/dashboard/settings', name: 'settings', component: SettingsStub },
]
const router = createRouter({ history: createMemoryHistory(), routes })
router.push(startPath)
return { router, routes }
}
it('Test 1 (the gap): a round-trip through /dashboard/server mounts the real Server.vue exactly once — the second arrival reactivates, not remounts', async () => {
const { router } = mountRealRouterView('/dashboard/server')
await router.isReady()
const wrapper = mount(DashboardRouterView, {
props: { mobileTabPaddingTop: null, needsMobileBackButtonSpace: false },
global: {
plugins: [router, createPinia()],
stubs: {
QuickActionsCard: true,
TorServicesCard: true,
ServerModals: true,
FipsNetworkCard: true,
},
},
})
await flushPromises()
const firstInstance = wrapper.findComponent(Server)
expect(firstInstance.exists()).toBe(true)
const firstUid = firstInstance.vm.$.uid
await router.push('/dashboard/settings')
await flushPromises()
await router.push('/dashboard/server')
await flushPromises()
const secondInstance = wrapper.findComponent(Server)
expect(secondInstance.exists()).toBe(true)
// Same instance uid == exactly one Server.vue instance ever existed for
// this round trip (a genuine remount would create a second, higher uid).
expect(secondInstance.vm.$.uid).toBe(firstUid)
wrapper.unmount()
})
it('Test 2 (no collateral damage): the same round-trip through Web5.vue (the other surface this gap closure re-examined) and a synthetic second tab both keep their instance/mount counts at 1', async () => {
let appsMountCount = 0
const AppsStubWithCounter = defineComponent({
name: 'AppsStubCounter',
setup() {
onMounted(() => { appsMountCount++ })
return () => h('div', 'apps')
},
})
const SettingsStub = defineComponent({ name: 'SettingsStub', render: () => h('div', 'settings') })
const routes: RouteRecordRaw[] = [
{ path: '/dashboard/web5', name: 'web5', component: Web5 },
{ path: '/dashboard/apps', name: 'apps', component: AppsStubWithCounter },
{ path: '/dashboard/settings', name: 'settings', component: SettingsStub },
]
const router = createRouter({ history: createMemoryHistory(), routes })
router.push('/dashboard/web5')
await router.isReady()
// Web5.vue's armWeb5Live() (its onActivated/onMounted arm function, the
// same idiom Server.vue's armServerEntryEffects() uses) calls exposed
// methods on child refs directly — a bare `stubs: { X: true }` auto-stub
// doesn't expose anything, so it throws under test. Real, minimal expose
// stubs (no-op bodies) let armWeb5Live() run exactly as it does against
// the real children, without needing their full implementations.
const ConnectedNodesStub = defineComponent({
name: 'Web5ConnectedNodesStub',
setup(_props, { expose }) {
expose({ loadPeers: () => {}, loadReceivedMessages: () => {}, loadConnectionRequests: () => {} })
return () => h('div')
},
})
const NodeVisibilityStub = defineComponent({
name: 'Web5NodeVisibilityStub',
setup(_props, { expose }) {
expose({ loadVisibility: () => {} })
return () => h('div')
},
})
const IdentitiesStub = defineComponent({
name: 'Web5IdentitiesStub',
setup(_props, { expose }) {
expose({ loadIdentities: () => {} })
return () => h('div')
},
})
const NostrRelaysStub = defineComponent({
name: 'Web5NostrRelaysStub',
setup(_props, { expose }) {
expose({ loadNostrRelays: () => {}, openRelaysModal: () => {} })
return () => h('div')
},
})
const wrapper = mount(DashboardRouterView, {
props: { mobileTabPaddingTop: null, needsMobileBackButtonSpace: false },
global: {
plugins: [router, createPinia()],
stubs: {
Web5QuickActions: true,
Web5ConnectedNodes: ConnectedNodesStub,
Web5NodeVisibility: NodeVisibilityStub,
Web5Identities: IdentitiesStub,
Web5NostrRelays: NostrRelaysStub,
Web5Monitoring: true,
Web5Federation: true,
},
},
})
await flushPromises()
const firstWeb5 = wrapper.findComponent(Web5)
expect(firstWeb5.exists()).toBe(true)
const firstWeb5Uid = firstWeb5.vm.$.uid
await router.push('/dashboard/apps')
await flushPromises()
expect(appsMountCount).toBe(1)
await router.push('/dashboard/settings')
await flushPromises()
await router.push('/dashboard/web5')
await flushPromises()
expect(wrapper.findComponent(Web5).vm.$.uid).toBe(firstWeb5Uid) // Web5 reactivated, not remounted
await router.push('/dashboard/settings')
await flushPromises()
await router.push('/dashboard/apps')
await flushPromises()
expect(appsMountCount).toBe(1) // still just the one mount — Apps reactivated too
wrapper.unmount()
})
it('Test 3 (registration is really the include list): every keepAliveIncludeNames() entry is a KeepWrap:<path> name for a registered path, contains no comma (Vue\'s own include-matching splits only on commas), and /dashboard/server\'s wrapper name is among them', () => {
const names = keepAliveIncludeNames()
expect(names.length).toBe(KEEP_ALIVE_PATHS.size)
for (const name of names) {
expect(name.startsWith(KEEP_WRAP_PREFIX)).toBe(true)
const path = name.slice(KEEP_WRAP_PREFIX.length)
expect(KEEP_ALIVE_PATHS.has(path)).toBe(true)
expect(name.includes(',')).toBe(false)
}
expect(names).toContain(`${KEEP_WRAP_PREFIX}/dashboard/server`)
expect(names).toContain(`${KEEP_WRAP_PREFIX}/dashboard/web5`)
})
it('Test 4 (the bound stays bound): shouldKeepAlive/KEEP_ALIVE_MAX are unchanged by this gap closure — Server and Web5 are still registered, still evictable, and the cap is still 6', () => {
expect(shouldKeepAlive({ path: '/dashboard/server' })).toBe(true)
expect(shouldKeepAlive({ path: '/dashboard/web5' })).toBe(true)
expect(KEEP_ALIVE_MAX).toBe(6)
})
})
@@ -0,0 +1,178 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createMemoryHistory, createRouter, type RouteRecordRaw } from 'vue-router'
import { defineComponent, h, onActivated, onMounted } from 'vue'
import { beforeEach, describe, expect, it } from 'vitest'
import DashboardRouterView from '../DashboardRouterView.vue'
import { shouldKeepAlive } from '../keepAliveRoutes'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
let mountCount = 0
let activateCount = 0
let detailMountCount = 0
const KeptAliveStub = defineComponent({
name: 'KeptAliveStub',
setup() {
onMounted(() => { mountCount++ })
onActivated(() => { activateCount++ })
return () => h('div', { class: 'kept-alive-stub' }, 'kept-alive')
},
})
const DetailStub = defineComponent({
name: 'DetailStub',
setup() {
onMounted(() => { detailMountCount++ })
return () => h('div', { class: 'detail-stub' }, 'detail')
},
})
const OtherStub = defineComponent({
name: 'OtherStub',
setup() {
return () => h('div', { class: 'other-stub' }, 'other')
},
})
function makeRouter() {
const routes: RouteRecordRaw[] = [
{ path: '/dashboard/marketplace', component: KeptAliveStub },
{ path: '/dashboard/marketplace/:id', component: DetailStub },
{ path: '/dashboard/other', component: OtherStub },
]
return createRouter({ history: createMemoryHistory(), routes })
}
describe('DashboardRouterView keep-alive behavior', () => {
beforeEach(() => {
mountCount = 0
activateCount = 0
detailMountCount = 0
})
it('keeps the instance alive across a round trip for an included path', async () => {
const router = makeRouter()
router.push('/dashboard/marketplace')
await router.isReady()
const wrapper = mount(DashboardRouterView, {
props: { mobileTabPaddingTop: null, needsMobileBackButtonSpace: false },
global: { plugins: [router] },
})
await flushPromises()
expect(mountCount).toBe(1)
await router.push('/dashboard/other')
await flushPromises()
await router.push('/dashboard/marketplace')
await flushPromises()
expect(mountCount).toBe(1) // never remounted
expect(activateCount).toBe(2) // initial mount + the round-trip reactivation
wrapper.unmount()
})
it('remounts a detail path on every visit (not instance-cached)', async () => {
const router = makeRouter()
router.push('/dashboard/marketplace/abc')
await router.isReady()
const wrapper = mount(DashboardRouterView, {
props: { mobileTabPaddingTop: null, needsMobileBackButtonSpace: false },
global: { plugins: [router] },
})
await flushPromises()
expect(detailMountCount).toBe(1)
await router.push('/dashboard/other')
await flushPromises()
await router.push('/dashboard/marketplace/abc')
await flushPromises()
expect(detailMountCount).toBe(2)
wrapper.unmount()
})
it('shouldKeepAlive returns false for a detail path whose prefix matches an included path', () => {
expect(shouldKeepAlive({ path: '/dashboard/marketplace' })).toBe(true)
expect(shouldKeepAlive({ path: '/dashboard/marketplace/abc' })).toBe(false)
})
// Visual contract (Task 3 checkpoint regression): the padded default-shape
// wrapper must render INSIDE `div.view-wrapper`, never the other way around.
// dashboard-styles.css scopes every transition as a compound selector
// (`.slide-up-enter-active.view-wrapper`, …) and `.view-wrapper` is
// `absolute inset-0` — if `view-wrapper` ever moves onto the view root
// inside the padded wrapper, page margins break and the slide/depth
// transitions stop firing. This test pins the structure so that regression
// cannot silently return.
it('renders the padded default wrapper inside div.view-wrapper', async () => {
const router = makeRouter()
router.push('/dashboard/other')
await router.isReady()
const wrapper = mount(DashboardRouterView, {
props: { mobileTabPaddingTop: null, needsMobileBackButtonSpace: false },
global: { plugins: [router] },
})
await flushPromises()
const viewWrapper = wrapper.find('.view-wrapper')
expect(viewWrapper.exists()).toBe(true)
// view-wrapper is the bare transition surface — no padding classes on it.
expect(viewWrapper.classes()).not.toContain('px-4')
expect(viewWrapper.classes()).not.toContain('view-container')
// Its first child is the padded scroll container carrying the page margins.
const padded = viewWrapper.element.firstElementChild as HTMLElement
expect(padded).not.toBeNull()
for (const cls of [
'absolute', 'inset-0', 'px-4', 'pt-4', 'md:pt-8', 'md:px-8',
'overflow-y-auto', 'mobile-safe-top', 'dashboard-scroll-panel', 'mobile-scroll-pad',
]) {
expect(padded.classList.contains(cls), `padded wrapper missing ${cls}`).toBe(true)
}
// The routed view sits inside the padded wrapper with its per-component
// classes, followed by the scroll-clearance spacer.
const view = wrapper.find('.other-stub')
expect(view.exists()).toBe(true)
expect(view.classes()).toContain('view-container')
expect(view.classes()).toContain('flex-none')
expect(padded.contains(view.element)).toBe(true)
const spacer = padded.querySelector('[aria-hidden="true"]')
expect(spacer).not.toBeNull()
expect(spacer!.classList.contains('shrink-0')).toBe(true)
wrapper.unmount()
})
})
describe('RefreshIndicator', () => {
it('renders nothing for ready, idle and loading, and a labeled status element for refreshing', () => {
const states = ['ready', 'idle', 'loading'] as const
for (const state of states) {
const wrapper = mount(RefreshIndicator, { props: { state } })
expect(wrapper.find('[role="status"]').exists()).toBe(false)
wrapper.unmount()
}
const refreshing = mount(RefreshIndicator, { props: { state: 'refreshing', label: 'Refreshing things' } })
const status = refreshing.find('[role="status"]')
expect(status.exists()).toBe(true)
expect(status.attributes('aria-live')).toBe('polite')
expect(refreshing.text()).toContain('Refreshing things')
refreshing.unmount()
})
it('falls back to a non-empty default accessible label when none is given', () => {
const wrapper = mount(RefreshIndicator, { props: { state: 'refreshing' } })
expect(wrapper.find('[role="status"]').text().length).toBeGreaterThan(0)
wrapper.unmount()
})
})
@@ -0,0 +1,982 @@
/* Dashboard animations and transitions
* Extracted from Dashboard.vue — 2advanced-style cinematic motion system
*/
/* Background - zoom in from depth with motion blur */
.zoom-reveal-bg {
animation: zoom-reveal 2.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
transform-origin: center center;
opacity: 0;
transform: scale(0.15);
filter: blur(24px);
}
@keyframes zoom-reveal {
0% {
opacity: 0;
transform: scale(0.15);
filter: blur(24px);
}
35% {
opacity: 0.5;
transform: scale(0.5);
filter: blur(20px);
}
65% {
opacity: 0.85;
transform: scale(0.88);
filter: blur(6px);
}
100% {
opacity: 1;
transform: scale(1);
filter: blur(0);
}
}
/* 2advanced-style glass assembly - fluid, layered, deliberate timing */
.glass-throw-active {
perspective: 1400px;
}
.glass-piece {
will-change: transform, opacity;
}
/* Sidebar - animates in at end with separate parts (like cards) */
.sidebar-shell {
width: 100%;
height: 100%;
min-height: 0;
background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border-right: 1px solid transparent;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.sidebar-animate .sidebar-shell {
animation: sidebar-shell-fly 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
animation-delay: 5.2s;
opacity: 0;
transform: translateX(-100%);
}
@keyframes sidebar-shell-fly {
0% {
opacity: 0;
transform: translateX(-100%);
border-color: transparent;
}
70% {
opacity: 1;
transform: translateX(0);
border-color: transparent;
}
100% {
opacity: 1;
transform: translateX(0);
border-color: rgba(255, 255, 255, 0.18);
}
}
.sidebar-inner {
overflow: hidden;
}
.sidebar-nav {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.24) transparent;
}
.sidebar-nav::-webkit-scrollbar {
width: 6px;
}
.sidebar-nav::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.22);
border-radius: 999px;
}
.sidebar-bottom {
background: linear-gradient(to top, rgba(0, 0, 0, 0.18), transparent 100%);
}
/* Only hide sidebar content when doing the login entrance animation */
.sidebar-animate .sidebar-inner {
opacity: 0;
animation: sidebar-inner-draw 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
animation-delay: 6.1s;
}
@keyframes sidebar-inner-draw {
0% {
opacity: 0;
clip-path: inset(0 100% 0 0);
}
20% { opacity: 1; }
100% {
opacity: 1;
clip-path: inset(0 0 0 0);
}
}
.sidebar-nav-item {
opacity: 0;
transform: translateX(-12px);
}
.sidebar-animate .sidebar-nav-item {
animation: sidebar-nav-item-in 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
animation-delay: calc(6.3s + var(--nav-stagger, 0) * 0.06s);
}
@keyframes sidebar-nav-item-in {
0% {
opacity: 0;
transform: translateX(-12px);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
.sidebar-controller {
opacity: 0;
}
.sidebar-animate .sidebar-controller {
animation: sidebar-fade-in 0.4s ease-out forwards;
animation-delay: 6.9s;
}
.sidebar-logout-btn {
opacity: 0;
transform: scale(0.95);
}
.sidebar-animate .sidebar-logout-btn {
animation: sidebar-logout-pop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
animation-delay: 7.1s;
}
@keyframes sidebar-fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes sidebar-logout-pop {
0% {
opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.sidebar-logo {
opacity: 0;
transform: translateY(-8px);
}
.sidebar-animate .sidebar-logo {
animation: sidebar-logo-in 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
animation-delay: 6.15s;
}
@keyframes sidebar-logo-in {
0% {
opacity: 0;
transform: translateY(-8px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
/* When not animating, show everything (direct load / hard refresh) */
aside:not(.sidebar-animate) .sidebar-shell {
border-color: rgba(255, 255, 255, 0.18);
opacity: 1;
transform: none;
}
aside:not(.sidebar-animate) .sidebar-inner,
aside:not(.sidebar-animate) .sidebar-logo,
aside:not(.sidebar-animate) .sidebar-nav-item,
aside:not(.sidebar-animate) .sidebar-controller,
aside:not(.sidebar-animate) .sidebar-logout-btn {
opacity: 1;
transform: none;
animation: none;
clip-path: none;
}
/* Glass throw animations — smooth easeInOut, no overshoot */
.glass-throw-main {
animation: glass-throw-main 1.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.15s forwards;
opacity: 0;
transform: translateX(20%) scale(0.2);
filter: blur(14px);
}
.glass-throw-content {
animation: glass-throw-content 1.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.22s forwards;
opacity: 0;
transform: translateY(12%) scale(0.25);
filter: blur(10px);
}
.glass-throw-mobile-tabs {
animation: glass-throw-top 1.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.08s forwards;
opacity: 0;
transform: translateY(-90%) scale(0.28);
filter: blur(10px);
}
.glass-throw-mobile-tabs-2 {
animation: glass-throw-top 1.35s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.18s forwards;
opacity: 0;
transform: translateY(-90%) scale(0.28);
filter: blur(10px);
}
.glass-throw-tabbar {
animation: glass-throw-bottom 1.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.2s forwards;
opacity: 0;
transform: translateY(85%) scale(0.25);
filter: blur(10px);
}
@keyframes glass-throw-sidebar {
0% {
opacity: 0;
transform: translateX(-100%) scale(0.25);
filter: blur(12px);
}
45% {
opacity: 0.9;
transform: translateX(-15%) scale(0.85);
filter: blur(8px);
}
100% {
opacity: 1;
transform: translateX(0) scale(1);
filter: blur(0);
}
}
@keyframes glass-throw-main {
0% {
opacity: 0;
transform: translateX(20%) scale(0.2);
filter: blur(14px);
}
50% {
opacity: 0.85;
transform: translateX(0) scale(0.9);
filter: blur(6px);
}
100% {
opacity: 1;
transform: translateX(0) scale(1);
filter: blur(0);
}
}
@keyframes glass-throw-content {
0% {
opacity: 0;
transform: translateY(12%) scale(0.25);
filter: blur(10px);
}
50% {
opacity: 0.9;
transform: translateY(0) scale(0.9);
filter: blur(4px);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
@keyframes glass-throw-top {
0% {
opacity: 0;
transform: translateY(-90%) scale(0.28);
filter: blur(10px);
}
50% {
opacity: 0.9;
transform: translateY(0) scale(0.95);
filter: blur(4px);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
@keyframes glass-throw-bottom {
0% {
opacity: 0;
transform: translateY(85%) scale(0.25);
filter: blur(10px);
}
50% {
opacity: 0.9;
transform: translateY(0) scale(0.95);
filter: blur(4px);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
/* Oomph accent - subtle flash synced with boot thud */
.oomph-flash {
background: radial-gradient(ellipse at center, rgba(255, 255, 255, 0.08) 0%, transparent 65%);
animation: oomph-flash 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
@keyframes oomph-flash {
0% { opacity: 0; }
25% { opacity: 0.9; }
100% { opacity: 0; }
}
/* Reveal flashes - enthralling entrance during zoom */
.reveal-flash-glitch {
background: radial-gradient(ellipse at center, rgba(255, 255, 255, 0.12) 0%, transparent 70%);
animation: reveal-flash-sequence 2.8s ease-out forwards;
}
@keyframes reveal-flash-sequence {
0% { opacity: 0; }
12% { opacity: 0.6; }
18% { opacity: 0; }
42% { opacity: 0.4; }
48% { opacity: 0; }
70% { opacity: 0.35; }
78% { opacity: 0; }
100% { opacity: 0; }
}
/* Panel mode app session */
.app-panel-container {
position: absolute;
inset: 0;
z-index: 100;
}
.panel-slide-enter-active {
transition: opacity 0.25s ease;
}
.panel-slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.panel-slide-enter-from {
opacity: 0;
}
.panel-slide-leave-to {
transform: translateX(40px) scale(0.97);
opacity: 0;
}
/* Perspective container for 3D depth effect */
.perspective-container-wrapper {
position: relative;
overflow: hidden;
height: 100%;
}
.perspective-container {
perspective: 2000px;
perspective-origin: 50% 50%;
position: relative;
height: 100%;
overflow: hidden;
}
/* View wrapper — smooth transitions with absolute positioning */
.view-wrapper {
position: absolute;
inset: 0;
/* preserve-3d + backface-visibility only during transitions (applied by
transition classes below). Keeping them always-on causes Chromium to skip
painting cards that start below the viewport — they appear as transparent
ghost rectangles when scrolled into view. */
will-change: transform, opacity;
opacity: 1;
}
.view-container {
/* No forced height — content sizes naturally, spacer below provides clearance */
}
/* Forward transition: 2advanced fluid depth */
.depth-forward-enter-active.view-wrapper,
.depth-forward-leave-active.view-wrapper {
transition: all 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform-style: preserve-3d;
backface-visibility: hidden;
}
.depth-forward-enter-from.view-wrapper {
opacity: 0;
transform: translateZ(-800px) scale(0.75);
filter: blur(4px);
}
.depth-forward-enter-to.view-wrapper {
opacity: 1;
transform: translateZ(0) scale(1);
filter: blur(0px);
}
.depth-forward-leave-from.view-wrapper {
opacity: 1;
transform: translateZ(0) scale(1);
filter: blur(0px);
}
.depth-forward-leave-to.view-wrapper {
opacity: 0;
transform: translateZ(400px) scale(1.2);
filter: blur(8px);
}
/* Back transition: 2advanced fluid depth */
.depth-back-enter-active.view-wrapper,
.depth-back-leave-active.view-wrapper {
transition: all 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform-style: preserve-3d;
backface-visibility: hidden;
}
.depth-back-enter-from.view-wrapper {
opacity: 0;
transform: translateZ(400px) scale(1.2);
filter: blur(8px);
}
.depth-back-enter-to.view-wrapper {
opacity: 1;
transform: translateZ(0) scale(1);
filter: blur(0px);
}
.depth-back-leave-from.view-wrapper {
opacity: 1;
transform: translateZ(0) scale(1);
filter: blur(0px);
}
.depth-back-leave-to.view-wrapper {
opacity: 0;
transform: translateZ(-800px) scale(0.75);
filter: blur(4px);
}
/* Subtle 3D tilt - 2advanced layered depth (desktop only) */
@media (min-width: 768px) {
.depth-forward-enter-from.view-wrapper {
transform: translateZ(-800px) scale(0.75) rotateX(5deg);
}
.depth-forward-leave-to.view-wrapper {
transform: translateZ(400px) scale(1.2) rotateX(-4deg);
}
.depth-back-enter-from.view-wrapper {
transform: translateZ(400px) scale(1.2) rotateX(-4deg);
}
.depth-back-leave-to.view-wrapper {
transform: translateZ(-800px) scale(0.75) rotateX(5deg);
}
}
/* Chat open transition — chat slides in from left */
.chat-open-enter-active.view-wrapper,
.chat-open-leave-active.view-wrapper {
transition: opacity 0.5s cubic-bezier(0.22, 1, 0.36, 1), transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
transform-style: preserve-3d;
backface-visibility: hidden;
}
.chat-open-enter-from.view-wrapper {
opacity: 0;
transform: translateX(-60px) scale(0.96);
}
.chat-open-enter-to.view-wrapper {
opacity: 1;
transform: translateX(0) scale(1);
}
.chat-open-leave-from.view-wrapper {
opacity: 1;
transform: translateX(0) scale(1);
}
.chat-open-leave-to.view-wrapper {
opacity: 0;
transform: translateX(60px) scale(0.96);
}
/* Chat close transition — chat slides out to left */
.chat-close-enter-active.view-wrapper,
.chat-close-leave-active.view-wrapper {
transition: opacity 0.5s cubic-bezier(0.22, 1, 0.36, 1), transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
transform-style: preserve-3d;
backface-visibility: hidden;
}
.chat-close-enter-from.view-wrapper {
opacity: 0;
transform: translateX(60px) scale(0.96);
}
.chat-close-enter-to.view-wrapper {
opacity: 1;
transform: translateX(0) scale(1);
}
.chat-close-leave-from.view-wrapper {
opacity: 1;
transform: translateX(0) scale(1);
}
.chat-close-leave-to.view-wrapper {
opacity: 0;
transform: translateX(-60px) scale(0.96);
}
/* Fade transition for initial loads and default cases */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
}
/* Mobile: Slide left transition (Apps -> Marketplace) */
.slide-left-enter-active.view-wrapper,
.slide-left-leave-active.view-wrapper {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
}
.slide-left-enter-from.view-wrapper {
transform: translateX(100%);
opacity: 0;
}
.slide-left-enter-to.view-wrapper {
transform: translateX(0);
opacity: 1;
}
.slide-left-leave-from.view-wrapper {
transform: translateX(0);
opacity: 1;
}
.slide-left-leave-to.view-wrapper {
transform: translateX(-100%);
opacity: 0;
}
/* Mobile: Slide right transition (Marketplace -> Apps) */
.slide-right-enter-active.view-wrapper,
.slide-right-leave-active.view-wrapper {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
}
.slide-right-enter-from.view-wrapper {
transform: translateX(-100%);
opacity: 0;
}
.slide-right-enter-to.view-wrapper {
transform: translateX(0);
opacity: 1;
}
.slide-right-leave-from.view-wrapper {
transform: translateX(0);
opacity: 1;
}
.slide-right-leave-to.view-wrapper {
transform: translateX(100%);
opacity: 0;
}
/* Slide down: Moving down the menu (content slides up like a scroll) */
.slide-down-enter-active.view-wrapper {
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.slide-down-leave-active.view-wrapper {
transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-down-enter-from.view-wrapper {
opacity: 0;
transform: translateY(40vh);
}
.slide-down-enter-to.view-wrapper {
opacity: 1;
transform: translateY(0);
}
.slide-down-leave-from.view-wrapper {
opacity: 1;
transform: translateY(0);
}
.slide-down-leave-to.view-wrapper {
opacity: 0;
transform: translateY(-30vh);
}
/* Slide up: Moving up the menu (content slides down like a scroll) */
.slide-up-enter-active.view-wrapper {
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.slide-up-leave-active.view-wrapper {
transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-up-enter-from.view-wrapper {
opacity: 0;
transform: translateY(-40vh);
}
.slide-up-enter-to.view-wrapper {
opacity: 1;
transform: translateY(0);
}
.slide-up-leave-from.view-wrapper {
opacity: 1;
transform: translateY(0);
}
.slide-up-leave-to.view-wrapper {
opacity: 0;
transform: translateY(30vh);
}
/* Background 3D container - full width, black fill during zoom */
.dashboard-view .bg-perspective-container {
position: fixed;
inset: 0;
z-index: -10;
perspective: 1000px;
perspective-origin: 50% 50%;
overflow: hidden;
left: 0 !important;
right: 0 !important;
width: 100% !important;
min-width: 100% !important;
background: #000;
}
/* Background layers with 3D transitions */
.dashboard-view .bg-layer {
position: absolute;
inset: 0;
background-size: cover !important;
background-position: center center !important;
background-repeat: no-repeat !important;
transition: all 0.45s cubic-bezier(0.68, -0.55, 0.265, 1.55);
transform-style: preserve-3d;
will-change: transform, opacity;
}
/* Default state - bg-intro visible, bg-intro-3 hidden back */
.dashboard-view .bg-layer:first-of-type {
opacity: 1;
transform: translateZ(0) scale(1);
}
.dashboard-view .bg-layer:nth-of-type(2) {
opacity: 0;
transform: translateZ(-200px) scale(0.9) rotateY(-15deg);
}
/* Transitioning out - current background moves away with zoom */
.dashboard-view .bg-layer.bg-transitioning-out {
opacity: 0;
transform: translateZ(200px) scale(1.15) rotateY(15deg) !important;
}
/* Transitioning in - new background comes forward with zoom */
.dashboard-view .bg-layer.bg-transitioning-in {
opacity: 1;
transform: translateZ(0) scale(1.05) rotateY(0deg) !important;
}
/* Kiosk: chromium runs software-composited (--in-process-gpu or
--disable-gpu, single raster thread). 3D-transformed will-change layers
routinely fail to repaint there after the first background swap, leaving
the container's black fill on screen. Flatten the stack to plain 2D
opacity crossfades in kiosk mode. */
html.kiosk-mode .dashboard-view .bg-perspective-container {
perspective: none;
}
html.kiosk-mode .dashboard-view .bg-layer {
transform: none !important;
transform-style: flat;
will-change: auto;
transition: opacity 0.45s ease;
}
/* Kiosk: keep tab changes animated with cheap 2D moves. The 3D depth
transitions (translateZ + blur + preserve-3d) drop frames / paint black
under the software compositor, and the onboarding-era blanket
`transform: none` on .view-wrapper killed tab motion entirely. Plain 2D
scale + opacity composites fine there. */
html.kiosk-mode .depth-forward-enter-active.view-wrapper,
html.kiosk-mode .depth-forward-leave-active.view-wrapper,
html.kiosk-mode .depth-back-enter-active.view-wrapper,
html.kiosk-mode .depth-back-leave-active.view-wrapper {
transition: transform 0.45s ease, opacity 0.45s ease;
transform-style: flat;
backface-visibility: visible;
filter: none !important;
}
html.kiosk-mode .depth-forward-enter-from.view-wrapper,
html.kiosk-mode .depth-back-leave-to.view-wrapper {
transform: scale(0.94);
filter: none !important;
}
html.kiosk-mode .depth-forward-leave-to.view-wrapper,
html.kiosk-mode .depth-back-enter-from.view-wrapper {
transform: scale(1.05);
filter: none !important;
}
html.kiosk-mode .depth-forward-enter-to.view-wrapper,
html.kiosk-mode .depth-forward-leave-from.view-wrapper,
html.kiosk-mode .depth-back-enter-to.view-wrapper,
html.kiosk-mode .depth-back-leave-from.view-wrapper {
transform: scale(1);
filter: none !important;
}
/* Background glitch effect layers - World Fair style */
.bg-glitch-layer-1,
.bg-glitch-layer-2,
.bg-glitch-scan {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 10;
opacity: 0;
}
.bg-glitch-layer-1 {
background-size: cover;
background-position: center;
mix-blend-mode: lighten;
filter: brightness(1.8) contrast(2) saturate(1.5) hue-rotate(180deg);
will-change: transform, clip-path, opacity;
}
.bg-glitch-layer-2 {
background-size: cover;
background-position: center;
mix-blend-mode: color-dodge;
filter: brightness(2) contrast(2) saturate(2) hue-rotate(90deg);
will-change: transform, clip-path, opacity;
}
.bg-glitch-scan {
background:
linear-gradient(90deg,
rgba(255,0,255,0.2) 0%,
rgba(0,255,255,0.2) 25%,
rgba(255,255,0,0.2) 50%,
rgba(0,255,255,0.2) 75%,
rgba(255,0,255,0.2) 100%
),
repeating-linear-gradient(0deg,
rgba(255,255,255,0.05) 0px,
rgba(255,255,255,0.05) 2px,
transparent 2px,
transparent 4px
);
will-change: transform, opacity;
}
/* Trigger glitch animation when active */
.bg-glitch-layer-1.glitch-active {
animation: bg-glitch-shift 0.375s steps(15, end) forwards;
}
.bg-glitch-layer-2.glitch-active {
animation: bg-glitch-shift-2 0.375s steps(12, end) forwards;
}
.bg-glitch-scan.glitch-active {
animation: bg-glitch-scan 0.375s linear forwards;
}
/* World Fair style - visible but tasteful glitch */
@keyframes bg-glitch-shift {
0% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
5% { opacity: 0.5; }
12% { transform: translate(15px,-8px); clip-path: inset(12% 0 70% 0); }
20% { transform: translate(-20px,10px); clip-path: inset(45% 0 35% 0); }
28% { transform: translate(18px,-5px); clip-path: inset(68% 0 15% 0); }
36% { transform: translate(-15px,12px); clip-path: inset(20% 0 60% 0); }
44% { transform: translate(22px,-10px); clip-path: inset(52% 0 28% 0); }
52% { transform: translate(-18px,8px); clip-path: inset(10% 0 75% 0); }
60% { transform: translate(12px,-6px); clip-path: inset(58% 0 22% 0); }
68% { transform: translate(-10px,15px); clip-path: inset(32% 0 48% 0); }
76% { transform: translate(16px,-4px); clip-path: inset(72% 0 12% 0); }
84% { transform: translate(-12px,7px); clip-path: inset(18% 0 65% 0); }
92% { transform: translate(8px,-3px); clip-path: inset(42% 0 40% 0); }
96% { opacity: 0.4; }
100% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
}
@keyframes bg-glitch-shift-2 {
0% { transform: translate(0,0) skewX(0deg); clip-path: inset(0% 0 0 0); opacity: 0; }
8% { opacity: 0.5; }
15% { transform: translate(-18px,10px) skewX(4deg); clip-path: inset(25% 0 55% 0); }
23% { transform: translate(22px,-12px) skewX(-5deg); clip-path: inset(50% 0 30% 0); }
31% { transform: translate(-16px,8px) skewX(3deg); clip-path: inset(72% 0 12% 0); }
39% { transform: translate(20px,-15px) skewX(-4deg); clip-path: inset(18% 0 65% 0); }
47% { transform: translate(-22px,12px) skewX(5deg); clip-path: inset(42% 0 38% 0); }
55% { transform: translate(18px,-8px) skewX(-3deg); clip-path: inset(62% 0 20% 0); }
63% { transform: translate(-14px,14px) skewX(4deg); clip-path: inset(30% 0 52% 0); }
71% { transform: translate(16px,-6px) skewX(-2deg); clip-path: inset(8% 0 78% 0); }
79% { transform: translate(-12px,10px) skewX(3deg); clip-path: inset(55% 0 28% 0); }
87% { transform: translate(10px,-4px) skewX(-2deg); clip-path: inset(35% 0 45% 0); }
95% { opacity: 0.4; }
100% { transform: translate(0,0) skewX(0deg); clip-path: inset(0% 0 0 0); opacity: 0; }
}
@keyframes bg-glitch-scan {
0% { opacity: 0; transform: translateX(-120%); }
5% { opacity: 0.5; }
15% { opacity: 0.55; transform: translateX(-80%); }
30% { opacity: 0.6; transform: translateX(-40%); }
50% { opacity: 0.6; transform: translateX(0%); }
70% { opacity: 0.55; transform: translateX(40%); }
85% { opacity: 0.5; transform: translateX(80%); }
95% { opacity: 0.45; }
100% { opacity: 0; transform: translateX(120%); }
}
/* Full width background */
.dashboard-view .bg-fullwidth {
min-width: 100%;
width: 100%;
background-size: cover !important;
background-position: center center !important;
}
/* Continuous glitch overlays - every 5s */
.dashboard-glitch-layer {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 5;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
opacity: 0;
}
.dashboard-glitch-1 {
mix-blend-mode: screen;
filter: hue-rotate(22deg) saturate(1.35);
animation: dashboard-glitch-shift 5s steps(10, end) infinite;
background-size: cover !important;
background-position: center center !important;
}
.dashboard-glitch-2 {
mix-blend-mode: screen;
filter: hue-rotate(-30deg) saturate(1.45);
animation: dashboard-glitch-shift-2 5s steps(9, end) infinite;
background-size: cover !important;
background-position: center center !important;
}
.dashboard-glitch-scan {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 6;
background:
linear-gradient(180deg, rgba(255,255,255,0.16), rgba(0,0,0,0) 60%),
repeating-linear-gradient(180deg, rgba(255,255,255,0.05) 0 2px, rgba(0,0,0,0) 2px 4px),
radial-gradient(ellipse at center, rgba(0,0,0,0) 40%, rgba(0,0,0,0.35) 100%);
opacity: 0;
animation: dashboard-glitch-scan 5s ease-out infinite;
}
/* Pause dashboard glitch animations during tab switch (backdrop-filter fix) */
html.tab-hidden .dashboard-glitch-1,
html.tab-hidden .dashboard-glitch-2,
html.tab-hidden .dashboard-glitch-scan {
animation-play-state: paused !important;
}
@keyframes dashboard-glitch-shift {
0%, 82% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
82.1% { opacity: 0.28; }
84% { transform: translate(6px,-2px); clip-path: inset(8% 0 70% 0); }
86% { transform: translate(-5px,2px); clip-path: inset(42% 0 40% 0); }
88% { transform: translate(3px,0); clip-path: inset(68% 0 10% 0); }
91% { transform: translate(-4px,3px); clip-path: inset(18% 0 60% 0); }
93% { transform: translate(5px,-3px); clip-path: inset(55% 0 20% 0); }
95% { transform: translate(-3px,1px); clip-path: inset(10% 0 80% 0); }
100% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
}
@keyframes dashboard-glitch-shift-2 {
0%, 82% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
82.1% { opacity: 0.24; }
84% { transform: translate(-6px,2px); clip-path: inset(12% 0 65% 0); }
86% { transform: translate(5px,-1px) skewX(0.6deg); clip-path: inset(36% 0 42% 0); }
89% { transform: translate(-3px,2px); clip-path: inset(72% 0 8% 0); }
92% { transform: translate(4px,-3px); clip-path: inset(22% 0 58% 0); }
95% { transform: translate(-4px,1px); clip-path: inset(50% 0 26% 0); }
100% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
}
@keyframes dashboard-glitch-scan {
0%, 82% { opacity: 0; transform: translateY(-20%); }
84% { opacity: 0.5; }
90% { opacity: 0.35; }
100% { opacity: 0; transform: translateY(115%); }
}
@@ -0,0 +1,82 @@
// Route classification for the Dashboard KeepAlive host (D-01/D-03/D-04).
//
// Exact-match only — a prefix match would sweep in secondary screens like
// `/dashboard/marketplace/:id`, which D-04 explicitly excludes from the
// instance cache (unbounded item counts would bloat it). Deliberately not
// derived from `isDetailRoute()` in useRouteTransitions.ts: that helper only
// recognises `/apps/` and `/marketplace/` details and misses `cloud/:folderId`,
// `server/openwrt`, `web5/credentials`, `goals/:goalId` and `app-session/:appId`.
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import { TAB_ORDER } from './useRouteTransitions'
/** Maximum number of main-tab component instances kept alive at once (D-03).
* Bounds memory on low-power fleet nodes — the oldest unused instance evicts
* once this cap is exceeded (Vue's own LRU eviction).
*
* 02-08 (FA-D) tuned this against an on-device measurement rather than
* leaving the carried-forward estimate unexamined: on a test node (real
* node hardware, deployed build), a headless Chromium session logged in via
* the real UI, cycled every main tab (all of TAB_ORDER incl. the withheld
* `/dashboard/settings`, plus `/dashboard/discover` — 11 tabs) through 4 full
* two-way round-trips (44 navigations total, well past the single "twice"
* the task calls for), reading the JS heap via CDP `Performance.getMetrics`
* before and after each cycle. Result: ~8.8MB before any navigation, then
* 10.02 / 20.69 / 16.67 / 14.12 MB after cycles 0-3 respectively (final
* reading 14.68MB) — fluctuating, not monotonically growing, across 10
* distinct cache-registered paths exceeding this cap. That confirms the
* eviction is actually bounding memory rather than the cap sitting unused,
* and the absolute growth (single-digit MB) is inconsequential on any
* low-power fleet node's available RAM. Left at 6, unchanged — a measured,
* confirmed 6 rather than the FA-D estimate it replaces. */
export const KEEP_ALIVE_MAX = 6
/** TAB_ORDER paths deliberately withheld from the instance cache this plan
* (02-04) would otherwise register. Two distinct reasons populate this set —
* kept in one place so `KEEP_ALIVE_PATHS` stays a single derivation:
*
* 1. Measured `already fast` in 02-FINDINGS.md with `Remounted: false` — no
* instance cache to gain (D-02). (None as of 02-04: every TAB_ORDER path
* measured `Remounted: true` (Home, Apps, Marketplace, Cloud, Web5,
* Fleet) or was `unmeasured` (Mesh, Chat) — neither unmeasured row has a
* `Remounted: false`, so per the plan's literal exclusion rule both stay
* registered, and their lifecycle was audited in 02-04's Task 1.)
* 2. Unaudited-risk (02-04 Rule-3 deviation, not in 02-FINDINGS.md at all):
* `/dashboard/settings` is in TAB_ORDER but neither Settings.vue nor any
* of its child sections are in this plan's Task 1/2 file lists. A grep
* across `neode-ui/src/views/settings/*.vue` found real un-audited side
* effects that would misbehave under KeepAlive exactly as this plan
* warns against: `SystemDangerZone.vue`'s reboot poll/elapsed intervals,
* and one-shot `onMounted`-only fetches in `VpnStatusSection.vue`,
* `KioskDisplaySection.vue`, `TransportPrefsCard.vue` and
* `ClaudeAuthSection.vue` that would never refresh again once cached.
* Registering Settings without that audit would ship the exact
* off-screen-timer/stale-data bug this plan exists to prevent — excluded
* until a future plan audits it the way Task 1 did for Home/Web5/Chat/
* Cloud/Server/Mesh. */
const WITHHELD_FROM_CACHE: ReadonlySet<string> = new Set([
'/dashboard/settings',
])
/** Paths whose component instance survives a tab switch (D-01), derived from
* the single-sourced TAB_ORDER main-tab list (plus `/dashboard/discover`,
* the App Store's second tab, measured `remount storm` in 02-FINDINGS.md but
* not part of TAB_ORDER) rather than restating every path literally — a
* future tab addition to TAB_ORDER is registered automatically instead of
* silently missing registration.
*
* 02-04 widened this from the 02-02 tracer's single seed path after
* auditing every main tab's mount/activate/deactivate lifecycle (see
* 02-04-SUMMARY.md's per-view side-effect table) — every registered path's
* timers, subscriptions and window listeners are now placed for an
* activate/deactivate lifecycle, so it's safe for their instances to
* survive a tab switch. */
export const KEEP_ALIVE_PATHS: ReadonlySet<string> = new Set([
...TAB_ORDER.filter((path) => !WITHHELD_FROM_CACHE.has(path)),
'/dashboard/discover',
])
/** True only for an exact path match against KEEP_ALIVE_PATHS. */
export function shouldKeepAlive(route: RouteLocationNormalizedLoaded | { path: string }): boolean {
return KEEP_ALIVE_PATHS.has(route.path)
}
@@ -0,0 +1,186 @@
import { type RouteLocationNormalizedLoaded } from 'vue-router'
/** Tab order for vertical transitions between main navigation items.
* Exported so keepAliveRoutes.ts (and plan 02-04's widening of
* KEEP_ALIVE_PATHS) can derive the main-tab path set from a single source
* of truth instead of re-deriving it. */
export const TAB_ORDER = [
'/dashboard',
'/dashboard/apps',
'/dashboard/marketplace',
'/dashboard/cloud',
'/dashboard/mesh',
'/dashboard/server',
'/dashboard/web5',
'/dashboard/fleet',
'/dashboard/chat',
'/dashboard/settings'
]
/** Web5 group sub-tab order for mobile horizontal swipe transitions */
const WEB5_TAB_ORDER = ['/dashboard/web5', '/dashboard/cloud', '/dashboard/server', '/dashboard/mesh']
/** Route-to-background image mapping */
export const ROUTE_BACKGROUNDS: Record<string, string> = {
'/dashboard': 'bg-home.webp',
'/dashboard/': 'bg-home.webp',
'/dashboard/apps': 'bg-myapps.webp',
'/dashboard/discover': 'bg-appstore.webp',
'/dashboard/marketplace': 'bg-appstore.webp',
'/dashboard/cloud': 'bg-cloud.webp',
'/dashboard/mesh': 'bg-mesh.webp',
'/dashboard/server': 'bg-network.jpg',
'/dashboard/web5': 'bg-web5.jpg',
'/dashboard/server/federation': 'bg-web5.jpg',
'/dashboard/monitoring': 'bg-web5.jpg',
'/dashboard/fleet': 'bg-web5.jpg',
'/dashboard/settings': 'bg-settings.webp',
'/dashboard/chat': 'bg-aiui.jpg',
}
export function isDetailRoute(path: string): boolean {
return (path.includes('/apps/') && !path.endsWith('/apps')) ||
(path.includes('/marketplace/') && !path.endsWith('/marketplace'))
}
/**
* Creates a route transition tracker that determines the appropriate
* CSS transition name based on navigation direction and route depth.
*/
export function useRouteTransitions() {
let previousPath = ''
let previousTab = ''
function getTransitionName(currentRoute: RouteLocationNormalizedLoaded): string {
const currentPath = currentRoute.path
if (!previousPath) {
previousPath = currentPath
return 'fade'
}
// Chat transitions: directional slide
const isChat = currentPath === '/dashboard/chat'
const wasChat = previousPath === '/dashboard/chat'
if (isChat) {
previousPath = currentPath
return 'chat-open'
}
if (wasChat) {
previousPath = currentPath
return 'chat-close'
}
const isAppDetails = currentPath.includes('/apps/') && !currentPath.endsWith('/apps')
const isAppsList = currentPath === '/dashboard/apps'
const wasAppDetails = previousPath.includes('/apps/') && !previousPath.endsWith('/apps')
const wasAppsList = previousPath === '/dashboard/apps'
const isMarketplaceDetails = currentPath.includes('/marketplace/') && !currentPath.endsWith('/marketplace')
const isMarketplaceList = currentPath === '/dashboard/marketplace'
const wasMarketplaceDetails = previousPath.includes('/marketplace/') && !previousPath.endsWith('/marketplace')
const wasMarketplaceList = previousPath === '/dashboard/marketplace'
const isCloudFolder = currentPath.includes('/cloud/') && !currentPath.endsWith('/cloud')
const isCloudList = currentPath === '/dashboard/cloud'
const wasCloudFolder = previousPath.includes('/cloud/') && !previousPath.endsWith('/cloud')
const wasCloudList = previousPath === '/dashboard/cloud'
const isFederation = currentPath === '/dashboard/server/federation'
const wasFederation = previousPath === '/dashboard/server/federation'
const isMonitoring = currentPath === '/dashboard/monitoring'
const wasMonitoring = previousPath === '/dashboard/monitoring'
const isFleet = currentPath === '/dashboard/fleet'
const wasFleet = previousPath === '/dashboard/fleet'
const isWeb5 = currentPath === '/dashboard/web5'
const wasWeb5 = previousPath === '/dashboard/web5'
// Any Web5 sub-detail (networking-profits, credentials, …) animates as a
// depth push from/back-to the Web5 tab — same feel as Find Nodes.
const isWeb5Detail = currentPath.startsWith('/dashboard/web5/')
const wasWeb5Detail = previousPath.startsWith('/dashboard/web5/')
let transitionName = 'fade'
// Mobile: Horizontal slide transitions between sub-tabs
if (typeof window !== 'undefined' && window.innerWidth < 768) {
const isServices = currentPath === '/dashboard/apps' && (currentRoute.query.tab === 'services' || currentRoute.query.tab === 'websites')
const wasServices = previousTab === 'services' || previousTab === 'websites'
const currentAppsIdx = isServices ? 2
: currentPath === '/dashboard/marketplace' ? 1
: currentPath === '/dashboard/apps' ? 0 : -1
const prevAppsIdx = wasServices ? 2
: previousPath === '/dashboard/marketplace' ? 1
: previousPath === '/dashboard/apps' ? 0 : -1
const currentWeb5Idx = WEB5_TAB_ORDER.indexOf(currentPath)
const prevWeb5Idx = WEB5_TAB_ORDER.indexOf(previousPath)
if (currentAppsIdx !== -1 && prevAppsIdx !== -1 && currentAppsIdx !== prevAppsIdx) {
transitionName = currentAppsIdx > prevAppsIdx ? 'slide-left' : 'slide-right'
} else if (currentWeb5Idx !== -1 && prevWeb5Idx !== -1 && currentWeb5Idx !== prevWeb5Idx) {
transitionName = currentWeb5Idx > prevWeb5Idx ? 'slide-left' : 'slide-right'
} else {
const currentIndex = TAB_ORDER.indexOf(currentPath)
const previousIndex = TAB_ORDER.indexOf(previousPath)
if (currentIndex !== -1 && previousIndex !== -1 && currentIndex !== previousIndex) {
transitionName = currentIndex > previousIndex ? 'slide-down' : 'slide-up'
}
}
}
// Desktop depth transitions: list <-> detail
else if (wasAppsList && isAppDetails) {
transitionName = 'depth-forward'
} else if (wasAppDetails && isAppsList) {
transitionName = 'depth-back'
} else if (wasMarketplaceList && isMarketplaceDetails) {
transitionName = 'depth-forward'
} else if (wasMarketplaceDetails && isMarketplaceList) {
transitionName = 'depth-back'
} else if (wasCloudList && isCloudFolder) {
transitionName = 'depth-forward'
} else if (wasCloudFolder && isCloudList) {
transitionName = 'depth-back'
} else if (wasWeb5 && isFederation) {
transitionName = 'depth-forward'
} else if (wasFederation && isWeb5) {
transitionName = 'depth-back'
} else if (wasWeb5 && isMonitoring) {
transitionName = 'depth-forward'
} else if (wasMonitoring && isWeb5) {
transitionName = 'depth-back'
} else if (wasWeb5 && isFleet) {
transitionName = 'depth-forward'
} else if (wasFleet && isWeb5) {
transitionName = 'depth-back'
} else if (wasWeb5 && isWeb5Detail) {
transitionName = 'depth-forward'
} else if (wasWeb5Detail && isWeb5) {
transitionName = 'depth-back'
} else if (wasMarketplaceList && isAppDetails) {
transitionName = 'depth-forward'
} else if (wasAppDetails && isMarketplaceList) {
transitionName = 'depth-back'
}
// Desktop: no transition between Apps <-> Marketplace (same-page tab feel)
else if ((wasAppsList && isMarketplaceList) || (wasMarketplaceList && isAppsList)) {
transitionName = 'fade'
}
// Vertical transition: between main tabs (desktop)
else {
const currentIndex = TAB_ORDER.indexOf(currentPath)
const previousIndex = TAB_ORDER.indexOf(previousPath)
if (currentIndex !== -1 && previousIndex !== -1 && currentIndex !== previousIndex) {
transitionName = currentIndex > previousIndex ? 'slide-down' : 'slide-up'
}
}
previousPath = currentPath
previousTab = (currentRoute.query.tab as string) || ''
return transitionName
}
return { getTransitionName }
}