fix(intro): audible entrance oomph, splash off deep routes, finale on intro shortcuts — with e2e cinematic suite

Three first-visit-experience bugs, all caught by the new e2e suite
(e2e/intro-experience.spec.ts — 5 tests against the demo stack on real
Chrome, instrumenting HTMLMediaElement/WebAudio/MutationObserver since
headless can't hear or see the cinematic):

- playDashboardLoadOomph was always silent: the login→dashboard route
  change closes the AudioContext (stopAllAudio), and the oomph only
  fetched the dead context. It now recreates one, riding the login
  click's sticky user activation.
- A direct /login load in the demo hijacked into the splash: App.vue
  read route.path before the router resolved the initial navigation, so
  every deep-route boot looked like a root boot. The splash decision now
  reads window.location.pathname.
- Replayed intros ran mute (enableCinematicSounds override) and the
  intro's shortcut exits skipped the dashboard finale (flag now armed on
  both demo and login exits).

Verified: 5/5 e2e, 673/673 unit, vue-tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-07-15 15:13:39 +01:00
co-authored by Claude Fable 5
parent efd1ae41de
commit a44cbe478a
4 changed files with 328 additions and 17 deletions
+33 -10
View File
@@ -400,14 +400,19 @@ onMounted(async () => {
// onboarded node).
let replayRequested = sessionStorage.getItem('archipelago_replay_intro') === '1'
if (replayRequested) sessionStorage.removeItem('archipelago_replay_intro')
// The route object still holds the router's START_LOCATION ('/') here —
// 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
// 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.
const { IS_DEMO } = await import('@/composables/useDemoIntro')
if (IS_DEMO && route.path === '/') replayRequested = true
if (IS_DEMO && bootPath === '/') replayRequested = true
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'))
&& (fromBoot || (bootPath === '/' && import.meta.env.VITE_DEV_MODE !== 'boot'))
if (splashCandidate && onboardingComplete !== true) {
try {
@@ -427,22 +432,29 @@ onMounted(async () => {
if (shouldShowIntroSplash({
seenIntro,
routePath: route.path,
routePath: bootPath,
fromBoot,
devMode: import.meta.env.VITE_DEV_MODE,
onboardingComplete,
replayRequested,
})) {
// The intro is definitely playing — unmute the whole cinematic (speech,
// synthwave, pops) even on browsers whose localStorage says onboarding is
// complete; the sound gate otherwise silences replays.
const { enableCinematicSounds } = await import('@/composables/useLoginSounds')
enableCinematicSounds()
// Kick off the intro video download NOW: the splash's <video> element only
// mounts ~20s in (after the typing sequence), and on a cold cache the file
// would otherwise start fetching mid-sequence and stutter.
// would otherwise start fetching mid-sequence and stutter. A detached
// <video preload=auto> is used because Chromium does not support
// <link rel=preload as=video> — the media cache then serves the splash's
// element, which uses the identical URL.
try {
const link = document.createElement('link')
link.rel = 'preload'
link.as = 'video'
link.type = 'video/mp4'
link.href = '/assets/video/video-intro.mp4?v=8'
document.head.appendChild(link)
const warm = document.createElement('video')
warm.muted = true
warm.preload = 'auto'
warm.src = '/assets/video/video-intro.mp4?v=8'
;(window as unknown as { __introVideoWarm?: HTMLVideoElement }).__introVideoWarm = warm
} catch { /* ignore */ }
// Coming from boot screen — show the full splash intro (Enter to Exit → typing → logo)
showSplash.value = true
@@ -516,6 +528,17 @@ async function handleSplashComplete() {
return
}
// Demo: the cinematic always continues into the onboarding intro page —
// the mock backend reports "onboarded", which would otherwise route
// straight to /login and cut the sequence short.
{
const { IS_DEMO } = await import('@/composables/useDemoIntro')
if (IS_DEMO) {
router.push('/onboarding/intro').catch(() => {})
return
}
}
try {
const { checkOnboardingStatus } = await import('@/composables/useOnboarding')
const seenOnboarding = await checkOnboardingStatus()
+23 -7
View File
@@ -8,14 +8,27 @@
* exempt and continue to play regardless.
*/
/** One-way, per-page-load override: when App.vue decides to run the intro
* splash (demo fresh boot, prod first install, Replay Intro), the WHOLE
* cinematic — speech, synthwave, pops — must be audible even though
* localStorage already says onboarding is complete. Without this the
* replayed intro runs silent (lost "Welcome Noderunner" + song). */
let cinematicMode = false
export function enableCinematicSounds() {
cinematicMode = true
}
/** True when the node has not yet completed onboarding — i.e. we're
* still in the first-install cinematic. Reads the localStorage cache
* set by useOnboarding (which is re-seeded from the backend on each
* successful check), so this stays correct after a browser clear
* once the onboarding-complete probe runs. Sound calls that fire
* before that probe completes will fall through silent on an already-
* onboarded node — which is exactly what we want. */
* still in the first-install cinematic — or when the intro is being
* deliberately replayed this page load (see enableCinematicSounds).
* Reads the localStorage cache set by useOnboarding (which is
* re-seeded from the backend on each successful check), so this stays
* correct after a browser clear once the onboarding-complete probe
* runs. Sound calls that fire before that probe completes will fall
* through silent on an already-onboarded node — which is exactly what
* we want: ordinary re-logins stay quiet. */
function isFirstInstallPhase(): boolean {
if (cinematicMode) return true
try {
return localStorage.getItem('neode_onboarding_complete') !== '1'
} catch {
@@ -260,7 +273,10 @@ export function playKeyboardTypingSound() {
*/
export function playDashboardLoadOomph(force = false) {
if (!force && !isFirstInstallPhase()) return
const ctx = getContext()
// The login→dashboard route change runs stopAllAudio(), which CLOSES the
// AudioContext — so the context must be recreated here, not just fetched.
// The login click's sticky user activation lets the fresh context run.
const ctx = ensureContext()
if (!ctx) return
try {
+11
View File
@@ -69,12 +69,22 @@ onMounted(() => {
}, 2100)
})
/** Any exit from the intro INTO login is the end of the cinematic — hand the
* login the one-shot finale flag so the dashboard plays its full first-entry
* reveal (zoom + oomph). OnboardingDone sets the same flag at the end of the
* full wizard; these are the shortcut exits that used to skip it (which is
* why the demo never got the big entrance). */
function armOnboardingFinale() {
try { sessionStorage.setItem('archy_onboarding_finale', '1') } catch { /* ignore */ }
}
function goToOptions() {
playNavSound('action')
// Demo: skip the onboarding wizard (seed/identity setup) entirely — go straight
// to login, which is prefilled with the demo password.
if (isDemo) {
localStorage.setItem('neode_onboarding_complete', '1')
armOnboardingFinale()
router.push('/login').catch(() => {})
return
}
@@ -89,6 +99,7 @@ function goToRestore() {
function goToLogin() {
playNavSound('action')
localStorage.setItem('neode_onboarding_complete', '1')
armOnboardingFinale()
router.push('/login').catch(() => {})
}
</script>