fix(intro): restore the typing intro on fresh kiosk installs + seamless splash-to-onboarding handoff

Three regressions of the fresh-install cinematic, all in App.vue:

1. The kiosk chromium opens /kiosk (a marker route that stamps
   localStorage.kiosk and redirects to /). The deep-route splash
   suppression added in PR #91 reads window.location.pathname, so the
   kiosk boot was classified as a deep route and the typing intro →
   "Welcome Noderunner" → onboarding sequence never played. Normalize
   /kiosk to / for the splash decision.

2. The pre-splash onboarding-status check runs a ~30s retry ladder;
   against a still-booting backend it held the black !isReady screen the
   whole time. Bound it with a 2.5s race — unknown means "play the
   intro" (a fresh install IS the slow-backend case; onboarded nodes
   answer in milliseconds so their suppression is unaffected).

3. handleSplashComplete revealed RouterView before routing, flashing
   RootRedirect's spinner at '/' between the intro's final frame and the
   onboarding screen. Commit the destination route first, then reveal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-16 15:35:47 -04:00
parent e5c7210663
commit 06186ab30c

View File

@ -411,7 +411,13 @@ onMounted(async () => {
// the initial navigation resolves asynchronously so a deep-route load
// (e.g. a direct /login visit) would masquerade as a root boot. The URL
// bar is the only truthful source this early.
const bootPath = window.location.pathname
//
// The kiosk chromium opens /kiosk a marker route whose beforeEnter
// stamps localStorage.kiosk and immediately redirects to '/'. That is a
// root boot, not a deep route: without normalizing it, the fresh-install
// kiosk never plays the typing intro (deep-route suppression regression).
const rawBootPath = window.location.pathname
const bootPath = rawBootPath === '/kiosk' ? '/' : rawBootPath
// Public demo: every fresh boot at the root (first visit or a browser
// refresh) starts with the typing splash for the full effect. In-session SPA
// navigation never remounts App, and deep-route refreshes keep their place.
@ -424,7 +430,17 @@ onMounted(async () => {
if (splashCandidate && onboardingComplete !== true) {
try {
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
onboardingComplete = await checkOnboardingStatus()
// Bound the pre-splash status check: its retry ladder can spend ~30s
// against a still-booting backend, and this await holds the black
// "!isReady" screen the whole time exactly where the typing intro
// should be playing on a fresh kiosk boot. Unknown within 2.5s let
// the splash play (a fresh install IS the slow-backend case; onboarded
// nodes answer in milliseconds, so their suppression path is intact).
// handleSplashComplete re-checks with full retries after the intro.
onboardingComplete = await Promise.race([
checkOnboardingStatus(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 2500)),
])
} catch {
onboardingComplete = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
}
@ -524,14 +540,24 @@ function onShareToMeshMessage(ev: MessageEvent) {
* Routes user directly to appropriate screen based on onboarding status (from backend)
*/
async function handleSplashComplete() {
showSplash.value = false
document.body.classList.add('splash-complete')
isReady.value = true
sessionStorage.setItem('archipelago_from_splash', '1')
// Commit the destination route BEFORE revealing RouterView. Revealing
// first rendered whatever route was current '/' RootRedirect's
// spinner for a beat between the intro's final frame and the
// onboarding/login screen actually mounting: a visible flash on every
// fresh install. Await the push so the first RouterView render is
// already the destination.
const reveal = () => {
showSplash.value = false
document.body.classList.add('splash-complete')
isReady.value = true
}
const devMode = import.meta.env.VITE_DEV_MODE
if (devMode === 'setup' || devMode === 'existing') {
router.push('/login').catch(() => {})
await router.push('/login').catch(() => {})
reveal()
return
}
@ -541,7 +567,8 @@ async function handleSplashComplete() {
{
const { IS_DEMO } = await import('@/composables/useDemoIntro')
if (IS_DEMO) {
router.push('/onboarding/intro').catch(() => {})
await router.push('/onboarding/intro').catch(() => {})
reveal()
return
}
}
@ -550,27 +577,24 @@ async function handleSplashComplete() {
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
const seenOnboarding = await checkOnboardingStatus()
if (seenOnboarding === true) {
router.push('/login').catch(() => {})
return
}
if (seenOnboarding === false) {
router.push('/onboarding/intro').catch(() => {})
return
}
// Backend unreachable after retries. Prefer the localStorage
// cache on THIS browser (if a prior successful check set it)
// otherwise defer to RootRedirect which polls + retries rather
// than forcing an already-onboarded user through the wizard.
if (localStorage.getItem('neode_onboarding_complete') === '1') {
router.push('/login').catch(() => {})
await router.push('/login').catch(() => {})
} else if (seenOnboarding === false) {
await router.push('/onboarding/intro').catch(() => {})
} else if (localStorage.getItem('neode_onboarding_complete') === '1') {
// Backend unreachable after retries. Prefer the localStorage
// cache on THIS browser (if a prior successful check set it)
// otherwise defer to RootRedirect which polls + retries rather
// than forcing an already-onboarded user through the wizard.
await router.push('/login').catch(() => {})
} else {
router.push('/').catch(() => {})
await router.push('/').catch(() => {})
}
} catch {
// Do NOT default to /onboarding/intro here. RootRedirect has retry
// + polling + boot-screen handling; let it decide.
router.push('/').catch(() => {})
await router.push('/').catch(() => {})
}
reveal()
}
</script>