fix(ui): restore first-login dashboard entrance + working Replay Intro #86

Merged
ai merged 1 commits from intro-entrance-fixes into main 2026-07-15 09:49:15 +00:00
7 changed files with 55 additions and 4 deletions

View File

@ -395,6 +395,11 @@ onMounted(async () => {
let seenIntro = localStorage.getItem('neode_intro_seen') === '1' let seenIntro = localStorage.getItem('neode_intro_seen') === '1'
const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1' const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1'
if (fromBoot) sessionStorage.removeItem('archipelago_from_boot') if (fromBoot) sessionStorage.removeItem('archipelago_from_boot')
// One-shot "Replay intro" request from the login screen must survive the
// auto-re-mark below (which otherwise instantly suppresses the replay on any
// onboarded node).
const replayRequested = sessionStorage.getItem('archipelago_replay_intro') === '1'
if (replayRequested) sessionStorage.removeItem('archipelago_replay_intro')
let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null let onboardingComplete: boolean | null = localStorage.getItem('neode_onboarding_complete') === '1' ? true : null
const splashCandidate = !seenIntro const splashCandidate = !seenIntro
&& (fromBoot || (route.path === '/' && import.meta.env.VITE_DEV_MODE !== 'boot')) && (fromBoot || (route.path === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
@ -408,7 +413,7 @@ onMounted(async () => {
} }
} }
if (!seenIntro && onboardingComplete === true) { if (!replayRequested && !seenIntro && onboardingComplete === true) {
try { localStorage.setItem('neode_intro_seen', '1') } catch { /* noop */ } try { localStorage.setItem('neode_intro_seen', '1') } catch { /* noop */ }
seenIntro = true seenIntro = true
} }
@ -421,6 +426,7 @@ onMounted(async () => {
fromBoot, fromBoot,
devMode: import.meta.env.VITE_DEV_MODE, devMode: import.meta.env.VITE_DEV_MODE,
onboardingComplete, onboardingComplete,
replayRequested,
})) { })) {
// Coming from boot screen show the full splash intro (Enter to Exit typing logo) // Coming from boot screen show the full splash intro (Enter to Exit typing logo)
showSplash.value = true showSplash.value = true

View File

@ -251,8 +251,15 @@ export function playKeyboardTypingSound() {
} }
/** Gaming-style boot thud - soft impact when dashboard loads */ /** Gaming-style boot thud - soft impact when dashboard loads */
export function playDashboardLoadOomph() { /**
if (!isFirstInstallPhase()) return * @param force Play regardless of install phase. The first dashboard entry
* right after the onboarding wizard needs this: by then onboarding is already
* flagged complete in localStorage, so the isFirstInstallPhase() gate (which
* exists to keep ordinary re-logins quiet) would silence the one entrance
* that SHOULD be loud.
*/
export function playDashboardLoadOomph(force = false) {
if (!force && !isFirstInstallPhase()) return
const ctx = getContext() const ctx = getContext()
if (!ctx) return if (!ctx) return

View File

@ -28,4 +28,14 @@ describe('shouldShowIntroSplash', () => {
onboardingComplete: null, onboardingComplete: null,
})).toBe(false) })).toBe(false)
}) })
it('an explicit replay request overrides every suppression rule', () => {
expect(shouldShowIntroSplash({
seenIntro: true,
routePath: '/',
fromBoot: false,
onboardingComplete: true,
replayRequested: true,
})).toBe(true)
})
}) })

View File

@ -4,9 +4,12 @@ export interface IntroSplashDecisionInput {
fromBoot: boolean fromBoot: boolean
devMode?: string devMode?: string
onboardingComplete: boolean | null onboardingComplete: boolean | null
/** Explicit "Replay intro" click — overrides every suppression rule. */
replayRequested?: boolean
} }
export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean { export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean {
if (input.replayRequested) return true
if (input.seenIntro) return false if (input.seenIntro) return false
if (input.onboardingComplete === true) return false if (input.onboardingComplete === true) return false

View File

@ -369,7 +369,9 @@ onMounted(() => {
if (loginTransition.justCompletedOnboarding) { if (loginTransition.justCompletedOnboarding) {
// Full glitchy reveal only on the very first dashboard entry // Full glitchy reveal only on the very first dashboard entry
// right after onboarding (one-time event, persists in feel). // right after onboarding (one-time event, persists in feel).
playDashboardLoadOomph() // force=true: onboarding is already marked complete by now, so the
// un-forced call would gate itself silent.
playDashboardLoadOomph(true)
showZoomIn.value = true showZoomIn.value = true
loginTransition.setPendingWelcomeTyping(true) loginTransition.setPendingWelcomeTyping(true)
loginTransition.setJustCompletedOnboarding(false) loginTransition.setJustCompletedOnboarding(false)

View File

@ -477,6 +477,7 @@ async function handleLogin() {
stopSynthwave() stopSynthwave()
whooshAway.value = true whooshAway.value = true
playLoginSuccessWhoosh() playLoginSuccessWhoosh()
consumeOnboardingFinale()
loginTransition.setJustLoggedIn(true) loginTransition.setJustLoggedIn(true)
await new Promise(r => setTimeout(r, 520)) await new Promise(r => setTimeout(r, 520))
await router.replace(loginRedirectTo.value).catch(() => { await router.replace(loginRedirectTo.value).catch(() => {
@ -512,6 +513,7 @@ async function handleTotpVerify() {
stopSynthwave() stopSynthwave()
whooshAway.value = true whooshAway.value = true
playLoginSuccessWhoosh() playLoginSuccessWhoosh()
consumeOnboardingFinale()
loginTransition.setJustLoggedIn(true) loginTransition.setJustLoggedIn(true)
await new Promise(r => setTimeout(r, 520)) await new Promise(r => setTimeout(r, 520))
await router.replace(loginRedirectTo.value).catch(() => { await router.replace(loginRedirectTo.value).catch(() => {
@ -533,11 +535,28 @@ async function handleTotpVerify() {
} }
} }
/** A login right after the onboarding wizard (flag set by OnboardingDone)
* gets the FULL dashboard entrance zoom + oomph even though it's a
* regular password login (e.g. the demo). Subsequent logins stay
* deliberately low-key (justLoggedIn only). */
function consumeOnboardingFinale() {
try {
if (sessionStorage.getItem('archy_onboarding_finale') === '1') {
sessionStorage.removeItem('archy_onboarding_finale')
loginTransition.setJustCompletedOnboarding(true)
}
} catch { /* ignore */ }
}
function replayIntro() { function replayIntro() {
// Clear the intro seen flag // Clear the intro seen flag
localStorage.removeItem('neode_intro_seen') localStorage.removeItem('neode_intro_seen')
// Demo: also clear the per-day gate so the intro plays again now. // Demo: also clear the per-day gate so the intro plays again now.
if (IS_DEMO) clearDemoIntroSeen() if (IS_DEMO) clearDemoIntroSeen()
// On an onboarded node App.vue instantly re-marks the intro as seen (that's
// what keeps fresh browsers on already-onboarded nodes from replaying it)
// this explicit one-shot flag tells it the replay is deliberate.
try { sessionStorage.setItem('archipelago_replay_intro', '1') } catch { /* ignore */ }
// Navigate to root to trigger splash screen // Navigate to root to trigger splash screen
window.location.href = '/' window.location.href = '/'
} }

View File

@ -120,6 +120,10 @@ onUnmounted(() => {
function goToLogin() { function goToLogin() {
playNavSound('action') playNavSound('action')
// The login that follows the wizard gets the full dashboard entrance
// (zoom + oomph) even when it's a regular password login (e.g. the demo)
// Login.vue consumes this flag on success.
try { sessionStorage.setItem('archy_onboarding_finale', '1') } catch { /* ignore */ }
router.push('/login').catch(() => {}) router.push('/login').catch(() => {})
} }
</script> </script>