From 4983ba4e2046f25131aada744b2898e0682c5a8d Mon Sep 17 00:00:00 2001 From: Dorian Date: Wed, 15 Jul 2026 10:48:39 +0100 Subject: [PATCH] fix(ui): first-login dashboard entrance restored + working Replay Intro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - playDashboardLoadOomph gated itself silent on the one entry that should be loud: onboarding is already flagged complete by the time the post-wizard dashboard mounts. force flag restores the full oomph there while keeping ordinary re-logins quiet (the intentional limit). - OnboardingDone now hands Login a one-shot finale flag, so the login right after the wizard gets the full zoom + oomph even as a regular password login — which is exactly the demo flow (and TOTP logins). - 'Replay intro' on the login screen actually replays: App.vue was instantly re-marking the intro as seen on onboarded nodes; an explicit one-shot replay flag now overrides every suppression rule (unit tested). Co-Authored-By: Claude Fable 5 --- neode-ui/src/App.vue | 8 +++++++- neode-ui/src/composables/useLoginSounds.ts | 11 +++++++++-- .../src/utils/__tests__/introSplash.test.ts | 10 ++++++++++ neode-ui/src/utils/introSplash.ts | 3 +++ neode-ui/src/views/Dashboard.vue | 4 +++- neode-ui/src/views/Login.vue | 19 +++++++++++++++++++ neode-ui/src/views/OnboardingDone.vue | 4 ++++ 7 files changed, 55 insertions(+), 4 deletions(-) diff --git a/neode-ui/src/App.vue b/neode-ui/src/App.vue index f1532d8b..acee52b1 100644 --- a/neode-ui/src/App.vue +++ b/neode-ui/src/App.vue @@ -395,6 +395,11 @@ onMounted(async () => { let seenIntro = localStorage.getItem('neode_intro_seen') === '1' const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1' if (fromBoot) sessionStorage.removeItem('archipelago_from_boot') + // One-shot "Replay intro" request from the login screen — must survive the + // auto-re-mark below (which otherwise instantly suppresses the replay on any + // onboarded node). + 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 const splashCandidate = !seenIntro && (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 */ } seenIntro = true } @@ -421,6 +426,7 @@ onMounted(async () => { fromBoot, devMode: import.meta.env.VITE_DEV_MODE, onboardingComplete, + replayRequested, })) { // Coming from boot screen — show the full splash intro (Enter to Exit → typing → logo) showSplash.value = true diff --git a/neode-ui/src/composables/useLoginSounds.ts b/neode-ui/src/composables/useLoginSounds.ts index 906b1469..98abddb7 100644 --- a/neode-ui/src/composables/useLoginSounds.ts +++ b/neode-ui/src/composables/useLoginSounds.ts @@ -251,8 +251,15 @@ export function playKeyboardTypingSound() { } /** 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() if (!ctx) return diff --git a/neode-ui/src/utils/__tests__/introSplash.test.ts b/neode-ui/src/utils/__tests__/introSplash.test.ts index fbf54320..bad1d133 100644 --- a/neode-ui/src/utils/__tests__/introSplash.test.ts +++ b/neode-ui/src/utils/__tests__/introSplash.test.ts @@ -28,4 +28,14 @@ describe('shouldShowIntroSplash', () => { onboardingComplete: null, })).toBe(false) }) + + it('an explicit replay request overrides every suppression rule', () => { + expect(shouldShowIntroSplash({ + seenIntro: true, + routePath: '/', + fromBoot: false, + onboardingComplete: true, + replayRequested: true, + })).toBe(true) + }) }) diff --git a/neode-ui/src/utils/introSplash.ts b/neode-ui/src/utils/introSplash.ts index 05f8e1b6..5b116de1 100644 --- a/neode-ui/src/utils/introSplash.ts +++ b/neode-ui/src/utils/introSplash.ts @@ -4,9 +4,12 @@ export interface IntroSplashDecisionInput { fromBoot: boolean devMode?: string onboardingComplete: boolean | null + /** Explicit "Replay intro" click — overrides every suppression rule. */ + replayRequested?: boolean } export function shouldShowIntroSplash(input: IntroSplashDecisionInput): boolean { + if (input.replayRequested) return true if (input.seenIntro) return false if (input.onboardingComplete === true) return false diff --git a/neode-ui/src/views/Dashboard.vue b/neode-ui/src/views/Dashboard.vue index 63e80ed8..f6d84fe9 100644 --- a/neode-ui/src/views/Dashboard.vue +++ b/neode-ui/src/views/Dashboard.vue @@ -369,7 +369,9 @@ onMounted(() => { if (loginTransition.justCompletedOnboarding) { // Full glitchy reveal — only on the very first dashboard entry // 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 loginTransition.setPendingWelcomeTyping(true) loginTransition.setJustCompletedOnboarding(false) diff --git a/neode-ui/src/views/Login.vue b/neode-ui/src/views/Login.vue index 1b192c74..013e5914 100644 --- a/neode-ui/src/views/Login.vue +++ b/neode-ui/src/views/Login.vue @@ -477,6 +477,7 @@ async function handleLogin() { stopSynthwave() whooshAway.value = true playLoginSuccessWhoosh() + consumeOnboardingFinale() loginTransition.setJustLoggedIn(true) await new Promise(r => setTimeout(r, 520)) await router.replace(loginRedirectTo.value).catch(() => { @@ -512,6 +513,7 @@ async function handleTotpVerify() { stopSynthwave() whooshAway.value = true playLoginSuccessWhoosh() + consumeOnboardingFinale() loginTransition.setJustLoggedIn(true) await new Promise(r => setTimeout(r, 520)) 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() { // Clear the intro seen flag localStorage.removeItem('neode_intro_seen') // Demo: also clear the per-day gate so the intro plays again now. 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 window.location.href = '/' } diff --git a/neode-ui/src/views/OnboardingDone.vue b/neode-ui/src/views/OnboardingDone.vue index 26603823..be1c3ec6 100644 --- a/neode-ui/src/views/OnboardingDone.vue +++ b/neode-ui/src/views/OnboardingDone.vue @@ -120,6 +120,10 @@ onUnmounted(() => { function goToLogin() { 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(() => {}) }