feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,203 @@
<script setup lang="ts">
import { RightArrowIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import { onboardingMessages, type OnboardingStep } from './onboardingConfig'
import OnboardingMascotStage from './OnboardingMascotStage.vue'
const props = defineProps<{
step: OnboardingStep
current: number
total: number
docked: boolean
}>()
defineEmits<{
advance: []
skip: []
}>()
const { formatMessage } = useVIntl()
const progressWidth = computed(() => `${Math.min(100, (props.current / props.total) * 100)}%`)
</script>
<template>
<div class="onboarding-dialogue-layout" :class="{ 'onboarding-dialogue-layout-docked': docked }">
<div v-if="docked" class="onboarding-dialogue-progress" aria-hidden="true">
<span :style="{ width: progressWidth }"></span>
</div>
<OnboardingMascotStage :alt="formatMessage(onboardingMessages.mascotAlt)" />
<div class="onboarding-dialogue-copy">
<div class="onboarding-dialogue-header">
<div class="onboarding-dialogue-heading">
<p class="m-0 mb-1.5 text-[0.8125rem] font-bold text-brand">{{ current }} / {{ total }}</p>
<h2 :id="`onboarding-title-${step.id}`">{{ formatMessage(step.title) }}</h2>
</div>
<ButtonStyled circular type="transparent">
<button :aria-label="formatMessage(onboardingMessages.skip)" @click.stop="$emit('skip')">
<XIcon />
</button>
</ButtonStyled>
</div>
<div class="onboarding-dialogue-body">
<p :id="`onboarding-description-${step.id}`">
{{ formatMessage(step.description) }}
</p>
<ButtonStyled v-if="step.interaction === 'manual'" color="brand">
<button @click="$emit('advance')">
{{ formatMessage(step.action) }}
<RightArrowIcon />
</button>
</ButtonStyled>
<p v-else class="onboarding-action-hint">
<RightArrowIcon aria-hidden="true" />
{{ formatMessage(step.action) }}
</p>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.onboarding-dialogue-layout {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 0.875rem;
}
.onboarding-dialogue-layout > :deep(.onboarding-mascot) {
width: 6rem;
align-self: center;
transform: scale(1.35);
transform-origin: center;
}
.onboarding-dialogue-layout-docked {
width: min(72rem, 100%);
margin-inline: auto;
gap: 1.25rem;
}
.onboarding-dialogue-progress {
position: absolute;
top: -1px;
left: 0;
width: 100%;
height: 2px;
overflow: hidden;
pointer-events: none;
}
.onboarding-dialogue-progress span {
display: block;
height: 100%;
background: var(--color-brand);
transition: width 280ms cubic-bezier(0.22, 1, 0.36, 1);
}
.onboarding-dialogue-layout-docked > :deep(.onboarding-mascot) {
width: 8rem;
align-self: end;
transform: none;
}
.onboarding-dialogue-copy {
min-width: 0;
color: var(--color-contrast);
}
.onboarding-dialogue-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: flex-start;
gap: 1rem;
padding-bottom: 0.375rem;
}
.onboarding-dialogue-heading h2,
.onboarding-dialogue-body p {
margin: 0;
}
.onboarding-dialogue-heading h2 {
color: var(--color-contrast);
font-size: 1.25rem;
font-weight: 700;
line-height: 1.25;
letter-spacing: 0;
}
.onboarding-dialogue-body > p:not(.onboarding-action-hint) {
max-width: 46ch;
color: var(--color-contrast);
font-size: 1rem;
line-height: 1.55;
}
.onboarding-dialogue-layout-docked .onboarding-dialogue-body > p:not(.onboarding-action-hint) {
max-width: 68ch;
}
.onboarding-progress,
.onboarding-action-hint {
font-size: 0.8125rem;
font-weight: 700;
}
.onboarding-progress {
margin-bottom: 0.375rem;
color: var(--color-brand);
}
.onboarding-action-hint {
display: flex;
align-items: center;
gap: 0.375rem;
margin-top: 0.625rem !important;
color: var(--color-brand);
text-wrap: pretty;
}
.onboarding-action-hint :deep(svg) {
flex: none;
}
.onboarding-dialogue-body :deep(.button-outer) {
margin-top: 1rem;
}
.onboarding-dialogue-header :deep(.button-outer) {
flex: none;
margin-top: 0;
}
.onboarding-dialogue-copy :deep(svg) {
width: 1rem;
height: 1rem;
}
@media (max-width: 700px) {
.onboarding-dialogue-layout > :deep(.onboarding-mascot) {
width: 4.5rem;
transform: scale(1.3);
}
.onboarding-dialogue-layout-docked {
width: 100%;
gap: 0.75rem;
}
.onboarding-dialogue-layout-docked > :deep(.onboarding-mascot) {
width: 5rem;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.onboarding-dialogue-progress span {
transition: none;
}
}
</style>

View File

@ -0,0 +1,23 @@
<script setup lang="ts">
import searchingVisual from '@/assets/axo-searching.svg?url'
import teachingVisual from '@/assets/axo-teaching.svg?url'
defineProps<{
alt: string
}>()
const mascotVisuals = [teachingVisual, searchingVisual]
const mascotVisual = mascotVisuals[Math.floor(Math.random() * mascotVisuals.length)]
</script>
<template>
<img class="onboarding-mascot" :src="mascotVisual" :alt="alt" />
</template>
<style scoped lang="scss">
.onboarding-mascot {
width: clamp(6rem, 11vw, 9rem);
height: auto;
object-fit: contain;
}
</style>

View File

@ -0,0 +1,235 @@
<script setup lang="ts">
import { toRef } from 'vue'
import type { OnboardingMode } from './onboardingConfig'
import OnboardingDialogue from './OnboardingDialogue.vue'
import OnboardingWelcome from './OnboardingWelcome.vue'
import { useOnboardingTour } from './useOnboardingTour'
const props = defineProps<{
visible: boolean
mode: OnboardingMode
}>()
const emit = defineEmits<{
complete: []
skip: []
requestCloseSettings: []
}>()
const {
bubbleElement,
bubblePlacement,
controlSpotlightStyle,
handleManualClick,
isDialogueStep,
isWelcomeStep,
step,
stepIndex,
steps,
targetRect,
advance,
} = useOnboardingTour(toRef(props, 'visible'), toRef(props, 'mode'), {
complete: () => emit('complete'),
skip: () => emit('skip'),
closeSettings: () => emit('requestCloseSettings'),
})
</script>
<template>
<div v-if="visible" class="fixed inset-0 z-[10001] overflow-hidden pointer-events-none" aria-live="polite">
<div
v-if="targetRect && step.spotlight === 'control'"
class="onboarding-spotlight"
:style="controlSpotlightStyle"
aria-hidden="true"
>
<span class="onboarding-corner onboarding-corner-top-left"></span>
<span class="onboarding-corner onboarding-corner-top-right"></span>
<span class="onboarding-corner onboarding-corner-bottom-right"></span>
<span class="onboarding-corner onboarding-corner-bottom-left"></span>
</div>
<section
ref="bubbleElement"
data-onboarding-overlay-ui
class="onboarding-surface"
:class="[
`onboarding-surface-${bubblePlacement.direction}`,
{
'onboarding-surface-centered': !targetRect,
'onboarding-surface-dialogue': isDialogueStep,
'onboarding-surface-welcome': isWelcomeStep,
},
]"
:style="bubblePlacement.style"
:aria-labelledby="`onboarding-title-${step.id}`"
:aria-describedby="`onboarding-description-${step.id}`"
@click="step.interaction === 'inspect' ? advance() : undefined"
>
<OnboardingWelcome
v-if="isWelcomeStep"
:step="step"
@start="handleManualClick"
@skip="emit('skip')"
/>
<OnboardingDialogue
v-else
:key="`${mode}-${step.id}`"
:step="step"
:current="stepIndex + 1"
:total="steps.length"
:docked="isDialogueStep"
@advance="handleManualClick"
@skip="emit('skip')"
/>
</section>
</div>
</template>
<style scoped lang="scss">
.onboarding-spotlight {
position: fixed;
pointer-events: none;
transform-origin: center;
animation: onboarding-focus 1.6s ease-in-out infinite;
}
.onboarding-corner {
position: absolute;
width: 0.75rem;
height: 0.75rem;
border-color: var(--color-brand);
border-style: solid;
border-width: 0;
}
.onboarding-corner-top-left {
top: 0;
left: 0;
border-top-width: 2px;
border-left-width: 2px;
}
.onboarding-corner-top-right {
top: 0;
right: 0;
border-top-width: 2px;
border-right-width: 2px;
}
.onboarding-corner-bottom-right {
right: 0;
bottom: 0;
border-right-width: 2px;
border-bottom-width: 2px;
}
.onboarding-corner-bottom-left {
bottom: 0;
left: 0;
border-bottom-width: 2px;
border-left-width: 2px;
}
.onboarding-surface {
position: fixed;
z-index: 1;
width: min(32rem, calc(100vw - 2rem));
max-height: calc(100vh - 4rem);
overflow-y: auto;
border: 1px solid var(--color-divider);
border-radius: var(--radius-lg);
background: var(--color-super-raised-bg);
padding: 1.25rem;
box-sizing: border-box;
pointer-events: auto;
}
.onboarding-surface-centered {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.onboarding-surface-welcome {
inset: 0;
z-index: 3;
width: auto;
height: 100dvh;
min-height: 0;
max-height: none;
transform: none;
overflow: hidden;
border: 0;
border-radius: 0;
padding: 0;
}
.onboarding-surface-dialogue {
top: auto !important;
right: 0;
bottom: 0;
left: 0 !important;
z-index: 2;
width: auto;
min-height: 0;
max-height: min(15rem, 40vh);
transform: none;
overflow-y: auto;
border: 0;
border-top: 1px solid var(--color-divider);
border-radius: 0;
padding: 0.75rem 1.5rem;
}
:global(body.onboarding-reserve-dialogue-space .modal-container) {
height: calc(100% - var(--onboarding-dialogue-reserved-space, 0px));
}
@keyframes onboarding-focus {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.015);
}
}
@media (prefers-reduced-motion: reduce) {
.onboarding-spotlight {
animation: none;
}
}
@media (max-width: 700px) {
.onboarding-surface {
top: auto !important;
bottom: 1rem;
left: 1rem !important;
width: calc(100vw - 2rem);
max-height: min(22rem, calc(100vh - 4rem));
transform: none;
padding: 1rem;
}
.onboarding-surface-dialogue {
right: 0;
bottom: 0;
left: 0 !important;
width: auto;
min-height: 0;
max-height: min(18rem, 52vh);
padding: 0.75rem 1rem;
}
.onboarding-surface-welcome {
inset: 0 !important;
width: auto;
height: 100dvh;
max-height: none;
padding: 0;
}
}
</style>

View File

@ -0,0 +1,457 @@
<script setup lang="ts">
import { RightArrowIcon } from '@modrinth/assets'
import { ButtonStyled, useVIntl } from '@modrinth/ui'
import AxolotlLogo from '@/components/ui/AxolotlLogo.vue'
import { onboardingMessages, type OnboardingStep } from './onboardingConfig'
defineProps<{
step: OnboardingStep
}>()
defineEmits<{
start: []
skip: []
}>()
const { formatMessage } = useVIntl()
</script>
<template>
<div class="onboarding-welcome-content">
<div class="onboarding-welcome-brand-stage">
<div class="onboarding-welcome-brand">
<div class="onboarding-welcome-logo">
<AxolotlLogo icon-only />
</div>
<div class="onboarding-welcome-wordmark" aria-label="Axolotl Launcher">
<span class="onboarding-welcome-wordmark-core" data-wordmark="Axolotl"> Axolotl </span>
<span class="onboarding-welcome-wordmark-suffix" data-wordmark="Launcher">
Launcher
</span>
</div>
</div>
</div>
<div class="onboarding-welcome-panel">
<div class="onboarding-welcome-panel-inner">
<div class="onboarding-welcome-copy">
<h1 :id="`onboarding-title-${step.id}`">{{ formatMessage(step.title) }}</h1>
<p :id="`onboarding-description-${step.id}`">
{{ formatMessage(step.description) }}
</p>
</div>
<div class="onboarding-welcome-actions">
<ButtonStyled color="brand">
<button @click="$emit('start')">
{{ formatMessage(step.action) }}
<RightArrowIcon />
</button>
</ButtonStyled>
<div class="onboarding-welcome-secondary-action">
<span>{{ formatMessage(onboardingMessages.welcomeFooter) }}</span>
<ButtonStyled type="transparent">
<button @click="$emit('skip')">
{{ formatMessage(onboardingMessages.skip) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.onboarding-welcome-content {
position: relative;
width: 100%;
height: 100dvh;
min-height: 0;
overflow: hidden;
box-sizing: border-box;
background: var(--color-bg);
isolation: isolate;
}
.onboarding-welcome-brand-stage {
position: absolute;
inset: 0;
overflow: hidden;
}
.onboarding-welcome-brand {
position: absolute;
top: 50%;
left: 50%;
display: flex;
align-items: center;
justify-content: flex-start;
gap: clamp(0.75rem, 2vw, 1.5rem);
transform: translate(-50%, -50%);
animation: onboarding-welcome-brand-lift 3200ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.onboarding-welcome-logo {
position: relative;
z-index: 2;
flex: none;
width: clamp(7rem, 15vw, 11rem);
height: clamp(7rem, 15vw, 11rem);
animation: onboarding-welcome-logo-reveal 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.onboarding-welcome-wordmark {
display: flex;
align-items: baseline;
gap: 0.35em;
max-width: 0;
overflow: hidden;
color: var(--color-contrast);
font-size: 4.5rem;
font-weight: 800;
line-height: 1;
letter-spacing: 0;
white-space: nowrap;
animation: onboarding-welcome-wordmark-reveal 1050ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.onboarding-welcome-wordmark span {
position: relative;
display: inline-block;
transform: translateX(-4rem);
animation: onboarding-welcome-wordmark-flight 1050ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.onboarding-welcome-wordmark-core {
background-image: linear-gradient(90deg, var(--color-contrast), var(--color-base));
background-clip: text;
-webkit-background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
}
.onboarding-welcome-wordmark-suffix {
color: var(--color-secondary);
font-weight: 650;
}
.onboarding-welcome-wordmark span::after {
position: absolute;
inset: 0;
color: var(--color-brand);
content: attr(data-wordmark);
animation: onboarding-welcome-brand-scan 700ms 2050ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.onboarding-welcome-wordmark-suffix::after {
animation-delay: 2400ms;
}
.onboarding-welcome-panel {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 3;
border-top: 1px solid var(--color-divider);
background: var(--color-raised-bg);
opacity: 0;
transform: translateY(100%);
animation: onboarding-welcome-panel-enter 650ms 3200ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.onboarding-welcome-panel::before {
position: absolute;
top: -2px;
left: 0;
width: clamp(4rem, 12vw, 10rem);
height: 2px;
background: var(--color-brand);
content: '';
}
.onboarding-welcome-panel-inner {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: clamp(2rem, 6vw, 6rem);
width: min(80rem, 100%);
min-height: clamp(11rem, 27vh, 15rem);
margin-inline: auto;
padding: 1.5rem clamp(1.5rem, 5vw, 4rem);
box-sizing: border-box;
}
.onboarding-welcome-copy {
min-width: 0;
}
.onboarding-welcome-copy h1,
.onboarding-welcome-copy p {
margin: 0;
}
.onboarding-welcome-copy h1 {
color: var(--color-contrast);
font-size: 2.25rem;
font-weight: 750;
line-height: 1.15;
letter-spacing: 0;
text-wrap: balance;
}
.onboarding-welcome-copy p {
max-width: 46rem;
margin-top: 0.75rem;
color: var(--color-secondary);
font-size: 1rem;
line-height: 1.55;
text-wrap: pretty;
}
.onboarding-welcome-actions {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
min-width: 15rem;
}
.onboarding-welcome-actions :deep(.button-outer),
.onboarding-welcome-actions :deep(button) {
width: 100%;
justify-content: center;
white-space: nowrap;
}
.onboarding-welcome-secondary-action {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
color: var(--color-secondary);
font-size: 0.8125rem;
}
.onboarding-welcome-secondary-action :deep(.button-outer),
.onboarding-welcome-secondary-action :deep(button) {
width: auto;
}
@keyframes onboarding-welcome-logo-reveal {
0% {
opacity: 0;
transform: scale(0.72);
}
65% {
opacity: 1;
transform: scale(1.04);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes onboarding-welcome-wordmark-reveal {
from {
max-width: 0;
}
to {
max-width: 42rem;
}
}
@keyframes onboarding-welcome-wordmark-flight {
from {
opacity: 0;
transform: translateX(-4rem);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes onboarding-welcome-brand-scan {
0%,
12% {
clip-path: inset(0 0 0 0);
}
100% {
clip-path: inset(0 0 0 100%);
}
}
@keyframes onboarding-welcome-brand-lift {
0%,
70% {
top: 50%;
}
100% {
top: clamp(9rem, 33vh, 18rem);
}
}
@keyframes onboarding-welcome-panel-enter {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.onboarding-welcome-brand,
.onboarding-welcome-logo,
.onboarding-welcome-wordmark,
.onboarding-welcome-wordmark span,
.onboarding-welcome-panel {
animation: none;
}
.onboarding-welcome-brand {
top: clamp(9rem, 33vh, 18rem);
}
.onboarding-welcome-wordmark {
max-width: 42rem;
}
.onboarding-welcome-logo,
.onboarding-welcome-wordmark span,
.onboarding-welcome-panel {
opacity: 1;
transform: none;
}
.onboarding-welcome-wordmark span::after {
display: none;
}
}
@media (max-width: 700px) {
.onboarding-welcome-brand {
gap: 0.75rem;
}
.onboarding-welcome-logo {
width: 6.5rem;
height: 6.5rem;
}
.onboarding-welcome-wordmark {
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
font-size: 2.5rem;
}
.onboarding-welcome-panel-inner {
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
min-height: min(19rem, 46vh);
padding: 1.25rem;
}
.onboarding-welcome-copy h1 {
font-size: 1.5rem;
}
.onboarding-welcome-copy p {
margin-top: 0.5rem;
font-size: 0.9375rem;
line-height: 1.45;
}
.onboarding-welcome-actions {
min-width: 0;
}
}
@media (max-width: 480px) {
.onboarding-welcome-wordmark {
font-size: 1.875rem;
}
.onboarding-welcome-secondary-action > span {
display: none;
}
}
@media (min-width: 701px) and (max-width: 1000px) {
.onboarding-welcome-wordmark {
font-size: 3.5rem;
}
}
@media (max-height: 680px) {
.onboarding-welcome-brand {
gap: 0.75rem;
}
.onboarding-welcome-logo {
width: 6.5rem;
height: 6.5rem;
}
.onboarding-welcome-wordmark {
font-size: 2.5rem;
}
.onboarding-welcome-panel-inner {
min-height: min(10rem, 32vh);
padding-block: 1rem;
}
.onboarding-welcome-copy p {
margin-top: 0.375rem;
line-height: 1.4;
}
}
@media (max-width: 700px) and (max-height: 680px) {
.onboarding-welcome-brand {
animation-name: onboarding-welcome-brand-lift-compact;
}
.onboarding-welcome-panel-inner {
grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem;
min-height: min(9.5rem, 40vh);
}
.onboarding-welcome-copy h1 {
font-size: 1.25rem;
}
.onboarding-welcome-copy p {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.onboarding-welcome-actions {
min-width: 10rem;
}
.onboarding-welcome-secondary-action > span {
display: none;
}
}
@keyframes onboarding-welcome-brand-lift-compact {
0%,
70% {
top: 50%;
}
100% {
top: 29%;
}
}
</style>

View File

@ -0,0 +1,767 @@
import { defineMessages, type MessageDescriptor } from '@modrinth/ui'
export type OnboardingInteraction = 'manual' | 'navigate' | 'activate' | 'inspect'
export type OnboardingMode = 'main' | 'instance'
export type CreationPath = 'custom'
export type StepDestination = string | 'complete'
export type OnboardingStep = {
id: string
targetId?: string
interaction: OnboardingInteraction
title: MessageDescriptor
description: MessageDescriptor
action: MessageDescriptor
spotlight?: 'control'
expectedPath?: string
next?: StepDestination
closeSettingsAfter?: boolean
nextByCreationPath?: Partial<Record<CreationPath, StepDestination>>
branchByTarget?: Record<
string,
{
creationPath?: CreationPath
next: StepDestination
}
>
}
export const onboardingMessages = defineMessages({
welcomeTitle: {
id: 'app.onboarding.welcome.title',
defaultMessage: 'Everything is ready',
},
welcomeDescription: {
id: 'app.onboarding.welcome.description',
defaultMessage:
'Your instances, content, worlds, and downloads now have one home. Let us take a quick lap before you settle in.',
},
welcomeFooter: {
id: 'app.onboarding.welcome.footer',
defaultMessage: 'Your last next launcher.',
},
start: { id: 'app.onboarding.action.start', defaultMessage: 'Take the tour' },
homeWidgetsTitle: {
id: 'app.onboarding.home-widgets.title',
defaultMessage: 'Your Home, your layout',
},
homeWidgetsDescription: {
id: 'app.onboarding.home-widgets.description',
defaultMessage:
'Information Home is built from widgets for recent activity, playtime, instances, worlds, and servers. The grid reflows as the window or account sidebar changes.',
},
homeCustomizeTitle: {
id: 'app.onboarding.home-customize.title',
defaultMessage: 'Arrange it your way',
},
homeCustomizeDescription: {
id: 'app.onboarding.home-customize.description',
defaultMessage:
'Use the bottom-right edit control to add, resize, and configure widgets. While editing, switch between an automatically packed grid and a free grid that preserves empty cells.',
},
discoverTitle: { id: 'app.onboarding.discover.title', defaultMessage: 'Find something new' },
discoverDescription: {
id: 'app.onboarding.discover.description',
defaultMessage:
'Modpacks, mods, plugins, resource packs, shaders: the good kind of rabbit hole.',
},
clickDiscover: {
id: 'app.onboarding.action.click-discover',
defaultMessage: 'Click Discover content to continue',
},
browseTitle: { id: 'app.onboarding.browse.title', defaultMessage: 'Search with receipts' },
browseDescription: {
id: 'app.onboarding.browse.description',
defaultMessage:
'Use types, search, and filters to narrow things down. Project pages keep versions, changelogs, galleries, and install options in one place.',
},
favoritesTitle: {
id: 'app.onboarding.favorites.title',
defaultMessage: 'Keep a short list',
},
favoritesDescription: {
id: 'app.onboarding.favorites.description',
defaultMessage:
'Open Favorites to revisit saved mods, resource packs, data packs, and shaders. The launcher refreshes their project details, then you can choose an instance and add mixed sources to the same install cart.',
},
clickFavorites: {
id: 'app.onboarding.action.click-favorites',
defaultMessage: 'Click Favorites to continue',
},
homeLayoutTitle: {
id: 'app.onboarding.home-layout.title',
defaultMessage: 'Change the amount of detail',
},
homeLayoutDescription: {
id: 'app.onboarding.home-layout.description',
defaultMessage:
'Use the bottom-right control to switch between Information Home and Minimal Home. Widget editing stays with Information Home.',
},
continueArea: {
id: 'app.onboarding.action.continue-area',
defaultMessage: 'Click anywhere for the next bit',
},
skinsTitle: { id: 'app.onboarding.skins.title', defaultMessage: 'A new look, maybe' },
skinsDescription: {
id: 'app.onboarding.skins.description',
defaultMessage:
'Keep your Minecraft skins together. Signing in can wait until you feel like it.',
},
clickSkins: {
id: 'app.onboarding.action.click-skins',
defaultMessage: 'Click Skin selector to continue',
},
skinsPageTitle: { id: 'app.onboarding.skins-page.title', defaultMessage: 'Your skin drawer' },
skinsPageDescription: {
id: 'app.onboarding.skins-page.description',
defaultMessage: 'Add, preview, sort, and apply skins here. No pressure to sign in just yet.',
},
accountTitle: {
id: 'app.onboarding.account.title',
defaultMessage: 'Accounts, on your schedule',
},
accountDescription: {
id: 'app.onboarding.account.description',
defaultMessage:
'When you are ready, sign in, switch accounts, or open your profile here. No deadline.',
},
downloadsTitle: { id: 'app.onboarding.downloads.title', defaultMessage: 'Download control room' },
downloadsDescription: {
id: 'app.onboarding.downloads.description',
defaultMessage:
'Installations and content downloads report in here, so nothing has to disappear mysteriously.',
},
clickDownloads: {
id: 'app.onboarding.action.click-downloads',
defaultMessage: 'Click Downloads to continue',
},
downloadsPageTitle: {
id: 'app.onboarding.downloads-page.title',
defaultMessage: 'Nothing gets lost',
},
downloadsPageDescription: {
id: 'app.onboarding.downloads-page.description',
defaultMessage:
'Active work, history, errors, retries, cancellations, and diagnostics all leave a paper trail here.',
},
settingsTitle: { id: 'app.onboarding.settings.title', defaultMessage: 'Make it yours' },
settingsDescription: {
id: 'app.onboarding.settings.description',
defaultMessage:
'The useful controls live here: launcher preferences, game launch behavior, content downloads, and privacy.',
},
clickSettings: {
id: 'app.onboarding.action.click-settings',
defaultMessage: 'Click Settings to continue',
},
appearanceTitle: { id: 'app.onboarding.appearance.title', defaultMessage: 'Set the vibe' },
appearanceDescription: {
id: 'app.onboarding.appearance.description',
defaultMessage:
'Theme, accent, backgrounds, and window effects all live here. Make the launcher feel familiar.',
},
languageTitle: { id: 'app.onboarding.language.title', defaultMessage: 'Speak your language' },
languageDescription: {
id: 'app.onboarding.language.description',
defaultMessage: 'Pick the launcher language and manage translations. No decoder ring required.',
},
translationTitle: {
id: 'app.onboarding.translation.title',
defaultMessage: 'Translation, the Axolotl way',
},
translationDescription: {
id: 'app.onboarding.translation.description',
defaultMessage:
'Translate Modrinth project titles, summaries, and descriptions while you browse. Keep the original, show both, or make the translation the main character.',
},
aiTitle: {
id: 'app.onboarding.ai.title',
defaultMessage: 'Bring your own AI provider',
},
aiDescription: {
id: 'app.onboarding.ai.description',
defaultMessage:
'Connect text-model providers once, choose the models you want available, or switch every AI feature off in one place.',
},
javaTitle: { id: 'app.onboarding.java.title', defaultMessage: 'Java, under the hood' },
javaDescription: {
id: 'app.onboarding.java.description',
defaultMessage:
'The Java runtimes that start Minecraft live here. Technical, but well-behaved.',
},
defaultsTitle: { id: 'app.onboarding.defaults.title', defaultMessage: 'Start ahead' },
defaultsDescription: {
id: 'app.onboarding.defaults.description',
defaultMessage:
'New instances inherit these choices, so you do not have to repeat the homework.',
},
resourcesTitle: {
id: 'app.onboarding.resources.title',
defaultMessage: 'Do not cook the computer',
},
resourcesDescription: {
id: 'app.onboarding.resources.description',
defaultMessage:
'Choose how content downloads and installs, from download sources to safety checks.',
},
privacyTitle: {
id: 'app.onboarding.privacy.title',
defaultMessage: 'Your data, your call',
},
privacyDescription: {
id: 'app.onboarding.privacy.description',
defaultMessage:
'Manage anonymous telemetry, Discord Rich Presence, and the Minecraft log analysis service whenever you need to.',
},
updatesTitle: { id: 'app.onboarding.updates.title', defaultMessage: 'Stay in the loop' },
updatesDescription: {
id: 'app.onboarding.updates.description',
defaultMessage: 'Choose when Axolotl checks for updates and whether it installs them for you.',
},
clickTab: { id: 'app.onboarding.action.click-tab', defaultMessage: 'Click this tab to continue' },
libraryTitle: { id: 'app.onboarding.library.title', defaultMessage: 'Your launch shelf' },
libraryDescription: {
id: 'app.onboarding.library.description',
defaultMessage:
'Instances keep versions, loaders, content, and saves separate. No accidental mod soup.',
},
clickLibrary: {
id: 'app.onboarding.action.click-library',
defaultMessage: 'Click Library to continue',
},
libraryPageTitle: {
id: 'app.onboarding.library-page.title',
defaultMessage: 'Everything, in its place',
},
libraryPageDescription: {
id: 'app.onboarding.library-page.description',
defaultMessage:
'Filter by modpack, server, or custom setup, then open any instance to manage it.',
},
createTitle: { id: 'app.onboarding.create.title', defaultMessage: 'Make a fresh start' },
createDescription: {
id: 'app.onboarding.create.description',
defaultMessage: 'Start from scratch or bring in an existing instance or modpack. Your call.',
},
clickCreate: {
id: 'app.onboarding.action.click-create',
defaultMessage: 'Click Create new instance to continue',
},
creationTitle: { id: 'app.onboarding.creation.title', defaultMessage: 'Pick your route' },
creationDescription: {
id: 'app.onboarding.creation.description',
defaultMessage:
'Start fresh with a custom setup, or import an existing instance or modpack. Your call.',
},
clickCreationMethod: {
id: 'app.onboarding.action.click-creation-method',
defaultMessage: 'Choose a route to continue',
},
creationNameTitle: {
id: 'app.onboarding.creation-name.title',
defaultMessage: 'Give it a memorable name',
},
creationNameDescription: {
id: 'app.onboarding.creation-name.description',
defaultMessage: 'Pick a name your future self will recognize at a glance.',
},
creationLoaderTitle: {
id: 'app.onboarding.creation-loader.title',
defaultMessage: 'Choose the engine',
},
creationLoaderDescription: {
id: 'app.onboarding.creation-loader.description',
defaultMessage: 'Vanilla, Fabric, Forge, NeoForge, and friends. Pick what your content needs.',
},
creationVersionTitle: {
id: 'app.onboarding.creation-version.title',
defaultMessage: 'Set the game version',
},
creationVersionDescription: {
id: 'app.onboarding.creation-version.description',
defaultMessage:
'Choose the Minecraft version for this instance. Compatibility likes specifics.',
},
creationConfirmTitle: {
id: 'app.onboarding.creation-confirm.title',
defaultMessage: 'One last look',
},
creationConfirmDescription: {
id: 'app.onboarding.creation-confirm.description',
defaultMessage:
'This creates the instance with your choices. I keep a strict hands-off policy.',
},
finishArea: {
id: 'app.onboarding.action.finish-area',
defaultMessage: 'Click anywhere and you are all set',
},
instanceActionsTitle: {
id: 'app.onboarding.instance-actions.title',
defaultMessage: 'The main controls',
},
instanceActionsDescription: {
id: 'app.onboarding.instance-actions.description',
defaultMessage:
'Launch, stop, repair, configure, export, or open the instance from its header.',
},
instanceTabsTitle: {
id: 'app.onboarding.instance-tabs.title',
defaultMessage: 'The rest of the workshop',
},
instanceTabsDescription: {
id: 'app.onboarding.instance-tabs.description',
defaultMessage: 'Use these tabs for content, files, screenshots, worlds, and logs. Tidy chaos.',
},
labTitle: {
id: 'app.onboarding.lab.title',
defaultMessage: 'Useful tools, built in',
},
labDescription: {
id: 'app.onboarding.lab.description',
defaultMessage:
'The Lab keeps local Minecraft tools inside the launcher, without another website or account.',
},
clickLab: {
id: 'app.onboarding.action.click-lab',
defaultMessage: 'Click Lab to continue',
},
labToolsTitle: {
id: 'app.onboarding.lab-tools.title',
defaultMessage: 'Local tools for Minecraft',
},
labToolsDescription: {
id: 'app.onboarding.lab-tools.description',
defaultMessage:
'Create formatted text and recipe data packs, explore Java worlds, and inspect schematic builds without leaving the launcher.',
},
openGradientText: {
id: 'app.onboarding.action.open-gradient-text',
defaultMessage: 'Open Gradient text generator to continue',
},
labEditorTitle: {
id: 'app.onboarding.lab-editor.title',
defaultMessage: 'Build and copy in one place',
},
labEditorDescription: {
id: 'app.onboarding.lab-editor.description',
defaultMessage:
'Edit text, choose colors, preview the result, and copy the format your Minecraft setup expects.',
},
labSeedMapTitle: {
id: 'app.onboarding.lab-seed-map.title',
defaultMessage: 'Find a world before you load it',
},
labSeedMapDescription: {
id: 'app.onboarding.lab-seed-map.description',
defaultMessage:
'Enter a seed or load one from an instance, then inspect biomes, structures, and ore layers on the local map.',
},
returnToLab: {
id: 'app.onboarding.action.return-lab',
defaultMessage: 'Click Lab to continue',
},
openSeedMap: {
id: 'app.onboarding.action.open-seed-map',
defaultMessage: 'Open Seed map to continue',
},
openSchematicWorkshop: {
id: 'app.onboarding.action.open-schematic-workshop',
defaultMessage: 'Open Schematic workshop to continue',
},
openRecipeGenerator: {
id: 'app.onboarding.action.open-recipe-generator',
defaultMessage: 'Open Recipe generator to continue',
},
labRecipeGeneratorTitle: {
id: 'app.onboarding.lab-recipe-generator.title',
defaultMessage: 'Craft data pack recipes',
},
labRecipeGeneratorDescription: {
id: 'app.onboarding.lab-recipe-generator.description',
defaultMessage:
'Pick a Java version, fill the recipe slots, and copy or export the JSON locally.',
},
labSchematicTitle: {
id: 'app.onboarding.lab-schematic.title',
defaultMessage: 'Inspect a build before placing it',
},
labSchematicDescription: {
id: 'app.onboarding.lab-schematic.description',
defaultMessage:
'Open a local .litematic or .schem file, or choose one from an installed instance. The 3D workspace keeps viewing, measurement, layer controls, materials, and local edits together.',
},
skip: { id: 'app.onboarding.action.skip', defaultMessage: 'Leave the tour' },
mascotAlt: { id: 'app.onboarding.mascot-alt', defaultMessage: 'Axolotl guide' },
})
const step = (
id: string,
interaction: OnboardingInteraction,
copy: {
title: MessageDescriptor
description: MessageDescriptor
action: MessageDescriptor
},
options: Omit<OnboardingStep, 'id' | 'interaction' | 'title' | 'description' | 'action'> = {},
): OnboardingStep => ({ id, interaction, ...copy, ...options })
const copy = (
title: MessageDescriptor,
description: MessageDescriptor,
action: MessageDescriptor,
) => ({ title, description, action })
const control = (targetId: string, expectedPath?: string) => ({
targetId,
spotlight: 'control' as const,
...(expectedPath ? { expectedPath } : {}),
})
const inspect = (
id: string,
targetId: string,
title: MessageDescriptor,
description: MessageDescriptor,
) => step(id, 'inspect', copy(title, description, onboardingMessages.continueArea), { targetId })
const settingsTourSteps: Array<[string, string, MessageDescriptor, MessageDescriptor]> = [
[
'settings-interface',
'settings-tab-interface',
onboardingMessages.appearanceTitle,
onboardingMessages.appearanceDescription,
],
[
'settings-launch-defaults',
'settings-tab-launch-defaults',
onboardingMessages.defaultsTitle,
onboardingMessages.defaultsDescription,
],
[
'settings-content-downloads',
'settings-tab-content-downloads',
onboardingMessages.resourcesTitle,
onboardingMessages.resourcesDescription,
],
]
export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
main: [
step(
'welcome',
'manual',
copy(
onboardingMessages.welcomeTitle,
onboardingMessages.welcomeDescription,
onboardingMessages.start,
),
),
inspect(
'home-widget-grid',
'home-widget-grid',
onboardingMessages.homeWidgetsTitle,
onboardingMessages.homeWidgetsDescription,
),
inspect(
'home-widget-customize',
'home-widget-customize',
onboardingMessages.homeCustomizeTitle,
onboardingMessages.homeCustomizeDescription,
),
inspect(
'home-layout-switch',
'home-layout-switch',
onboardingMessages.homeLayoutTitle,
onboardingMessages.homeLayoutDescription,
),
step(
'discover-navigation',
'navigate',
copy(
onboardingMessages.discoverTitle,
onboardingMessages.discoverDescription,
onboardingMessages.clickDiscover,
),
control('nav-discover', '/browse/modpack'),
),
inspect(
'discover-content',
'browse-content',
onboardingMessages.browseTitle,
onboardingMessages.browseDescription,
),
step(
'discover-favorites-navigation',
'navigate',
copy(
onboardingMessages.favoritesTitle,
onboardingMessages.favoritesDescription,
onboardingMessages.clickFavorites,
),
control('browse-favorites-tab', '/browse/favorites'),
),
inspect(
'discover-favorites-content',
'browse-favorites-content',
onboardingMessages.favoritesTitle,
onboardingMessages.favoritesDescription,
),
step(
'skins-navigation',
'navigate',
copy(
onboardingMessages.skinsTitle,
onboardingMessages.skinsDescription,
onboardingMessages.clickSkins,
),
control('nav-skins', '/skins'),
),
inspect(
'skins-page',
'skins-page',
onboardingMessages.skinsPageTitle,
onboardingMessages.skinsPageDescription,
),
step(
'account',
'inspect',
copy(
onboardingMessages.accountTitle,
onboardingMessages.accountDescription,
onboardingMessages.continueArea,
),
control('account-entry'),
),
step(
'lab-navigation',
'navigate',
copy(
onboardingMessages.labTitle,
onboardingMessages.labDescription,
onboardingMessages.clickLab,
),
control('nav-lab', '/lab'),
),
inspect(
'lab-tools',
'lab-tools',
onboardingMessages.labToolsTitle,
onboardingMessages.labToolsDescription,
),
step(
'lab-gradient-text-navigation',
'navigate',
copy(
onboardingMessages.labEditorTitle,
onboardingMessages.labEditorDescription,
onboardingMessages.openGradientText,
),
control('lab-gradient-text-card', '/lab/gradient-text'),
),
inspect(
'lab-gradient-text-editor',
'lab-gradient-text-editor',
onboardingMessages.labEditorTitle,
onboardingMessages.labEditorDescription,
),
step(
'lab-return-navigation',
'navigate',
copy(
onboardingMessages.labSeedMapTitle,
onboardingMessages.labSeedMapDescription,
onboardingMessages.returnToLab,
),
control('nav-lab', '/lab'),
),
step(
'lab-seed-map-navigation',
'navigate',
copy(
onboardingMessages.labSeedMapTitle,
onboardingMessages.labSeedMapDescription,
onboardingMessages.openSeedMap,
),
control('lab-seed-map-card', '/lab/seed-map'),
),
inspect(
'lab-seed-map-workspace',
'seed-map-workspace',
onboardingMessages.labSeedMapTitle,
onboardingMessages.labSeedMapDescription,
),
step(
'lab-return-schematic-navigation',
'navigate',
copy(
onboardingMessages.labSchematicTitle,
onboardingMessages.labSchematicDescription,
onboardingMessages.returnToLab,
),
control('nav-lab', '/lab'),
),
step(
'lab-schematic-navigation',
'navigate',
copy(
onboardingMessages.labSchematicTitle,
onboardingMessages.labSchematicDescription,
onboardingMessages.openSchematicWorkshop,
),
control('lab-schematic-preview-card', '/lab/schematic-preview'),
),
inspect(
'lab-schematic-workspace',
'schematic-preview-workspace',
onboardingMessages.labSchematicTitle,
onboardingMessages.labSchematicDescription,
),
step(
'lab-recipe-generator-navigation',
'navigate',
copy(
onboardingMessages.labRecipeGeneratorTitle,
onboardingMessages.labRecipeGeneratorDescription,
onboardingMessages.openRecipeGenerator,
),
control('lab-recipe-generator-card', '/lab/recipe-generator'),
),
inspect(
'lab-recipe-generator-workspace',
'recipe-generator-workspace',
onboardingMessages.labRecipeGeneratorTitle,
onboardingMessages.labRecipeGeneratorDescription,
),
step(
'downloads-navigation',
'navigate',
copy(
onboardingMessages.downloadsTitle,
onboardingMessages.downloadsDescription,
onboardingMessages.clickDownloads,
),
control('nav-downloads', '/downloads'),
),
inspect(
'downloads-tabs',
'downloads-tabs',
onboardingMessages.downloadsPageTitle,
onboardingMessages.downloadsPageDescription,
),
step(
'settings-navigation',
'activate',
copy(
onboardingMessages.settingsTitle,
onboardingMessages.settingsDescription,
onboardingMessages.clickSettings,
),
control('nav-settings', '/settings'),
),
...settingsTourSteps.map(([id, targetId, title, description], index) =>
step(id, 'activate', copy(title, description, onboardingMessages.clickTab), {
...control(targetId),
closeSettingsAfter: index === settingsTourSteps.length - 1,
}),
),
step(
'library-navigation',
'navigate',
copy(
onboardingMessages.libraryTitle,
onboardingMessages.libraryDescription,
onboardingMessages.clickLibrary,
),
control('nav-library', '/library'),
),
inspect(
'library-content',
'library-content',
onboardingMessages.libraryPageTitle,
onboardingMessages.libraryPageDescription,
),
step(
'create-instance',
'navigate',
copy(
onboardingMessages.createTitle,
onboardingMessages.createDescription,
onboardingMessages.clickCreate,
),
control('create-instance', '/create'),
),
step(
'creation-flow',
'activate',
copy(
onboardingMessages.creationTitle,
onboardingMessages.creationDescription,
onboardingMessages.clickCreationMethod,
),
{
targetId: 'creation-methods',
branchByTarget: {
'creation-method-custom': { creationPath: 'custom', next: 'creation-name' },
'creation-method-import': { next: 'complete' },
},
},
),
inspect(
'creation-name',
'creation-name',
onboardingMessages.creationNameTitle,
onboardingMessages.creationNameDescription,
),
inspect(
'creation-loader',
'creation-loader',
onboardingMessages.creationLoaderTitle,
onboardingMessages.creationLoaderDescription,
),
step(
'creation-version',
'inspect',
copy(
onboardingMessages.creationVersionTitle,
onboardingMessages.creationVersionDescription,
onboardingMessages.continueArea,
),
{
targetId: 'creation-game-version',
nextByCreationPath: { custom: 'creation-confirm' },
},
),
step(
'creation-confirm',
'inspect',
copy(
onboardingMessages.creationConfirmTitle,
onboardingMessages.creationConfirmDescription,
onboardingMessages.finishArea,
),
{ targetId: 'creation-confirm' },
),
],
instance: [
inspect(
'instance-actions',
'instance-actions',
onboardingMessages.instanceActionsTitle,
onboardingMessages.instanceActionsDescription,
),
step(
'instance-tabs',
'inspect',
copy(
onboardingMessages.instanceTabsTitle,
onboardingMessages.instanceTabsDescription,
onboardingMessages.finishArea,
),
{ targetId: 'instance-tabs' },
),
],
}
export function onboardingTargetSelector(targetId: string) {
return `[data-onboarding-id="${targetId}"]`
}

View File

@ -0,0 +1,372 @@
import { computed, nextTick, onBeforeUnmount, onMounted, type Ref, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import {
type CreationPath,
type OnboardingMode,
onboardingTargetSelector,
onboardingTours,
type StepDestination,
} from './onboardingConfig'
type TourEvents = {
complete: () => void
skip: () => void
closeSettings: () => void
}
const controlSpotlightPadding = 3
const missingTargetRetryLimit = 8
const missingTargetRetryDelay = 250
export function useOnboardingTour(
visible: Ref<boolean>,
mode: Ref<OnboardingMode>,
events: TourEvents,
) {
const route = useRoute()
const stepIndex = ref(0)
const targetRect = ref<DOMRect | null>(null)
const bubbleElement = ref<HTMLElement>()
const bubbleSize = ref({ width: 512, height: 160 })
const waitingForRoute = ref(false)
const creationPath = ref<CreationPath>()
const targetElement = ref<HTMLElement>()
let targetObserver: ResizeObserver | undefined
let bubbleObserver: ResizeObserver | undefined
let targetRetryTimer: ReturnType<typeof setTimeout> | undefined
let advanceTimer: ReturnType<typeof setTimeout> | undefined
let unlockTimer: ReturnType<typeof setTimeout> | undefined
let targetRetryCount = 0
let transitionLocked = false
const steps = computed(() => onboardingTours[mode.value])
const step = computed(() => steps.value[stepIndex.value])
const isWelcomeStep = computed(() => step.value.id === 'welcome')
const isDialogueStep = computed(
() => !!step.value.targetId && (step.value.spotlight !== 'control' || !targetRect.value),
)
const controlSpotlightStyle = computed(() => {
if (!targetRect.value) return {}
const rect = targetRect.value
return {
left: `${Math.max(0, rect.left - controlSpotlightPadding)}px`,
top: `${Math.max(0, rect.top - controlSpotlightPadding)}px`,
width: `${rect.width + controlSpotlightPadding * 2}px`,
height: `${rect.height + controlSpotlightPadding * 2}px`,
}
})
const bubblePlacement = computed(() => {
if (!targetRect.value || isDialogueStep.value) return { direction: 'center', style: {} }
const rect = targetRect.value
const safeInset = 16
const safeTop = 48
const bubbleWidth = Math.min(bubbleSize.value.width, window.innerWidth - safeInset * 2)
const bubbleHeight = Math.min(bubbleSize.value.height, window.innerHeight - safeTop - safeInset)
const gap = 20
const positions = [
{
direction: 'right',
left: rect.right + gap,
top: rect.top + rect.height / 2 - bubbleHeight / 2,
},
{
direction: 'bottom',
left: rect.left + rect.width / 2 - bubbleWidth / 2,
top: rect.bottom + gap,
},
{
direction: 'left',
left: rect.left - bubbleWidth - gap,
top: rect.top + rect.height / 2 - bubbleHeight / 2,
},
{
direction: 'top',
left: rect.left + rect.width / 2 - bubbleWidth / 2,
top: rect.top - bubbleHeight - gap,
},
]
const position =
positions.find(
(candidate) =>
candidate.left >= safeInset &&
candidate.top >= safeTop &&
candidate.left + bubbleWidth <= window.innerWidth - safeInset &&
candidate.top + bubbleHeight <= window.innerHeight - safeInset,
) ?? positions[0]
return {
direction: position.direction,
style: {
left: `${Math.min(
Math.max(safeInset, position.left),
window.innerWidth - bubbleWidth - safeInset,
)}px`,
top: `${Math.min(
Math.max(safeTop, position.top),
window.innerHeight - bubbleHeight - safeInset,
)}px`,
},
}
})
function clearTargetTracking() {
targetObserver?.disconnect()
targetObserver = undefined
if (targetRetryTimer) clearTimeout(targetRetryTimer)
targetRetryTimer = undefined
}
function clearModalReservation() {
document.body.classList.remove('onboarding-reserve-dialogue-space')
document.body.style.removeProperty('--onboarding-dialogue-reserved-space')
}
function updateModalReservation() {
const targetIsInModal = !!targetElement.value?.closest('[role="dialog"]')
if (!visible.value || !isDialogueStep.value || !targetIsInModal) {
clearModalReservation()
return
}
document.body.classList.add('onboarding-reserve-dialogue-space')
document.body.style.setProperty(
'--onboarding-dialogue-reserved-space',
`${Math.ceil(bubbleSize.value.height)}px`,
)
}
function updateBubbleSize() {
if (!bubbleElement.value) return
const { width, height } = bubbleElement.value.getBoundingClientRect()
bubbleSize.value = { width, height }
updateModalReservation()
}
function scheduleMissingTargetRetry(stepId: string) {
if (targetRetryCount >= missingTargetRetryLimit) {
void advance()
return
}
targetRetryCount++
targetRetryTimer = setTimeout(() => {
if (visible.value && step.value.id === stepId && !targetRect.value) updateTarget()
}, missingTargetRetryDelay)
}
function updateTarget() {
clearTargetTracking()
if (!visible.value || !step.value.targetId) {
targetElement.value = undefined
targetRect.value = null
clearModalReservation()
return
}
const target = document.querySelector<HTMLElement>(
onboardingTargetSelector(step.value.targetId),
)
const rect = target?.getBoundingClientRect()
if (!target || !rect || rect.width < 1 || rect.height < 1) {
targetElement.value = undefined
targetRect.value = null
clearModalReservation()
scheduleMissingTargetRetry(step.value.id)
return
}
targetRetryCount = 0
targetElement.value = target
const updateRect = () => {
targetRect.value = target.getBoundingClientRect()
}
updateRect()
targetObserver = new ResizeObserver(updateRect)
targetObserver.observe(target)
updateModalReservation()
requestAnimationFrame(updateRect)
}
function goTo(destination: StepDestination) {
if (destination === 'complete') {
events.complete()
return
}
const destinationIndex = steps.value.findIndex((candidate) => candidate.id === destination)
if (destinationIndex === -1) {
events.complete()
return
}
stepIndex.value = destinationIndex
}
async function advance() {
if (transitionLocked) return
transitionLocked = true
const destination = creationPath.value
? step.value.nextByCreationPath?.[creationPath.value]
: step.value.next
if (destination) {
goTo(destination)
scheduleUnlock()
return
}
if (stepIndex.value === steps.value.length - 1) {
events.complete()
scheduleUnlock()
return
}
if (step.value.closeSettingsAfter) events.closeSettings()
stepIndex.value++
await nextTick()
updateTarget()
scheduleUnlock()
}
function scheduleUnlock() {
if (unlockTimer) clearTimeout(unlockTimer)
unlockTimer = setTimeout(() => {
transitionLocked = false
unlockTimer = undefined
}, 180)
}
function scheduleDestination(destination?: StepDestination) {
if (advanceTimer || transitionLocked) return
if (destination) transitionLocked = true
advanceTimer = setTimeout(() => {
advanceTimer = undefined
if (destination) {
goTo(destination)
scheduleUnlock()
} else {
void advance()
}
}, 150)
}
function handleManualClick() {
if (step.value.interaction === 'manual') void advance()
}
function handleBranchClick(target: Element) {
if (!step.value.branchByTarget) return false
for (const [targetId, branch] of Object.entries(step.value.branchByTarget)) {
if (!target.closest(onboardingTargetSelector(targetId))) continue
creationPath.value = branch.creationPath
scheduleDestination(branch.next)
return true
}
return false
}
function handleDocumentClick(event: MouseEvent) {
if (!visible.value) return
const clickedElement = event.target instanceof Element ? event.target : null
if (clickedElement?.closest('[data-onboarding-overlay-ui]')) return
if (step.value.interaction === 'inspect') {
event.preventDefault()
event.stopImmediatePropagation()
void advance()
return
}
if (!step.value.targetId || !['navigate', 'activate'].includes(step.value.interaction)) return
if (clickedElement && handleBranchClick(clickedElement)) return
if (!targetElement.value?.contains(event.target as Node)) return
if (step.value.expectedPath && route.path !== step.value.expectedPath) {
waitingForRoute.value = true
return
}
scheduleDestination()
}
function handleKeydown(event: KeyboardEvent) {
if (!visible.value || event.key !== 'Escape') return
event.preventDefault()
events.skip()
}
watch(
() => route.path,
(path) => {
if (!waitingForRoute.value || !step.value.expectedPath) return
if (path !== step.value.expectedPath) return
waitingForRoute.value = false
void advance()
},
)
watch(visible, async (isVisible) => {
if (isVisible) {
stepIndex.value = 0
waitingForRoute.value = false
creationPath.value = undefined
targetRetryCount = 0
await nextTick()
updateBubbleSize()
if (bubbleElement.value) bubbleObserver?.observe(bubbleElement.value)
} else {
bubbleObserver?.disconnect()
clearModalReservation()
}
updateTarget()
})
watch(step, async () => {
if (!visible.value) return
targetRetryCount = 0
await nextTick()
updateBubbleSize()
updateTarget()
})
watch(mode, () => {
stepIndex.value = 0
})
onMounted(() => {
document.addEventListener('click', handleDocumentClick, true)
document.addEventListener('keydown', handleKeydown)
window.addEventListener('resize', updateTarget)
window.addEventListener('scroll', updateTarget, true)
bubbleObserver = new ResizeObserver(updateBubbleSize)
if (bubbleElement.value) bubbleObserver.observe(bubbleElement.value)
})
onBeforeUnmount(() => {
clearTargetTracking()
clearModalReservation()
bubbleObserver?.disconnect()
if (advanceTimer) clearTimeout(advanceTimer)
if (unlockTimer) clearTimeout(unlockTimer)
document.removeEventListener('click', handleDocumentClick, true)
document.removeEventListener('keydown', handleKeydown)
window.removeEventListener('resize', updateTarget)
window.removeEventListener('scroll', updateTarget, true)
})
return {
advance,
bubbleElement,
bubblePlacement,
controlSpotlightStyle,
handleManualClick,
isDialogueStep,
isWelcomeStep,
step,
stepIndex,
steps,
targetRect,
}
}