feat(stage-*): add coordinated splash and loading screens (#2698)

This commit is contained in:
Neko
2026-10-01 05:27:03 +08:00
committed by GitHub
parent 6a6dd46f9f
commit 3fd34c20c8
37 changed files with 1669 additions and 151 deletions
+62 -1
View File
@@ -38,13 +38,74 @@
const setting = localStorage.getItem('vueuse-color-scheme') || 'auto'
if (setting === 'dark' || (prefersDark && setting !== 'light'))
document.documentElement.classList.toggle('dark', true)
const savedHue = localStorage.getItem('settings/theme/colors/hue')
const hue = Number(savedHue)
if (savedHue !== null && Number.isFinite(hue))
document.documentElement.style.setProperty('--chromatic-hue', hue.toString())
})()
</script>
<script src="/assets/js/CubismSdkForWeb-5-r.3/Core/live2dcubismcore.min.js"></script>
<style>
#boot-splash {
position: fixed;
inset: 0;
z-index: 9999;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: max(calc(env(safe-area-inset-top) + 48px), calc(40vh - 82px)) 24px calc(env(safe-area-inset-bottom) + clamp(48px, 8vh, 96px));
background: #fff;
color: #262626;
font-family: "Comfortaa Variable", "Comfortaa", ui-sans-serif, system-ui, sans-serif;
}
#boot-splash .brand { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; gap: 20px; }
#boot-splash img { width: 96px; height: 96px; filter: hue-rotate(calc(var(--chromatic-hue, 220.44) * 1deg)); }
#boot-splash strong { font-size: 32px; font-weight: 700; letter-spacing: 0.07em; padding-left: 0.07em; -webkit-text-stroke: 0.5px currentColor; }
#boot-splash .status { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; width: min(220px, 60vw); height: 44px; }
#boot-splash .track-space { width: 48px; height: 3px; margin-top: 28px; }
#boot-splash .boot-error { display: none; position: absolute; bottom: max(calc(env(safe-area-inset-bottom) + 48px), 8vh); flex-direction: column; align-items: center; gap: 12px; max-width: 80vw; text-align: center; font-size: 13px; }
#boot-splash .boot-error button { padding: 10px 20px; border: 0; border-radius: 12px; background: oklch(65% 0.16 var(--chromatic-hue, 220.44)); color: #fff; font: inherit; cursor: pointer; }
#boot-splash.failed .boot-error { display: flex; }
#app:not(:empty) + #boot-splash { display: none; }
html.dark #boot-splash { background: #171717; color: #f5f5f5; }
</style>
<noscript><style>#boot-splash { display: none; }</style></noscript>
</head>
<body class="font-sans">
<div id="app"></div>
<div id="boot-splash" role="status" aria-busy="true">
<div class="brand">
<img src="/favicon.svg" alt="" />
<strong>AIRI</strong>
</div>
<div class="status"><span class="track-space" aria-hidden="true"></span></div>
<div class="boot-error" role="alert"><span>AIRI could not start.</span><button type="button" onclick="location.reload()">Retry</button></div>
</div>
<script src="/assets/js/CubismSdkForWeb-5-r.3/Core/live2dcubismcore.min.js"></script>
<script type="module" src="/src/main.ts"></script>
<script id="boot-splash-locales" type="application/json">__AIRI_STARTUP_LOCALES__</script>
<script>
;(function () {
const data = JSON.parse(document.querySelector('#boot-splash-locales').textContent)
const preferred = localStorage.getItem('settings/language') || navigator.language
const locale = data.localeRemap[preferred] || preferred
const labels = data.labels[locale] || data.labels.en
document.querySelector('#boot-splash .boot-error span').textContent = labels.failed
document.querySelector('#boot-splash .boot-error button').textContent = labels.retry
setTimeout(() => {
if (document.querySelector('#app:empty')) {
const splash = document.querySelector('#boot-splash')
splash?.classList.add('failed')
splash?.setAttribute('aria-busy', 'false')
}
}, 15000)
})()
</script>
<noscript> This website requires JavaScript to function properly. Please enable JavaScript to continue. </noscript>
</body>
</html>
+86 -54
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components'
import { OnboardingDialog, OnboardingStepAnalyticsNotice, StartupOverlay, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useStartupResourceTimeout } from '@proj-airi/stage-ui/composables/use-startup-resource-timeout'
import { usePiniaSynced } from '@proj-airi/stage-ui/libs/pinia'
import { initializeAnalytics, isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/libs/product-signals'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
@@ -16,12 +17,13 @@ import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
import { useStartupResourcesStore } from '@proj-airi/stage-ui/stores/startup-resources'
import { useTheme } from '@proj-airi/ui'
import { StageTransitionGroup } from '@proj-airi/ui-transitions'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterView } from 'vue-router'
import { RouterView, useRouter } from 'vue-router'
import { toast, Toaster } from 'vue-sonner'
import OnboardingPermissionsStep from './components/onboarding/step-permissions.vue'
@@ -31,6 +33,7 @@ import { getHostWebSocketConnector } from './modules/websocket-bridge'
const contextBridgeStore = useContextBridgeStore()
const authStore = useAuthStore()
const i18n = useI18n()
const router = useRouter()
const displayModelsStore = useDisplayModelsStore()
const settingsStore = useSettings()
const settings = storeToRefs(settingsStore)
@@ -42,6 +45,15 @@ const settingsAudioDeviceStore = useSettingsAudioDevice()
const { showingSetup } = storeToRefs(onboardingStore)
const { isDark } = useTheme()
const cardStore = useAiriCardStore()
const startup = useStartupResourcesStore()
startup.reset()
startup.register(['auth', 'modelIndex', 'card', 'services', 'modelData', 'modelSelection', 'audio', 'route', 'model'])
useStartupResourceTimeout('model', 120_000, () => i18n.t('stage.startup.model-timeout'))
const startupOnboarding = ref(false)
watch(showingSetup, (visible) => {
if (!visible)
startupOnboarding.value = false
})
useArtistryStore()
useConsciousnessStore()
useHearingStore()
@@ -104,30 +116,43 @@ watch(settings.themeColorsHueDynamic, () => {
document.documentElement.classList.toggle('dynamic-hue', settings.themeColorsHueDynamic.value)
}, { immediate: true })
// Initialize first-time setup check when app mounts
onMounted(async () => {
initializeAnalytics()
await authStore.initialize()
await displayModelsStore.initialize()
await cardStore.initialize()
registerAuthenticatedSetup()
if (!authStore.isAuthenticated)
await removeAuthenticationProviderConfiguration()
if (onboardingStore.needsOnboarding) {
onboardingStore.showingSetup = true
async function loadStartup() {
try {
initializeAnalytics()
await startup.run('auth', () => authStore.initialize())
await startup.run('modelIndex', () => displayModelsStore.initialize())
await startup.run('card', async () => {
await cardStore.initialize()
registerAuthenticatedSetup()
if (!authStore.isAuthenticated)
await removeAuthenticationProviderConfiguration()
})
await startup.run('services', () => {
void serverChannelStore.initialize({ possibleEvents: ['ui:configure'], connector: getHostWebSocketConnector }).catch(error => console.error('Mods server initialization failed:', error))
contextBridgeStore.initialize()
characterOrchestratorStore.initialize()
})
await startup.run('modelData', () => displayModelsStore.loadDisplayModelsFromIndexedDB())
await startup.run('modelSelection', () => settingsStore.initializeStageModel())
await startup.run('audio', () => settingsAudioDeviceStore.initialize())
}
catch (error) {
console.error('Startup failed:', error)
}
}
await serverChannelStore.initialize({
possibleEvents: ['ui:configure'],
connector: getHostWebSocketConnector,
}).catch(err => console.error('Failed to initialize Mods Server Channel in App.vue:', err))
contextBridgeStore.initialize()
characterOrchestratorStore.initialize()
onMounted(() => {
void startup.run('route', () => router.isReady()).catch(error => console.error('Initial route failed:', error))
void loadStartup()
})
await displayModelsStore.loadDisplayModelsFromIndexedDB()
await settingsStore.initializeStageModel()
await settingsAudioDeviceStore.initialize()
watch(() => [startup.resources.find(resource => resource.id === 'modelSelection')?.status, startup.resources.find(resource => resource.id === 'route')?.status], ([selection, route]) => {
if (selection !== 'ready' || route !== 'ready' || startup.resources.find(resource => resource.id === 'model')?.status !== 'queued')
return
if (router.currentRoute.value.name !== 'IndexScenePage' || settings.stageModelRenderer.value === 'disabled' || !settings.stageModelSelectedUrl.value)
startup.skip('model')
else
startup.start('model')
})
onUnmounted(() => {
@@ -136,13 +161,16 @@ onUnmounted(() => {
contextBridgeStore.dispose()
})
// Handle first-time setup events
function handleSetupConfigured() {
onboardingStore.markSetupCompleted()
function continueWithoutModel() {
settingsStore.setStageModelRenderer('disabled')
startup.skip('model')
}
function handleSetupSkipped() {
onboardingStore.markSetupSkipped()
function openOnboardingAfterStartup() {
if (onboardingStore.needsOnboarding) {
startupOnboarding.value = true
onboardingStore.showingSetup = true
}
}
const extraSteps = computed(() => [
@@ -159,33 +187,37 @@ const extraSteps = computed(() => [
</script>
<template>
<StageTransitionGroup
:primary-color="primaryColor"
:secondary-color="secondaryColor"
:tertiary-color="tertiaryColor"
:colors="colors"
:z-index="100"
:disable-transitions="settings.disableTransitions.value"
:use-page-specific-transitions="settings.usePageSpecificTransitions.value"
>
<RouterView v-slot="{ Component }">
<KeepAlive :include="['IndexScenePage', 'StageScenePage']">
<component :is="Component" />
</KeepAlive>
</RouterView>
</StageTransitionGroup>
<StartupOverlay logo-src="/favicon.svg" @finished="openOnboardingAfterStartup" @skip-model="continueWithoutModel">
<StageTransitionGroup
:primary-color="primaryColor"
:secondary-color="secondaryColor"
:tertiary-color="tertiaryColor"
:colors="colors"
:z-index="100"
:disable-transitions="settings.disableTransitions.value"
:use-page-specific-transitions="settings.usePageSpecificTransitions.value"
>
<RouterView v-slot="{ Component }">
<KeepAlive :include="['IndexScenePage', 'StageScenePage']">
<component :is="Component" />
</KeepAlive>
</RouterView>
</StageTransitionGroup>
<ToasterRoot @close="id => toast.dismiss(id)">
<Toaster rich-colors />
</ToasterRoot>
<ToasterRoot @close="id => toast.dismiss(id)">
<Toaster rich-colors />
</ToasterRoot>
<!-- First Time Setup Dialog -->
<OnboardingDialog
v-model="showingSetup"
:extra-steps="extraSteps"
@configured="handleSetupConfigured"
@skipped="handleSetupSkipped"
/>
<!-- First Time Setup Dialog -->
<OnboardingDialog
v-model="showingSetup"
:scale-background="false"
:instant-open="startupOnboarding"
:extra-steps="extraSteps"
@configured="onboardingStore.markSetupCompleted()"
@skipped="onboardingStore.markSetupSkipped()"
/>
</StartupOverlay>
</template>
<style>
+19
View File
@@ -18,6 +18,7 @@ import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { useStartupResourcesStore } from '@proj-airi/stage-ui/stores/startup-resources'
import { breakpointsTailwind, useBreakpoints, useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue'
@@ -25,6 +26,22 @@ import { computed, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watc
import WebSocketStatusButton from '../components/websocket-status-button.vue'
const paused = ref(false)
const modelRenderState = ref<'pending' | 'loading' | 'mounted'>('pending')
const modelRenderError = ref<Error>()
const startup = useStartupResourcesStore()
watch([modelRenderState, modelRenderError, () => startup.resources.find(resource => resource.id === 'model')?.status], ([state, error, status]) => {
if (status !== 'loading')
return
if (error)
startup.fail('model', error)
else if (state === 'mounted')
startup.complete('model')
})
function markModelFailed(error: Error) {
modelRenderError.value = error
}
function handleSettingsOpen(open: boolean) {
paused.value = open
@@ -206,6 +223,7 @@ onUnmounted(() => {
<ViewControlSlider />
</div>
<WidgetStage
v-model:state="modelRenderState"
h-full w-full
:enable-orbit-controls="!isMobile"
:paused="paused"
@@ -213,6 +231,7 @@ onUnmounted(() => {
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
@error="markModelFailed"
/>
</div>
<InteractiveArea v-if="!isMobile" h="85dvh" absolute right-4 flex flex-1 flex-col max-w="500px" min-w="30%" />
+1 -3
View File
@@ -7,9 +7,7 @@ export default mergeConfigs([
{
presets: [
presetWebFonts({
fonts: {
...presetWebFontsFonts('fontsource'),
},
fonts: presetWebFontsFonts('fontsource'),
timeouts: {
warning: 5000,
failure: 10000,
+6
View File
@@ -8,6 +8,7 @@ import { execSync } from 'node:child_process'
import { join, resolve } from 'node:path'
import VueI18n from '@intlify/unplugin-vue-i18n/vite'
import messages from '@proj-airi/i18n/locales'
import templateCompilerOptions from '@tresjs/core/template-compiler-options'
import Vue from '@vitejs/plugin-vue'
import Unocss from 'unocss/vite'
@@ -20,6 +21,7 @@ import VueMacros from 'vue-macros/vite'
import VueRouter from 'vue-router/vite'
import { tryCatch } from '@moeru/std'
import { localeRemap, serializeStartupFallbackLocales } from '@proj-airi/i18n'
import { paraformerBilingualZhEn, xAsrBilingualZhEnInt8, zipformerMultilingual } from '@proj-airi/provider-inference/sherpaw-transcription/models'
import { Download } from '@proj-airi/unplugin-fetch/vite'
import { DownloadLive2DSDK } from '@proj-airi/unplugin-live2d-sdk/vite'
@@ -106,6 +108,10 @@ export default defineConfig({
},
plugins: [
{
name: 'airi-startup-locales',
transformIndexHtml: html => html.replace('__AIRI_STARTUP_LOCALES__', serializeStartupFallbackLocales(messages, localeRemap)),
},
...isEnvTruthy(process.env.VITE_SKIP_MKCERT ?? '')
? []
: [mkcert((() => {
+62 -1
View File
@@ -38,13 +38,74 @@
const setting = localStorage.getItem('vueuse-color-scheme') || 'auto'
if (setting === 'dark' || (prefersDark && setting !== 'light'))
document.documentElement.classList.toggle('dark', true)
const savedHue = localStorage.getItem('settings/theme/colors/hue')
const hue = Number(savedHue)
if (savedHue !== null && Number.isFinite(hue))
document.documentElement.style.setProperty('--chromatic-hue', hue.toString())
})()
</script>
<script src="/assets/js/CubismSdkForWeb-5-r.3/Core/live2dcubismcore.min.js"></script>
<style>
#boot-splash {
position: fixed;
inset: 0;
z-index: 9999;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: max(calc(env(safe-area-inset-top) + 48px), calc(40vh - 82px)) 24px calc(env(safe-area-inset-bottom) + clamp(48px, 8vh, 96px));
background: #fff;
color: #262626;
font-family: "Comfortaa Variable", "Comfortaa", ui-sans-serif, system-ui, sans-serif;
}
#boot-splash .brand { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; gap: 20px; }
#boot-splash img { width: 96px; height: 96px; filter: hue-rotate(calc(var(--chromatic-hue, 220.44) * 1deg)); }
#boot-splash strong { font-size: 32px; font-weight: 700; letter-spacing: 0.07em; padding-left: 0.07em; -webkit-text-stroke: 0.5px currentColor; }
#boot-splash .status { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; width: min(220px, 60vw); height: 44px; }
#boot-splash .track-space { width: 48px; height: 3px; margin-top: 28px; }
#boot-splash .boot-error { display: none; position: absolute; bottom: max(calc(env(safe-area-inset-bottom) + 48px), 8vh); flex-direction: column; align-items: center; gap: 12px; max-width: 80vw; text-align: center; font-size: 13px; }
#boot-splash .boot-error button { padding: 10px 20px; border: 0; border-radius: 12px; background: oklch(65% 0.16 var(--chromatic-hue, 220.44)); color: #fff; font: inherit; cursor: pointer; }
#boot-splash.failed .boot-error { display: flex; }
#app:not(:empty) + #boot-splash { display: none; }
html.dark #boot-splash { background: #171717; color: #f5f5f5; }
</style>
<noscript><style>#boot-splash { display: none; }</style></noscript>
</head>
<body class="font-sans">
<div id="app"></div>
<div id="boot-splash" role="status" aria-busy="true">
<div class="brand">
<img src="/favicon.svg" alt="" />
<strong>AIRI</strong>
</div>
<div class="status"><span class="track-space" aria-hidden="true"></span></div>
<div class="boot-error" role="alert"><span>AIRI could not start.</span><button type="button" onclick="location.reload()">Retry</button></div>
</div>
<script src="/assets/js/CubismSdkForWeb-5-r.3/Core/live2dcubismcore.min.js"></script>
<script type="module" src="/src/main.ts"></script>
<script id="boot-splash-locales" type="application/json">__AIRI_STARTUP_LOCALES__</script>
<script>
;(function () {
const data = JSON.parse(document.querySelector('#boot-splash-locales').textContent)
const preferred = localStorage.getItem('settings/language') || navigator.language
const locale = data.localeRemap[preferred] || preferred
const labels = data.labels[locale] || data.labels.en
document.querySelector('#boot-splash .boot-error span').textContent = labels.failed
document.querySelector('#boot-splash .boot-error button').textContent = labels.retry
setTimeout(() => {
if (document.querySelector('#app:empty')) {
const splash = document.querySelector('#boot-splash')
splash?.classList.add('failed')
splash?.setAttribute('aria-busy', 'false')
}
}, 15000)
})()
</script>
<noscript> This website requires JavaScript to function properly. Please enable JavaScript to continue. </noscript>
</body>
</html>
+1
View File
@@ -46,6 +46,7 @@
"@proj-airi/stage-ui-three": "workspace:^",
"@proj-airi/stream-kit": "workspace:^",
"@proj-airi/ui": "workspace:^",
"@proj-airi/ui-loading-screens": "workspace:^",
"@proj-airi/ui-transitions": "workspace:^",
"@tresjs/cientos": "catalog:",
"@tresjs/core": "catalog:",
+92 -59
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@proj-airi/stage-ui/components'
import { OnboardingDialog, OnboardingStepAnalyticsNotice, StartupOverlay, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useInferencePreload } from '@proj-airi/stage-ui/composables'
import { useStartupResourceTimeout } from '@proj-airi/stage-ui/composables/use-startup-resource-timeout'
import { usePiniaSynced } from '@proj-airi/stage-ui/libs/pinia'
import { initializeAnalytics, isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/libs/product-signals'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
@@ -18,12 +19,13 @@ import { useVisionStore } from '@proj-airi/stage-ui/stores/modules/vision'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
import { useStartupResourcesStore } from '@proj-airi/stage-ui/stores/startup-resources'
import { ErrorBoundary, useTheme } from '@proj-airi/ui'
import { StageTransitionGroup } from '@proj-airi/ui-transitions'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterView } from 'vue-router'
import { RouterView, useRouter } from 'vue-router'
import { toast, Toaster } from 'vue-sonner'
import PerformanceOverlay from './components/Devtools/PerformanceOverlay.vue'
@@ -35,6 +37,7 @@ usePWAStore()
const contextBridgeStore = useContextBridgeStore()
const authStore = useAuthStore()
const i18n = useI18n()
const router = useRouter()
const displayModelsStore = useDisplayModelsStore()
const settingsStore = useSettings()
const settings = storeToRefs(settingsStore)
@@ -76,6 +79,15 @@ function registerAuthenticatedSetup() {
}
const inferencePreload = useInferencePreload()
const startup = useStartupResourcesStore()
startup.reset()
startup.register(['auth', 'modelIndex', 'card', 'chat', 'services', 'modelData', 'modelSelection', 'audio', 'route', 'model'])
useStartupResourceTimeout('model', 120_000, () => i18n.t('stage.startup.model-timeout'))
const startupOnboarding = ref(false)
watch(showingSetup, (visible) => {
if (!visible)
startupOnboarding.value = false
})
const primaryColor = computed(() => {
return isDark.value
@@ -117,31 +129,45 @@ watch(settings.themeColorsHueDynamic, () => {
document.documentElement.classList.toggle('dynamic-hue', settings.themeColorsHueDynamic.value)
}, { immediate: true })
// Initialize first-time setup check when app mounts
onMounted(async () => {
initializeAnalytics()
await authStore.initialize()
await displayModelsStore.initialize()
await cardStore.initialize()
registerAuthenticatedSetup()
if (!authStore.isAuthenticated)
await removeAuthenticationProviderConfiguration()
if (onboardingStore.needsOnboarding) {
onboardingStore.showingSetup = true
async function loadStartup() {
try {
initializeAnalytics()
await startup.run('auth', () => authStore.initialize())
await startup.run('modelIndex', () => displayModelsStore.initialize())
await startup.run('card', async () => {
await cardStore.initialize()
registerAuthenticatedSetup()
if (!authStore.isAuthenticated)
await removeAuthenticationProviderConfiguration()
})
await startup.run('chat', () => chatStore.initialize(syncedPinia))
await startup.run('services', () => {
void serverChannelStore.initialize({ possibleEvents: ['ui:configure'] }).catch(error => console.error('Mods server initialization failed:', error))
contextBridgeStore.initialize()
characterOrchestratorStore.initialize()
})
await startup.run('modelData', () => displayModelsStore.loadDisplayModelsFromIndexedDB())
await startup.run('modelSelection', () => settingsStore.initializeStageModel())
await startup.run('audio', () => settingsAudioDeviceStore.initialize())
inferencePreload.triggerPreload()
}
catch (error) {
console.error('Startup failed:', error)
}
}
await chatStore.initialize(syncedPinia)
await serverChannelStore.initialize({ possibleEvents: ['ui:configure'] }).catch(err => console.error('Failed to initialize Mods Server Channel in App.vue:', err))
contextBridgeStore.initialize()
characterOrchestratorStore.initialize()
onMounted(() => {
void startup.run('route', () => router.isReady()).catch(error => console.error('Initial route failed:', error))
void loadStartup()
})
await displayModelsStore.loadDisplayModelsFromIndexedDB()
await settingsStore.initializeStageModel()
await settingsAudioDeviceStore.initialize()
// Preload local inference models (Kokoro TTS, etc.) in background after a delay
inferencePreload.triggerPreload()
watch(() => [startup.resources.find(resource => resource.id === 'modelSelection')?.status, startup.resources.find(resource => resource.id === 'route')?.status], ([selection, route]) => {
if (selection !== 'ready' || route !== 'ready' || startup.resources.find(resource => resource.id === 'model')?.status !== 'queued')
return
if (router.currentRoute.value.name !== 'IndexScenePage' || settings.stageModelRenderer.value === 'disabled' || !settings.stageModelSelectedUrl.value)
startup.skip('model')
else
startup.start('model')
})
onUnmounted(() => {
@@ -151,49 +177,56 @@ onUnmounted(() => {
contextBridgeStore.dispose()
})
// Handle first-time setup events
function handleSetupConfigured() {
onboardingStore.markSetupCompleted()
function continueWithoutModel() {
settingsStore.setStageModelRenderer('disabled')
startup.skip('model')
}
function handleSetupSkipped() {
onboardingStore.markSetupSkipped()
function openOnboardingAfterStartup() {
if (onboardingStore.needsOnboarding) {
startupOnboarding.value = true
onboardingStore.showingSetup = true
}
}
</script>
<template>
<StageTransitionGroup
:primary-color="primaryColor"
:secondary-color="secondaryColor"
:tertiary-color="tertiaryColor"
:colors="colors"
:z-index="100"
:disable-transitions="settings.disableTransitions.value"
:use-page-specific-transitions="settings.usePageSpecificTransitions.value"
>
<RouterView v-slot="{ Component }">
<ErrorBoundary
title="Something went wrong while rendering this page."
@error="(err, _, info) => console.error('[ErrorBoundary]', info, err)"
>
<component :is="Component" />
</ErrorBoundary>
</RouterView>
</StageTransitionGroup>
<StartupOverlay logo-src="/favicon.svg" @finished="openOnboardingAfterStartup" @skip-model="continueWithoutModel">
<StageTransitionGroup
:primary-color="primaryColor"
:secondary-color="secondaryColor"
:tertiary-color="tertiaryColor"
:colors="colors"
:z-index="100"
:disable-transitions="settings.disableTransitions.value"
:use-page-specific-transitions="settings.usePageSpecificTransitions.value"
>
<RouterView v-slot="{ Component }">
<ErrorBoundary
title="Something went wrong while rendering this page."
@error="(err, _, info) => console.error('[ErrorBoundary]', info, err)"
>
<component :is="Component" />
</ErrorBoundary>
</RouterView>
</StageTransitionGroup>
<ToasterRoot @close="id => toast.dismiss(id)">
<Toaster />
</ToasterRoot>
<ToasterRoot @close="id => toast.dismiss(id)">
<Toaster />
</ToasterRoot>
<!-- First Time Setup Dialog -->
<OnboardingDialog
v-model="showingSetup"
:extra-steps="onboardingExtraSteps"
@configured="handleSetupConfigured"
@skipped="handleSetupSkipped"
/>
<!-- First Time Setup Dialog -->
<OnboardingDialog
v-model="showingSetup"
:scale-background="false"
:instant-open="startupOnboarding"
:extra-steps="onboardingExtraSteps"
@configured="onboardingStore.markSetupCompleted()"
@skipped="onboardingStore.markSetupSkipped()"
/>
<PerformanceOverlay />
<PerformanceOverlay />
</StartupOverlay>
</template>
<style>
+19
View File
@@ -18,11 +18,28 @@ import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useHearingSpeechInputPipeline } from '@proj-airi/stage-ui/stores/modules/hearing'
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { useStartupResourcesStore } from '@proj-airi/stage-ui/stores/startup-resources'
import { breakpointsTailwind, useBreakpoints, useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue'
const paused = ref(false)
const modelRenderState = ref<'pending' | 'loading' | 'mounted'>('pending')
const modelRenderError = ref<Error>()
const startup = useStartupResourcesStore()
watch([modelRenderState, modelRenderError, () => startup.resources.find(resource => resource.id === 'model')?.status], ([state, error, status]) => {
if (status !== 'loading')
return
if (error)
startup.fail('model', error)
else if (state === 'mounted')
startup.complete('model')
})
function markModelFailed(error: Error) {
modelRenderError.value = error
}
function handleSettingsOpen(open: boolean) {
paused.value = open
@@ -217,10 +234,12 @@ const cursorPosition = computed(() => ({
<ViewControlSlider />
</div>
<WidgetStage
v-model:state="modelRenderState"
h-full w-full
:cursor-position="cursorPosition"
:enable-orbit-controls="!isMobile"
:paused="paused"
@error="markModelFailed"
/>
</div>
<InteractiveArea v-if="!isMobile" h="85dvh" absolute right-4 flex flex-1 flex-col max-w="500px" min-w="30%" />
+6
View File
@@ -4,6 +4,7 @@ import { execSync } from 'node:child_process'
import { join, resolve } from 'node:path'
import VueI18n from '@intlify/unplugin-vue-i18n/vite'
import messages from '@proj-airi/i18n/locales'
import templateCompilerOptions from '@tresjs/core/template-compiler-options'
import Vue from '@vitejs/plugin-vue'
import Unocss from 'unocss/vite'
@@ -17,6 +18,7 @@ import VueMacros from 'vue-macros/vite'
import VueRouter from 'vue-router/vite'
import { tryCatch } from '@moeru/std'
import { localeRemap, serializeStartupFallbackLocales } from '@proj-airi/i18n'
import { paraformerBilingualZhEn, xAsrBilingualZhEnInt8, zipformerMultilingual } from '@proj-airi/provider-inference/sherpaw-transcription/models'
import { Download } from '@proj-airi/unplugin-fetch/vite'
import { DownloadLive2DSDK } from '@proj-airi/unplugin-live2d-sdk/vite'
@@ -122,6 +124,10 @@ export default defineConfig({
},
plugins: [
{
name: 'airi-startup-locales',
transformIndexHtml: html => html.replace('__AIRI_STARTUP_LOCALES__', serializeStartupFallbackLocales(messages, localeRemap)),
},
...(
hasFlagEnableMkcert()
? [Mkcert((() => {
+2
View File
@@ -47,3 +47,5 @@ export function resolveSupportedLocale(
? normalizedLocale
: fallbackLocale
}
export { serializeStartupFallbackLocales } from './startup-fallback'
+21
View File
@@ -79,6 +79,27 @@ render-error:
{renderer} failed to render the selected model.
Model ID: {modelId}
Error: {error}
startup:
failed: Startup failed
failed-resource: Could not load {resource}
recover: Try again. If it fails again, open the details below.
recover-model: Try again, or continue without a character. You can select another model later.
details: What happened?
close-details: Close details
retry: Retry
continue-without-model: Continue Anyway
model-timeout: The character model took too long to load. Check your connection and retry.
resources:
auth: account settings
modelIndex: model list
card: character settings
chat: chat history
services: app services
modelData: saved models
modelSelection: model settings
audio: audio settings
route: page
model: character model
message: Say something...
send-mode:
title: Send key
@@ -78,6 +78,26 @@ render-error:
{renderer} 无法渲染选中的模型。
模型 ID:{modelId}
错误:{error}
startup:
failed: 启动失败
failed-resource: 无法加载{resource}
recover: 请重试。如果仍然失败,请查看下方的错误详情。
recover-model: 重新启动 AIRI 再试一次,或暂不加载角色,稍后选择其他模型。
details: 发生了什么?
close-details: 关闭详情
retry: 重新启动 AIRI
continue-without-model: 暂不加载角色
resources:
auth: 账户设置
modelIndex: 模型列表
card: 角色设置
chat: 聊天记录
services: 应用服务
modelData: 已保存的模型
modelSelection: 模型设置
audio: 音频设置
route: 页面
model: 角色模型
message: 说点什么...
send-mode:
title: 发送键值
+18
View File
@@ -0,0 +1,18 @@
interface StartupFallbackMessages {
stage?: { startup?: { failed?: string, retry?: string } }
settings?: { dialogs?: { onboarding?: { retry?: string } } }
}
/** Serializes boot error labels for the inline splash that runs before Vue. */
export function serializeStartupFallbackLocales(locales: Record<string, StartupFallbackMessages>, localeRemap: Record<string, string>): string {
const english = locales.en?.stage?.startup
if (!english?.failed || !english.retry)
throw new Error('English startup error labels are required')
const labels = Object.fromEntries(Object.entries(locales).map(([locale, messages]) => [locale, {
failed: messages.stage?.startup?.failed ?? english.failed,
retry: messages.stage?.startup?.retry ?? messages.settings?.dialogs?.onboarding?.retry ?? english.retry,
}]))
return JSON.stringify({ labels, localeRemap }).replaceAll('<', '\\u003c')
}
@@ -7,7 +7,7 @@ import { ref, watch } from 'vue'
import SpineCanvas from './spine/Canvas.vue'
import SpineModel from './spine/Model.vue'
withDefaults(defineProps<{
const props = withDefaults(defineProps<{
/** Scene painted inside the canvas, behind the model. */
backgroundUrl?: string | null
modelSrc?: string
@@ -27,19 +27,31 @@ withDefaults(defineProps<{
renderScale: 1,
})
const emit = defineEmits<{ (e: 'error', error: Error): void }>()
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
const componentStateCanvas = defineModel<'pending' | 'loading' | 'mounted'>('canvasState', { default: 'pending' })
const componentStateModel = defineModel<'pending' | 'loading' | 'mounted'>('modelState', { default: 'pending' })
const modelFailed = ref(false)
watch(() => [props.modelSrc, props.modelId], () => {
modelFailed.value = false
})
const canvasRef = ref<InstanceType<typeof SpineCanvas>>()
const modelRef = ref<InstanceType<typeof SpineModel>>()
watch([componentStateModel, componentStateCanvas], () => {
componentState.value = (componentStateModel.value === 'mounted' && componentStateCanvas.value === 'mounted')
componentState.value = (!modelFailed.value && componentStateModel.value === 'mounted' && componentStateCanvas.value === 'mounted')
? 'mounted'
: 'loading'
})
function reportModelError(error: Error) {
modelFailed.value = true
componentState.value = 'loading'
emit('error', error)
}
defineExpose({
canvasElement: () => canvasRef.value?.canvasElement(),
captureFrame: () => canvasRef.value?.captureFrame(),
@@ -74,6 +86,7 @@ defineExpose({
:default-mix-duration="defaultMixDuration"
:idle-animation-enabled="idleAnimationEnabled"
:max-fps="maxFps"
@error="reportModelError"
/>
</SpineCanvas>
</Screen>
@@ -324,7 +324,6 @@ async function loadModel() {
}
catch (err) {
const error = err instanceof Error ? err : new Error(String(err))
emits('error', error)
reject(error)
}
},
@@ -388,7 +387,6 @@ async function loadModel() {
error: (_sc, errors: Record<string, string>) => {
const message = Object.values(errors).join('; ')
const error = new Error(message)
emits('error', error)
reject(error)
},
}
@@ -402,6 +400,7 @@ async function loadModel() {
// below has already fired and found nothing to draw with.
void syncBackground()
})
componentState.value = 'mounted'
}
catch (err) {
console.error('[Spine] Failed to load model:', err)
@@ -409,7 +408,6 @@ async function loadModel() {
}
finally {
modelLoading.value = false
componentState.value = 'mounted'
modelLoadMutex.release()
}
}
+13
View File
@@ -2,6 +2,19 @@
Shared core for stage
## Startup progress
`useStartupResourcesStore` records each resource as queued, loading, ready, failed, or skipped.
The apps register the complete resource list before work starts. Their startup flows report each module's result through the store.
The app roots reset the store before registration. This also stops an old load from updating a new registration after hot reload.
`StartupOverlay` reads the store and shows splash, progress, or an error with a retry action.
`useStartupResourceTimeout` fails a resource that stays loading past its deadline. Web and Pocket apply it to character model loading.
The optional Mods server connects outside the tracked startup work. Its connection does not block onboarding.
Each app's HTML shows the first splash before Vue mounts. CSS hides it when Vue renders into `#app`.
The home page reports when its character model is ready or fails. A failed model keeps the overlay visible.
If the model fails, the user can retry the app or continue without a character.
The overlay emits `finished` when all resources are ready. Apps open onboarding at that point.
## Chat sampling
In **Settings → Modules → Consciousness**, custom temperature and Top P are off
+1
View File
@@ -106,6 +106,7 @@
"@proj-airi/stage-ui-tachie": "workspace:^",
"@proj-airi/stage-ui-three": "workspace:^",
"@proj-airi/ui": "workspace:^",
"@proj-airi/ui-loading-screens": "workspace:^",
"@proj-airi/vite-plugin-sherpaw": "workspace:^",
"@ricky0123/vad-web": "catalog:",
"@sherpaw/xsai-transcription": "catalog:",
@@ -10,9 +10,13 @@ import Onboarding from './onboarding.vue'
import { useBreakpoints } from '../../../../composables/use-breakpoints'
const props = defineProps<{
const props = withDefaults(defineProps<{
extraSteps?: OnboardingStep[]
}>()
scaleBackground?: boolean
instantOpen?: boolean
}>(), {
scaleBackground: true,
})
const emit = defineEmits<{
(e: 'configured'): void
@@ -31,8 +35,8 @@ onMounted(() => screenSafeArea.update())
<template>
<DialogRoot v-if="isDesktop" :open="showDialog" @update:open="value => showDialog = value">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-9999 bg-black/50 backdrop-blur-sm data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" />
<DialogContent class="fixed left-1/2 top-1/2 z-9999 h-[min(100dvh,48rem)] max-h-full max-w-2xl w-[92dvw] flex flex-col overflow-hidden rounded-2xl bg-white p-6 shadow-xl outline-none backdrop-blur-md scrollbar-none -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:bg-neutral-900">
<DialogOverlay class="fixed inset-0 z-9999 bg-black/50 backdrop-blur-sm data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" :style="props.instantOpen ? { animation: 'none' } : undefined" />
<DialogContent class="fixed left-1/2 top-1/2 z-9999 h-[min(100dvh,48rem)] max-h-full max-w-2xl w-[92dvw] flex flex-col overflow-hidden rounded-2xl bg-white p-6 shadow-xl outline-none backdrop-blur-md scrollbar-none -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:bg-neutral-900" :style="props.instantOpen ? { animation: 'none' } : undefined">
<VisuallyHidden>
<DialogTitle>Onboarding</DialogTitle>
</VisuallyHidden>
@@ -42,9 +46,10 @@ onMounted(() => screenSafeArea.update())
</DialogContent>
</DialogPortal>
</DialogRoot>
<DrawerRoot v-else :open="showDialog" should-scale-background @update:open="value => showDialog = value">
<DrawerRoot v-else :open="showDialog" :should-scale-background="props.scaleBackground" @update:open="value => showDialog = value">
<DrawerPortal>
<DrawerOverlay
:style="props.instantOpen ? { animation: 'none' } : undefined"
:class="[
'fixed inset-0 z-900',
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0',
@@ -52,6 +57,7 @@ onMounted(() => screenSafeArea.update())
]"
/>
<DrawerContent
:data-vaul-animate="props.instantOpen ? 'false' : undefined"
:class="[
'fixed bottom-0 left-0 right-0 z-1000',
'mt-20 px-4 pt-4',
@@ -154,7 +154,7 @@ it('persists configured credentials through the leader and loads the model list'
// The dialog saves the config and then the status, each through its own
// leader call, so the status can reach the leader after the config.
await expect.poll(() => leader.providerConfigStore.providers[providerId]?.status).toBe('configured')
expect(leader.providerConfigStore.addedProviders[providerId]).toBe(true)
await expect.poll(() => leader.providerConfigStore.addedProviders[providerId]).toBe(true)
await expect.poll(() => JSON.parse(localStorage.getItem('settings/providers/configured') ?? '{}')).toMatchObject({
[providerId]: { config: expectedConfig },
@@ -5,5 +5,6 @@ export * from './dialogs'
export * from './hologram'
export * from './providers'
export * from './settings'
export { default as StartupOverlay } from './startup/startup-overlay.vue'
export * from './status'
export * from './toasters'
@@ -0,0 +1,223 @@
import { createPinia } from 'pinia'
import { afterEach, expect, it } from 'vitest'
import { page } from 'vitest/browser'
import { createApp, h } from 'vue'
import { createI18n } from 'vue-i18n'
import StartupOverlay from './startup-overlay.vue'
import { useStartupResourcesStore } from '../../../stores/startup-resources'
import 'virtual:uno.css'
const hosts: HTMLElement[] = []
afterEach(() => {
for (const host of hosts.splice(0))
host.remove()
})
function mountOverlay(onFinished: () => void) {
const host = document.createElement('div')
document.body.appendChild(host)
hosts.push(host)
const pinia = createPinia()
const i18n = createI18n({
legacy: false,
locale: 'en',
fallbackLocale: 'en',
missingWarn: false,
fallbackWarn: false,
messages: {
en: {
stage: {
operations: { 'load-models-status': { loading: 'Loading' } },
startup: {
'failed': 'Startup failed',
'failed-resource': 'Could not load {resource}',
'recover': 'Try again. If it fails again, open the details below.',
'recover-model': 'Try again, or continue without a character. You can select another model later.',
'details': 'What happened?',
'close-details': 'Close details',
'retry': 'Retry',
'continue-without-model': 'Continue Anyway',
'resources': { auth: 'account settings', model: 'character model' },
},
},
},
},
})
const app = createApp({ render: () => h(StartupOverlay, { logoSrc: '/favicon.svg', onFinished }, { default: () => h('main', [h('button', { id: 'covered-action' }, 'Covered action')]) }) })
app.use(pinia)
app.use(i18n)
app.mount(host)
return { app, startup: useStartupResourcesStore(pinia) }
}
it('keeps the startup screen and retry action visible after a resource fails', async () => {
await page.viewport(1440, 900)
let finished = false
const { app, startup } = mountOverlay(() => {
finished = true
})
try {
startup.register(['model'])
startup.start('model')
await expect.poll(() => {
const rect = document.querySelector('.startup-track')?.getBoundingClientRect()
return rect ? Math.round(rect.top + rect.height / 2) : 0
}).toBe(810)
startup.fail('model', new Error('Download failed'))
await expect.poll(() => document.querySelector('[role="alert"]')?.textContent).toContain('Could not load character model')
expect(document.querySelector('.startup-screen')).not.toBeNull()
expect(document.querySelector<HTMLElement>('.startup-covered-content')?.inert).toBe(true)
expect(document.querySelector('.startup-covered-content')?.getAttribute('aria-hidden')).toBe('true')
document.querySelector<HTMLButtonElement>('#covered-action')?.focus()
expect(document.activeElement?.id).not.toBe('covered-action')
expect(document.querySelector('.startup-screen-error .startup-brand')).not.toBeNull()
expect(document.querySelector('.startup-error-hint')?.textContent).toContain('select another model later')
expect(document.querySelector('.startup-status-error [role="progressbar"]')).not.toBeNull()
expect(document.querySelector('.startup-error-recovery')?.textContent).toContain('Retry')
expect(document.querySelector('.startup-error-recovery')?.textContent).toContain('Continue Anyway')
expect(document.querySelector('.startup-error-details-tooltip')).toBeNull()
const detailsTrigger = document.querySelector<HTMLButtonElement>('.startup-error-details-trigger')
expect(detailsTrigger?.textContent).toContain('What happened?')
expect(detailsTrigger!.getBoundingClientRect().width).toBeLessThan(200)
expect(detailsTrigger!.getBoundingClientRect().top).toBeGreaterThan(document.querySelector('.startup-error-hint')!.getBoundingClientRect().bottom)
detailsTrigger?.click()
await expect.poll(() => document.querySelector('[role="tooltip"]')?.textContent).toContain('Download failed')
expect(detailsTrigger?.getAttribute('aria-describedby')).toBe('startup-error-details-tooltip')
expect(document.querySelector('.startup-error-recovery')!.getBoundingClientRect().top).toBeGreaterThan(document.querySelector('[role="tooltip"]')!.getBoundingClientRect().bottom)
const recoveryRect = document.querySelector('.startup-error-recovery')!.getBoundingClientRect()
await expect.poll(() => {
const rect = document.querySelector('.startup-track')!.getBoundingClientRect()
return Math.abs(rect.bottom - window.innerHeight + 16) + Math.abs(rect.left - 16) + Math.abs(rect.width - window.innerWidth + 32)
}).toBeLessThanOrEqual(1)
const progressRect = document.querySelector('.startup-track')!.getBoundingClientRect()
const actions = document.querySelectorAll('.startup-error-action')
expect(Math.abs(progressRect.left - 16)).toBeLessThanOrEqual(1)
expect(Math.abs(progressRect.width - window.innerWidth + 32)).toBeLessThanOrEqual(1)
const infoRect = document.querySelector('.startup-error-info')!.getBoundingClientRect()
expect(recoveryRect.top).toBeGreaterThan(infoRect.bottom)
expect(recoveryRect.top - infoRect.bottom).toBeLessThan(24)
expect(recoveryRect.bottom).toBeLessThan(progressRect.top)
expect(progressRect.top - recoveryRect.bottom).toBeGreaterThan(100)
expect(document.querySelector('.startup-error-header')).toBeNull()
expect(Math.round(recoveryRect.width)).toBe(680)
expect(actions).toHaveLength(2)
expect(actions[0].getBoundingClientRect().width).toBeGreaterThan(300)
expect(document.querySelector('.startup-error-progress-heading')).toBeNull()
document.querySelector<HTMLElement>('.startup-error-title')?.click()
await expect.poll(() => document.querySelector('[role="tooltip"]')).toBeNull()
expect(finished).toBe(false)
}
finally {
app.unmount()
}
})
it('keeps mobile progress near the bottom actions', async () => {
await page.viewport(390, 844)
const { app, startup } = mountOverlay(() => {})
try {
startup.register(['model'])
startup.start('model')
await expect.poll(() => document.querySelector('.startup-track')?.getBoundingClientRect().width).toBeGreaterThan(200)
const loadingProgressCenter = document.querySelector('.startup-track')!.getBoundingClientRect().y + 8
startup.fail('model', new Error('Download failed'))
await expect.poll(() => document.querySelector('.startup-error-title')).not.toBeNull()
const recoveryRect = document.querySelector('.startup-error-recovery')!.getBoundingClientRect()
const actions = document.querySelectorAll('.startup-error-action')
expect(Math.round(recoveryRect.width)).toBe(window.innerWidth - 32)
expect(document.querySelector('.startup-error-header')).toBeNull()
expect(getComputedStyle(document.querySelector('.startup-screen-error .startup-brand')!).display).toBe('none')
expect(actions).toHaveLength(2)
expect(actions[0].getBoundingClientRect().width).toBe(window.innerWidth - 32)
await expect.poll(() => {
const rect = document.querySelector('.startup-track')!.getBoundingClientRect()
return Math.abs(rect.left - 16) + Math.abs(rect.width - window.innerWidth + 32)
}).toBeLessThanOrEqual(1)
const errorProgressRect = document.querySelector('.startup-track')!.getBoundingClientRect()
expect(loadingProgressCenter - errorProgressRect.y - errorProgressRect.height / 2).toBeGreaterThan(20)
expect(Math.abs(errorProgressRect.left - 16)).toBeLessThanOrEqual(1)
expect(Math.abs(errorProgressRect.width - window.innerWidth + 32)).toBeLessThanOrEqual(1)
expect(document.querySelector('.startup-error-title')!.getBoundingClientRect().bottom).toBeLessThan(errorProgressRect.top)
expect(Math.round(recoveryRect.top - errorProgressRect.bottom)).toBe(12)
expect(document.querySelector('.startup-error-progress-heading')).toBeNull()
await page.viewport(320, 568)
await expect.poll(() => {
const rect = document.querySelector('.startup-track')!.getBoundingClientRect()
return Math.abs(rect.width - window.innerWidth + 32)
}).toBeLessThanOrEqual(1)
const progressRect = document.querySelector('.startup-track')!.getBoundingClientRect()
const compactRecoveryRect = document.querySelector('.startup-error-recovery')!.getBoundingClientRect()
expect(Math.abs(progressRect.left - 16)).toBeLessThanOrEqual(1)
expect(Math.abs(progressRect.width - window.innerWidth + 32)).toBeLessThanOrEqual(1)
expect(document.querySelector('.startup-error-details-trigger')!.getBoundingClientRect().bottom).toBeLessThan(progressRect.top)
expect(compactRecoveryRect.top).toBeGreaterThan(document.querySelector('.startup-error-details-trigger')!.getBoundingClientRect().bottom)
expect(Math.round(compactRecoveryRect.top - progressRect.bottom)).toBe(12)
document.querySelector<HTMLButtonElement>('.startup-error-details-trigger')?.click()
await expect.poll(() => document.querySelector('.startup-error-details-drawer')?.textContent).toContain('Download failed')
const detailsRect = document.querySelector('.startup-error-details-drawer')!.getBoundingClientRect()
expect(detailsRect.left).toBe(0)
expect(detailsRect.right).toBe(window.innerWidth)
await expect.poll(() => Math.round(document.querySelector('.startup-error-details-drawer')!.getBoundingClientRect().bottom)).toBe(window.innerHeight)
document.querySelector<HTMLButtonElement>('.startup-error-details-close')?.click()
await expect.poll(() => document.querySelector('.startup-error-details-drawer')).toBeNull()
}
finally {
app.unmount()
await page.viewport(1440, 900)
}
})
it('offers Retry without a continue action when a required resource fails', async () => {
await page.viewport(390, 844)
const { app, startup } = mountOverlay(() => {})
try {
startup.register(['auth'])
startup.start('auth')
startup.fail('auth', new Error('Account setup failed'))
await expect.poll(() => document.querySelector('[role="alert"]')?.textContent).toContain('Could not load account settings')
expect(document.querySelector('.startup-error-recovery')?.textContent).toContain('Retry')
expect(document.querySelector('.startup-error-recovery')?.textContent).not.toContain('Continue Anyway')
await expect.poll(() => document.querySelector('.startup-error-title')).not.toBeNull()
const progressRect = document.querySelector('.startup-track')!.getBoundingClientRect()
const recoveryRect = document.querySelector('.startup-error-recovery')!.getBoundingClientRect()
expect(progressRect.left).toBe(16)
expect(progressRect.width).toBe(window.innerWidth - 32)
expect(document.querySelector('.startup-error-header')).toBeNull()
expect(Math.round(recoveryRect.top - progressRect.bottom)).toBe(12)
}
finally {
app.unmount()
await page.viewport(1440, 900)
}
})
it('finishes only after all resources complete', async () => {
let finished = false
const { app, startup } = mountOverlay(() => {
finished = true
})
try {
startup.register(['auth', 'model'])
startup.start('auth')
startup.complete('auth')
await expect.poll(() => document.querySelector('[role="progressbar"]')?.getAttribute('aria-valuenow')).toBe('50')
expect(finished).toBe(false)
startup.start('model')
startup.complete('model')
await expect.poll(() => finished).toBe(true)
expect(document.querySelector<HTMLElement>('.startup-covered-content')?.inert).toBe(false)
document.querySelector<HTMLButtonElement>('#covered-action')?.focus()
expect(document.activeElement?.id).toBe('covered-action')
}
finally {
app.unmount()
}
})
@@ -0,0 +1,72 @@
<script setup lang="ts">
import StartupScreen from '@proj-airi/ui-loading-screens/startup-screen'
import { storeToRefs } from 'pinia'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useStartupResourcesStore } from '../../../stores/startup-resources'
defineProps<{ logoSrc: string }>()
const emit = defineEmits<{
(e: 'finished'): void
(e: 'skipModel'): void
}>()
const { t } = useI18n()
const startup = useStartupResourcesStore()
const { progress, failed, ready } = storeToRefs(startup)
const errorTitle = computed(() => failed.value
? t('stage.startup.failed-resource', { resource: t(`stage.startup.resources.${failed.value.id}`) })
: t('stage.startup.failed'))
const phase = ref<'splash' | 'loading' | 'done'>('splash')
const opened = ref(false)
let opening: Promise<void>
function retry() {
window.location.reload()
}
onMounted(() => {
opening = new Promise(resolve => setTimeout(resolve, 500))
phase.value = 'loading'
opened.value = true
})
watch([ready, opened], async ([isReady, isOpen]) => {
if (!isReady || !isOpen || phase.value === 'done')
return
await opening
await nextTick()
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
phase.value = 'done'
emit('finished')
})
</script>
<template>
<div class="startup-covered-content" :inert="phase !== 'done'" :aria-hidden="phase !== 'done' ? 'true' : undefined">
<slot />
</div>
<StartupScreen
:phase="failed ? 'error' : phase"
:progress="progress"
:logo-src="logoSrc"
:label="t('stage.operations.load-models-status.loading')"
:error-title="errorTitle"
:error-hint="t(failed?.id === 'model' ? 'stage.startup.recover-model' : 'stage.startup.recover')"
:error-message="failed?.error"
:error-details-label="t('stage.startup.details')"
:error-details-close-label="t('stage.startup.close-details')"
:retry-label="t('stage.startup.retry')"
:alternative-label="failed?.id === 'model' ? t('stage.startup.continue-without-model') : undefined"
@retry="retry"
@alternative="emit('skipModel')"
/>
</template>
<style scoped>
.startup-covered-content {
display: contents;
}
</style>
@@ -10,7 +10,7 @@ import type { EmotionPayload } from '../../constants/emotions'
import type { SpeechTransport, StageTtsSession, StreamingSessionSnapshot } from '../../libs/speech/tts-session'
import { defineInvokeHandler } from '@moeru/eventa'
import { sleep } from '@moeru/std'
import { errorMessageFrom, sleep } from '@moeru/std'
import { createLive2DLipSync } from '@proj-airi/model-driver-lipsync'
import { wlipsyncProfile } from '@proj-airi/model-driver-lipsync/shared/wlipsync'
import { createPlaybackManager, createSpeechPipeline, normalizeActPayload } from '@proj-airi/pipelines-audio'
@@ -64,6 +64,7 @@ const props = withDefaults(defineProps<{
paused: false,
})
const emit = defineEmits<{ error: [error: Error] }>()
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
const { getDb } = useDuckDb()
@@ -187,6 +188,12 @@ const viewUpdateCleanups: Array<() => void> = []
function handleStageRenderError(error: Error) {
stageRenderError.value = error
emit('error', error)
}
function reportStageRenderError(error: unknown) {
console.error(error)
handleStageRenderError(new Error(errorMessageFrom(error) ?? 'Failed to render stage'))
}
async function retryStageRenderer() {
@@ -1083,7 +1090,7 @@ defineExpose({
:enable-orbit-controls="props.enableOrbitControls"
:audio-context="audioContext"
:current-audio-source="currentAudioSource"
@error="console.error"
@error="reportStageRenderError"
@vrm-interact="onVRMInteract"
/>
<SpineScene
@@ -1101,6 +1108,7 @@ defineExpose({
:idle-animation-enabled="spineIdleAnimationEnabled"
:max-fps="spineMaxFps"
:render-scale="spineRenderScale"
@error="reportStageRenderError"
/>
<TachieScene
v-if="stageModelRenderer === 'tachie' && showStage"
@@ -1114,7 +1122,7 @@ defineExpose({
:paused="paused"
:theme-colors-hue="themeColorsHue"
:theme-colors-hue-dynamic="themeColorsHueDynamic"
@error="console.error"
@error="reportStageRenderError"
/>
<MMDScene
v-if="stageModelRenderer === 'mmd' && showStage"
@@ -1130,7 +1138,7 @@ defineExpose({
:enable-orbit-controls="props.enableOrbitControls"
:audio-context="audioContext"
:current-audio-source="currentAudioSource"
@error="console.error"
@error="reportStageRenderError"
/>
<div
v-if="stageModelRenderer === 'godot'"
@@ -1156,7 +1164,7 @@ defineExpose({
<StageRenderError
v-if="stageRenderError"
:error="stageRenderError"
renderer="Live2D"
:renderer="stageModelRenderer === 'live2d' ? 'Live2D' : stageModelRenderer ?? 'Model'"
:model-id="stageModelSelected"
@retry="retryStageRenderer"
/>
@@ -0,0 +1,44 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { effectScope, nextTick } from 'vue'
import { useStartupResourcesStore } from '../stores/startup-resources'
import { useStartupResourceTimeout } from './use-startup-resource-timeout'
beforeEach(() => {
setActivePinia(createPinia())
vi.useFakeTimers()
})
afterEach(() => vi.useRealTimers())
it('fails a model resource that stays loading past its deadline', async () => {
const startup = useStartupResourcesStore()
startup.register(['model'])
const scope = effectScope()
scope.run(() => useStartupResourceTimeout('model', 1000, () => 'Model load timed out'))
startup.start('model')
await nextTick()
await vi.advanceTimersByTimeAsync(1000)
expect(startup.failed?.error).toBe('Model load timed out')
scope.stop()
})
it('clears the deadline when the model becomes ready', async () => {
const startup = useStartupResourcesStore()
startup.register(['model'])
const scope = effectScope()
scope.run(() => useStartupResourceTimeout('model', 1000, () => 'Model load timed out'))
startup.start('model')
await nextTick()
startup.complete('model')
await nextTick()
await vi.advanceTimersByTimeAsync(1000)
expect(startup.ready).toBe(true)
expect(startup.failed).toBeUndefined()
scope.stop()
})
@@ -0,0 +1,20 @@
import { watch } from 'vue'
import { useStartupResourcesStore } from '../stores/startup-resources'
/** Fails a startup resource if it stays loading past its deadline. */
export function useStartupResourceTimeout(id: string, timeoutMs: number, message: () => string) {
const startup = useStartupResourcesStore()
watch(() => startup.resources.find(resource => resource.id === id)?.status, (status, _, onCleanup) => {
if (status !== 'loading')
return
const timer = setTimeout(() => {
if (startup.resources.find(resource => resource.id === id)?.status === 'loading')
startup.fail(id, new Error(message()))
}, timeoutMs)
onCleanup(() => clearTimeout(timer))
})
}
@@ -0,0 +1,79 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useStartupResourcesStore } from './startup-resources'
describe('startup resources', () => {
beforeEach(() => setActivePinia(createPinia()))
it('counts completed resources and keeps a failed model blocking', () => {
const startup = useStartupResourcesStore()
startup.register(['config', 'model'])
startup.start('config')
startup.complete('config')
startup.start('model')
startup.fail('model', new Error('Model download failed'))
expect(startup.progress).toBe(50)
expect(startup.ready).toBe(false)
expect(startup.failed?.error).toBe('Model download failed')
})
it('records an initialization rejection before propagating it', async () => {
const startup = useStartupResourcesStore()
startup.register(['auth'])
await expect(startup.run('auth', async () => {
throw new Error('Offline')
})).rejects.toThrow('Offline')
expect(startup.failed?.id).toBe('auth')
expect(startup.progress).toBe(0)
})
it('allows a model resource to be skipped when no model is selected', () => {
const startup = useStartupResourcesStore()
startup.register(['config', 'model'])
startup.start('config')
startup.complete('config')
startup.skip('model')
expect(startup.ready).toBe(true)
expect(startup.progress).toBe(100)
})
it('recovers when the user skips a failed character model', () => {
const startup = useStartupResourcesStore()
startup.register(['model'])
startup.start('model')
startup.fail('model', new Error('Corrupt archive'))
startup.skip('model')
expect(startup.failed).toBeUndefined()
expect(startup.ready).toBe(true)
})
it('rejects duplicate and unknown resource IDs', () => {
const startup = useStartupResourcesStore()
expect(() => startup.register(['auth', 'auth'])).toThrow('unique')
startup.register(['auth'])
expect(() => startup.start('missing')).toThrow('Unknown')
})
it('does not let an earlier startup run update a new registration', async () => {
const startup = useStartupResourcesStore()
startup.register(['auth'])
let finishLoad: (() => void) | undefined
const pending = startup.run('auth', () => new Promise<void>((resolve) => {
finishLoad = resolve
}))
startup.reset()
startup.register(['auth'])
startup.start('auth')
finishLoad?.()
await expect(pending).rejects.toThrow('reset')
expect(startup.resources[0]?.status).toBe('loading')
expect(startup.failed).toBeUndefined()
})
})
@@ -0,0 +1,91 @@
import { errorMessageFrom } from '@moeru/std'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export type StartupResourceStatus = 'queued' | 'loading' | 'ready' | 'failed' | 'skipped'
export interface StartupResource {
id: string
status: StartupResourceStatus
error?: string
}
/** Owns one page load's resource states. Register the full list before work starts. */
export const useStartupResourcesStore = defineStore('startup-resources', () => {
const resources = ref<StartupResource[]>([])
let generation = 0
const progress = computed(() => resources.value.length === 0
? 0
: Math.round(resources.value.filter(resource => resource.status === 'ready' || resource.status === 'skipped').length / resources.value.length * 100))
const failed = computed(() => resources.value.find(resource => resource.status === 'failed'))
const ready = computed(() => resources.value.length > 0 && resources.value.every(resource => resource.status === 'ready' || resource.status === 'skipped'))
function register(ids: readonly string[]) {
if (resources.value.length > 0)
throw new Error('Startup resources are already registered')
if (new Set(ids).size !== ids.length)
throw new Error('Startup resource IDs must be unique')
resources.value = ids.map(id => ({ id, status: 'queued' }))
}
function reset() {
generation += 1
resources.value = []
}
function resource(id: string) {
const item = resources.value.find(item => item.id === id)
if (!item)
throw new Error(`Unknown startup resource: ${id}`)
return item
}
function start(id: string) {
const item = resource(id)
if (item.status !== 'queued')
throw new Error(`Cannot start ${id} from ${item.status}`)
item.status = 'loading'
}
function complete(id: string) {
const item = resource(id)
if (item.status !== 'loading')
throw new Error(`Cannot complete ${id} from ${item.status}`)
item.status = 'ready'
}
function fail(id: string, error: unknown) {
const item = resource(id)
if (item.status !== 'loading')
throw new Error(`Cannot fail ${id} from ${item.status}`)
item.status = 'failed'
item.error = errorMessageFrom(error)
}
function skip(id: string) {
const item = resource(id)
if (item.status !== 'queued' && item.status !== 'failed')
throw new Error(`Cannot skip ${id} from ${item.status}`)
item.status = 'skipped'
item.error = undefined
}
/** Runs one resource and records a failure before the caller handles the rejection. */
async function run(id: string, load: () => Promise<unknown> | unknown) {
const currentGeneration = generation
start(id)
try {
await load()
if (currentGeneration !== generation)
throw new Error('Startup resources were reset during loading')
complete(id)
}
catch (error) {
if (currentGeneration === generation)
fail(id, error)
throw error
}
}
return { resources, progress, failed, ready, register, reset, start, complete, fail, skip, run }
})
+1 -4
View File
@@ -1,11 +1,8 @@
import { defineConfig, mergeConfigs } from 'unocss'
import { mergeConfigs } from 'unocss'
import { histoireUnoConfig, sharedUnoConfig } from '../../uno.config'
export default mergeConfigs([
sharedUnoConfig(),
histoireUnoConfig(),
defineConfig({
// All font configurations are now inherited from the root uno.config.ts
}),
])
+3 -3
View File
@@ -6,6 +6,7 @@ import Info from 'unplugin-info/vite'
import VueRouter from 'vue-router/vite'
import { playwright } from '@vitest/browser-playwright'
import { mergeConfigs } from 'unocss'
import { loadEnv } from 'vite'
import { defineConfig } from 'vitest/config'
@@ -23,9 +24,8 @@ export default defineConfig({
// Use the app's route-block transform when browser tests mount shared pages.
VueRouter({ routesFolder: [], dts: false }),
Vue(),
UnoCSS({
UnoCSS(mergeConfigs([sharedUnoConfig(), {
// Browser tests use product styles, not Histoire's hover-preview variants.
...sharedUnoConfig(),
configFile: false,
// Vitest loads components after the stylesheet. Scan their source before
// the initial CSS response instead of relying on Vite's HMR updates.
@@ -35,7 +35,7 @@ export default defineConfig({
`${import.meta.dirname}/../ui/src/**/*.vue`,
],
},
}),
}])),
],
test: {
env: loadEnv('test', cwd(), ''),
+27 -4
View File
@@ -1,10 +1,33 @@
# @proj-airi/ui-loading-screens
<p align="center">
[<a href="https://proj-airi-packages-ui-loading-screens.netlify.app/">Try it</a>]
</p>
This package provides loading screens for AIRI apps. [View the playground](https://proj-airi-packages-ui-loading-screens.netlify.app/).
The loading screens to show during long-time application startups.
## Use
Import `StartupScreen` from `@proj-airi/ui-loading-screens/startup-screen`. Give it the phase, progress, logo URL, and translated labels.
```vue
<StartupScreen phase="loading" :progress="50" logo-src="/favicon.svg" :label="t('stage.operations.load-models-status.loading')" :error-title="t('stage.startup.failed')" :error-hint="t('stage.startup.recover')" :error-details-label="t('stage.startup.details')" :error-details-close-label="t('stage.startup.close-details')" :retry-label="t('stage.startup.retry')" />
```
The `splash` phase shows the logo. The `loading` phase shows progress near the lower edge.
The `error` phase shows the error title, guidance, progress bar, and recovery actions.
Desktop places the actions below the error text and keeps progress at the bottom.
Mobile hides the logo and places progress just above the bottom actions. Error progress has no percentage label.
The small details button opens a desktop tooltip or a mobile bottom drawer. The `done` phase removes the screen.
The screen uses Comfortaa for the brand and content.
The progress bar uses `Progress` from `@proj-airi/ui`.
`StartupOverlay` reads the resource state and supplies the error text.
The optional `alternative-label` adds a second action for a recoverable failure.
## When to use it
Use `StartupScreen` while an app waits for the startup tasks that it tracks. Report progress from completed tasks.
## When not to use it
Do not use startup progress for downloads without completion signals. Show a separate loading state for later model changes.
## License
+4 -1
View File
@@ -17,6 +17,7 @@
"exports": {
".": "./src/index.ts",
"./components": "./src/components/index.ts",
"./startup-screen": "./src/components/startup-screen.vue",
"./*": "./*"
},
"scripts": {
@@ -27,15 +28,17 @@
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@fontsource-variable/comfortaa": "catalog:",
"@moeru/std": "catalog:",
"@proj-airi/font-departure-mono": "workspace:^",
"@proj-airi/ui": "workspace:^",
"@rive-app/canvas-lite": "catalog:",
"@vueuse/core": "catalog:",
"@vueuse/motion": "catalog:",
"vaul-vue": "catalog:",
"vue": "catalog:"
},
"devDependencies": {
"@proj-airi/ui": "workspace:^",
"@unocss/reset": "catalog:",
"@vitejs/plugin-vue": "catalog:",
"vite": "catalog:",
@@ -1,2 +1,3 @@
export { default as LoadingLogoWithBar } from './LoadingLogoWithBar.vue'
export { default as LoadingSciFiCircle } from './LoadingSciFiCircle/index.vue'
export { default as StartupScreen } from './startup-screen.vue'
@@ -0,0 +1,216 @@
<script setup lang="ts">
import { Button } from '@proj-airi/ui'
import { onClickOutside, useMediaQuery } from '@vueuse/core'
import { DrawerContent, DrawerDescription, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTitle } from 'vaul-vue'
import { shallowRef, useTemplateRef, watch } from 'vue'
defineProps<{
label: string
closeLabel: string
message: string
}>()
const isDesktop = useMediaQuery('(min-width: 768px)')
const open = shallowRef(false)
const rootElement = useTemplateRef('rootElement')
onClickOutside(rootElement, () => {
if (isDesktop.value)
open.value = false
})
watch(isDesktop, () => open.value = false)
</script>
<template>
<div ref="rootElement" class="startup-error-details">
<Button
type="button"
size="unset"
color="red"
variant="secondary"
:outline="false"
class="startup-error-details-trigger"
:aria-expanded="open"
:aria-controls="isDesktop ? 'startup-error-details-tooltip' : 'startup-error-details-drawer'"
:aria-describedby="isDesktop && open ? 'startup-error-details-tooltip' : undefined"
:aria-haspopup="isDesktop ? undefined : 'dialog'"
@click="open = !open"
@keydown.esc="open = false"
>
<span i-solar:question-circle-linear aria-hidden="true" />
{{ label }}
</Button>
<div v-if="isDesktop && open" id="startup-error-details-tooltip" role="tooltip" class="startup-error-details-tooltip">
{{ message }}
</div>
</div>
<DrawerRoot v-if="!isDesktop" v-model:open="open" handle-only>
<DrawerPortal>
<DrawerOverlay class="startup-error-details-overlay" />
<DrawerContent id="startup-error-details-drawer" class="startup-error-details-drawer">
<DrawerHandle class="startup-error-details-handle" />
<div class="startup-error-details-drawer-heading">
<DrawerTitle class="startup-error-details-drawer-title">
{{ label }}
</DrawerTitle>
<button type="button" class="startup-error-details-close" :aria-label="closeLabel" @click="open = false">
<span i-solar:close-circle-linear aria-hidden="true" />
</button>
</div>
<DrawerDescription class="startup-error-details-message">
{{ message }}
</DrawerDescription>
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
</template>
<style scoped>
.startup-error-details {
position: relative;
width: fit-content;
margin: 12px 24px 0;
}
.startup-error-details-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border: 1px solid #ef44443d;
border-radius: 6px;
background: #ef44440a;
color: #dc2626;
cursor: help;
font: inherit;
font-size: 11px;
}
.startup-error-details-trigger:focus-visible,
.startup-error-details-close:focus-visible {
outline: 2px solid currentColor;
outline-offset: 3px;
}
.startup-error-details-tooltip {
position: relative;
z-index: 2;
box-sizing: border-box;
width: max-content;
max-width: min(320px, calc(100vw - 48px));
margin-top: 8px;
padding: 12px;
border-radius: 8px;
background: #171717;
box-shadow: 0 8px 24px #0003;
color: #fafafa;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.startup-error-details-tooltip::before {
content: '';
position: absolute;
bottom: 100%;
left: 12px;
border-right: 6px solid transparent;
border-bottom: 6px solid #171717;
border-left: 6px solid transparent;
}
.startup-error-details-overlay {
position: fixed;
inset: 0;
z-index: 10001;
background: #0006;
}
.startup-error-details-drawer {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 10002;
box-sizing: border-box;
max-height: 70dvh;
padding: 12px 20px max(24px, env(safe-area-inset-bottom));
overflow-y: auto;
border-radius: 20px 20px 0 0;
background: #fafafa;
box-shadow: 0 -8px 24px #0002;
color: #262626;
outline: none;
}
.startup-error-details-handle {
margin-bottom: 16px;
background: #d4d4d4;
}
.startup-error-details-drawer-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.startup-error-details-drawer-title {
font-size: 18px;
font-weight: 700;
}
.startup-error-details-close {
display: grid;
width: 32px;
height: 32px;
padding: 0;
place-items: center;
border: 0;
background: transparent;
color: #737373;
cursor: pointer;
font-size: 22px;
}
.startup-error-details-message {
margin: 16px 0 0;
color: #525252;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
:global(html.dark .startup-error-details-trigger) {
border-color: #f8717159;
background: #f8717114;
color: #f87171;
}
:global(html.dark .startup-error-details-drawer) {
background: #262626;
color: #f5f5f5;
}
:global(html.dark .startup-error-details-handle) {
background: #525252;
}
:global(html.dark .startup-error-details-message) {
color: #d4d4d4;
}
@media (max-width: 600px) {
.startup-error-details {
margin-right: 16px;
margin-left: 16px;
}
.startup-error-details-trigger {
cursor: pointer;
}
}
</style>
@@ -0,0 +1,393 @@
<script setup lang="ts">
import { Button, Progress } from '@proj-airi/ui'
import { nextTick, useTemplateRef, watch } from 'vue'
import StartupErrorDetails from './startup-error-details.vue'
import '@fontsource-variable/comfortaa/wght.css'
/** Shows startup progress and retains failures until the user retries. */
const props = defineProps<{
phase: 'splash' | 'loading' | 'error' | 'done'
progress: number
logoSrc: string
label: string
errorTitle: string
errorHint: string
errorMessage?: string
errorDetailsLabel: string
errorDetailsCloseLabel: string
retryLabel: string
alternativeLabel?: string
}>()
const emit = defineEmits<{
(e: 'retry'): void
(e: 'alternative'): void
}>()
const trackElement = useTemplateRef<HTMLElement>('trackElement')
watch(() => props.phase, async (phase, previous, onCleanup) => {
if (phase !== 'error' || previous !== 'loading' || window.matchMedia('(prefers-reduced-motion: reduce)').matches)
return
let animation: Animation | undefined
let active = true
onCleanup(() => {
active = false
animation?.cancel()
})
const track = trackElement.value
const before = track?.getBoundingClientRect()
await nextTick()
if (!active || !track || !before)
return
// Move the track between its measured loading and error positions.
const after = track.getBoundingClientRect()
animation = track.animate([
{ transform: `translate(${before.left - after.left}px, ${before.top - after.top}px) scaleX(${before.width / after.width})` },
{ transform: 'none' },
], { duration: 500, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' })
})
</script>
<template>
<Teleport to="body">
<Transition name="startup-exit">
<section v-if="phase !== 'done'" class="startup-screen" :class="{ 'startup-screen-error': phase === 'error' }">
<div class="startup-brand">
<img class="startup-logo" :src="logoSrc" alt="">
<strong class="startup-name">AIRI</strong>
</div>
<div v-if="phase === 'error'" class="startup-error-info" role="alert">
<h2 class="startup-error-title">
{{ errorTitle }}
</h2>
<p class="startup-error-hint">
{{ errorHint }}
</p>
<StartupErrorDetails
v-if="errorMessage"
:label="errorDetailsLabel"
:close-label="errorDetailsCloseLabel"
:message="errorMessage"
/>
</div>
<div v-if="phase === 'error'" class="startup-error-recovery">
<Button class="startup-error-action" color="primary" variant="primary" @click="emit('retry')">
{{ retryLabel }}
</Button>
<Button v-if="alternativeLabel" class="startup-error-action" @click="emit('alternative')">
{{ alternativeLabel }}
</Button>
</div>
<div
class="startup-status"
:class="{
'startup-status-error': phase === 'error',
}"
>
<span v-if="phase === 'loading'" class="startup-label">{{ label }}</span>
<div
ref="trackElement"
class="startup-track"
:class="{ 'startup-track-loading': phase === 'loading' || phase === 'error' }"
:role="phase === 'loading' || phase === 'error' ? 'progressbar' : undefined"
:aria-label="phase === 'error' ? errorTitle : label"
:aria-valuemin="phase === 'loading' || phase === 'error' ? 0 : undefined"
:aria-valuemax="phase === 'loading' || phase === 'error' ? 100 : undefined"
:aria-valuenow="phase === 'loading' || phase === 'error' ? progress : undefined"
>
<Progress v-if="phase === 'loading' || phase === 'error'" :progress="progress" :bar-class="phase === 'error' ? 'bg-red-500 dark:bg-red-400' : undefined" class="startup-progress" />
</div>
</div>
</section>
</Transition>
</Teleport>
</template>
<style scoped>
.startup-screen {
position: fixed;
inset: 0;
z-index: 10000;
box-sizing: border-box;
background: #fff;
color: #262626;
font-family: "Comfortaa Variable", "Comfortaa", ui-sans-serif, system-ui, sans-serif;
}
:global(html.dark .startup-screen) {
background: #171717;
color: #f5f5f5;
}
.startup-screen-error {
display: grid;
grid-template-rows: minmax(0, 1fr) auto auto minmax(0, 1fr) auto;
gap: 16px;
padding: max(env(safe-area-inset-top), 16px) 16px max(env(safe-area-inset-bottom), 16px);
overflow-y: auto;
}
.startup-brand {
position: absolute;
top: max(calc(env(safe-area-inset-top) + 48px), calc(40vh - 82px));
left: 50%;
width: 180px;
height: 156px;
transform: translateX(-50%);
transition: top 500ms cubic-bezier(0.22, 1, 0.36, 1), left 500ms cubic-bezier(0.22, 1, 0.36, 1), transform 500ms cubic-bezier(0.22, 1, 0.36, 1), width 500ms ease, height 500ms ease;
}
.startup-logo {
position: absolute;
top: 0;
left: 50%;
width: 96px;
height: 96px;
filter: hue-rotate(calc(var(--chromatic-hue, 220.44) * 1deg));
transform: translateX(-50%);
transition: left 500ms ease, width 500ms ease, height 500ms ease, transform 500ms ease;
}
.startup-name {
position: absolute;
top: 116px;
left: 50%;
padding-left: 0.07em;
font-size: 32px;
font-weight: 700;
letter-spacing: 0.07em;
white-space: nowrap;
-webkit-text-stroke: 0.5px currentColor;
transform: translateX(-50%);
transition: top 500ms ease, left 500ms ease, transform 500ms ease, font-size 500ms ease, letter-spacing 500ms ease;
}
.startup-screen-error .startup-brand {
top: calc(env(safe-area-inset-top) + 24px);
left: 24px;
width: 140px;
height: 36px;
transform: none;
}
.startup-screen-error .startup-logo {
left: 0;
width: 36px;
height: 36px;
transform: none;
}
.startup-screen-error .startup-name {
top: 7px;
left: 46px;
font-size: 20px;
letter-spacing: 0.04em;
transform: none;
}
.startup-status {
position: absolute;
top: calc(90% - 14px);
left: 50%;
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
width: min(220px, 60vw);
height: 44px;
transform: translate(-50%, -50%);
transition: top 500ms cubic-bezier(0.22, 1, 0.36, 1), width 500ms cubic-bezier(0.22, 1, 0.36, 1), transform 500ms cubic-bezier(0.22, 1, 0.36, 1);
}
.startup-status-error {
position: relative;
top: auto;
left: auto;
grid-row: 5;
align-items: stretch;
width: 100%;
height: 16px;
transform: none;
transition: none;
}
.startup-error-info {
grid-row: 2;
align-self: center;
justify-self: center;
width: min(680px, 100%);
padding-top: 24px;
animation: startup-error-fade-in 400ms 100ms both;
}
.startup-track {
position: relative;
width: 48px;
height: 3px;
margin-top: 28px;
overflow: hidden;
border-radius: 999px;
opacity: 0;
transition: width 500ms cubic-bezier(0.34, 1.36, 0.64, 1), height 500ms cubic-bezier(0.34, 1.36, 0.64, 1), opacity 250ms ease-out;
}
.startup-track-loading {
width: 100%;
height: 16px;
opacity: 1;
}
.startup-status-error .startup-track {
width: 100%;
margin: 0;
transform-origin: top left;
transition: none;
}
.startup-progress {
width: 100%;
}
.startup-label {
position: absolute;
top: 0;
left: 0;
width: 100%;
overflow: hidden;
color: #737373;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.startup-error-title {
margin: 0 24px;
font-size: clamp(24px, 3vw, 32px);
font-weight: 700;
line-height: 1.35;
}
.startup-error-hint {
max-width: 560px;
margin: 10px 24px 0;
color: #737373;
font-size: 13px;
line-height: 1.6;
}
.startup-error-recovery {
grid-row: 3;
justify-self: center;
display: flex;
gap: 12px;
width: min(680px, 100%);
animation: startup-error-fade-in 400ms 100ms both;
}
.startup-error-action {
min-width: 0;
flex: 1;
}
:global(html.dark .startup-error-hint) {
color: #a3a3a3;
}
@keyframes startup-error-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
:global(html.dark .startup-label) {
color: #a3a3a3;
}
.startup-exit-leave-active {
transition: opacity 250ms ease-out;
}
.startup-exit-leave-to {
opacity: 0;
}
@media (max-height: 650px) {
.startup-screen-error .startup-brand {
top: calc(env(safe-area-inset-top) + 16px);
height: 28px;
}
.startup-screen-error .startup-logo {
width: 28px;
height: 28px;
}
.startup-screen-error .startup-name {
top: 3px;
left: 38px;
}
}
@media (max-width: 600px) {
.startup-screen-error .startup-brand {
display: none;
}
.startup-screen-error {
grid-template-rows: minmax(min-content, 1fr) auto auto;
gap: 12px;
}
.startup-error-info {
grid-row: 1;
}
.startup-status-error {
grid-row: 2;
}
.startup-error-title {
margin-right: 16px;
margin-left: 16px;
}
.startup-error-hint {
margin-right: 16px;
margin-left: 16px;
}
.startup-error-recovery {
grid-row: 3;
display: grid;
width: 100%;
}
.startup-error-action {
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.startup-track {
transition: none;
}
.startup-brand,
.startup-logo,
.startup-name,
.startup-status {
transition: none;
}
.startup-error-info,
.startup-error-recovery {
animation: none;
}
.startup-exit-leave-active {
transition: none;
}
}
</style>
+9 -2
View File
@@ -8,8 +8,8 @@ defineProps<{
<template>
<div relative overflow-hidden rounded-md>
<div
:class="[barClass ? barClass : 'bg-primary-300 dark:bg-primary-300/50']"
absolute h-4 min-w-2 rounded-md will-change-width
:class="[barClass ? barClass : 'bg-primary-300 dark:bg-primary-300/50', progress > 0 && 'min-w-2']"
absolute h-4 rounded-md will-change-width
:style="{ width: `${progress}%` }"
transition="width duration-500 ease-in-out"
>
@@ -31,6 +31,13 @@ defineProps<{
will-change: transform, opacity;
}
@media (prefers-reduced-motion: reduce) {
.progress-shine-animation {
animation: none;
opacity: 0;
}
}
@keyframes progress-shine {
0% {
opacity: 0.4;
+15 -3
View File
@@ -2522,6 +2522,9 @@ importers:
'@proj-airi/ui':
specifier: workspace:^
version: link:../../packages/ui
'@proj-airi/ui-loading-screens':
specifier: workspace:^
version: link:../../packages/ui-loading-screens
'@proj-airi/ui-transitions':
specifier: workspace:^
version: link:../../packages/ui-transitions
@@ -4579,6 +4582,9 @@ importers:
'@proj-airi/ui':
specifier: workspace:^
version: link:../ui
'@proj-airi/ui-loading-screens':
specifier: workspace:^
version: link:../ui-loading-screens
'@proj-airi/vite-plugin-sherpaw':
specifier: workspace:^
version: link:../vite-plugin-sherpaw
@@ -5360,12 +5366,18 @@ importers:
packages/ui-loading-screens:
dependencies:
'@fontsource-variable/comfortaa':
specifier: 'catalog:'
version: 5.3.0
'@moeru/std':
specifier: 'catalog:'
version: 0.1.0-beta.17
'@proj-airi/font-departure-mono':
specifier: workspace:^
version: link:../font-departure-mono
'@proj-airi/ui':
specifier: workspace:^
version: link:../ui
'@rive-app/canvas-lite':
specifier: 'catalog:'
version: 2.40.1
@@ -5375,13 +5387,13 @@ importers:
'@vueuse/motion':
specifier: 'catalog:'
version: 3.0.3(magicast@0.5.4)(vue@3.5.41(typescript@6.0.3))
vaul-vue:
specifier: 'catalog:'
version: 0.4.1(reka-ui@2.10.3(vue@3.5.41(typescript@6.0.3)))(vue@3.5.41(typescript@6.0.3))
vue:
specifier: 'catalog:'
version: 3.5.41(typescript@6.0.3)
devDependencies:
'@proj-airi/ui':
specifier: workspace:^
version: link:../ui
'@unocss/reset':
specifier: 'catalog:'
version: 66.7.5