Archipelago — open-source initial import
This commit is contained in:
@@ -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%); }
|
||||
}
|
||||
Binary file not shown.
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user