feat(docs): add welcome screen with animated logo

Introduce a new welcome screen that automatically redirects first-time visitors from the homepage. The screen features an animated "Easy Vibe" logo with three color themes (ocean, rainbow, sunset) that cycle through a drawing animation. Users can click anywhere to enter the main site.

The welcome screen includes:
- A JSON file containing SVG path data for the animated logo
- A Vue component with gradient backgrounds and smooth animations
- Logic to detect first-time visitors using localStorage
- Integration into the existing VitePress theme structure
- Updated navigation to exclude the welcome page from sidebar controls
- Modified homepage logic to redirect to welcome screen on first visit
This commit is contained in:
sanbuphy
2026-03-16 14:07:54 +08:00
parent 74c2f4ab26
commit ec95e132f4
7 changed files with 404 additions and 12 deletions
@@ -8,6 +8,7 @@ const { site, page, lang } = useData()
const activeTab = ref('home')
const showLangMenu = ref(false)
const topPromoProgress = ref(1)
const WELCOME_SEEN_KEY = 'easy-vibe-welcome-seen'
// Appendix Scroll Logic
const appendixWrapper = ref(null)
@@ -1629,7 +1630,45 @@ const topPromoStyle = computed(() => {
}
})
const replayIntro = () => {
const currentPath = window.location.pathname
router.go(withBase(`/welcome/?next=${encodeURIComponent(currentPath)}`))
}
onMounted(() => {
const currentPath = window.location.pathname
const basePath = site.value.base || '/'
const normalizedBase = basePath.endsWith('/') ? basePath : `${basePath}/`
const normalizedPath = currentPath.endsWith('/')
? currentPath
: `${currentPath}/`
const localeHomeSuffixes = [
'/zh-cn/',
'/en/',
'/zh-tw/',
'/ja-jp/',
'/ko-kr/',
'/es-es/',
'/fr-fr/',
'/de-de/',
'/ar-sa/',
'/vi-vn/'
]
const isLocaleHome = localeHomeSuffixes.some(
(suffix) =>
currentPath.endsWith(suffix) ||
currentPath.endsWith(`${suffix}index.html`)
)
const isRootHome =
normalizedPath === normalizedBase ||
currentPath === `${normalizedBase}index.html`
if (isRootHome && !isLocaleHome) {
const hasSeenWelcome = window.localStorage.getItem(WELCOME_SEEN_KEY) === '1'
if (!hasSeenWelcome) {
router.go(withBase(`/welcome/?next=${encodeURIComponent(currentPath)}`))
return
}
}
document.addEventListener('click', closeLangMenu)
if (appendixWrapper.value) {
appendixWrapper.value.addEventListener('scroll', onAppendixScroll)
@@ -1822,7 +1861,13 @@ const appendixCards = [
<nav class="sticky-nav glass">
<div class="nav-content">
<div class="nav-cluster">
<span class="nav-title">{{ t.nav.title }}</span>
<button
class="nav-title"
type="button"
@click="replayIntro"
>
{{ t.nav.title }}
</button>
<div class="nav-links">
<button
:class="{ active: activeTab === 'home' }"
@@ -2247,6 +2292,11 @@ a {
color: var(--vp-c-text-1) !important;
flex-shrink: 0;
letter-spacing: -0.008em;
background: none;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
}
.nav-links {
@@ -0,0 +1,319 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter, withBase } from 'vitepress'
import easyVibePaths from '../data/easyVibePaths.json'
const router = useRouter()
const WELCOME_SEEN_KEY = 'easy-vibe-welcome-seen'
const phase = ref('reset')
const theme = ref('ocean')
const themes = ['ocean', 'rainbow', 'sunset']
let timers = []
const themeColor = computed(() => `url(#welcome-${theme.value})`)
const themeClass = computed(() => `welcome-theme-${theme.value}`)
const clearTimers = () => {
timers.forEach((timer) => clearTimeout(timer))
timers = []
}
const runLoop = () => {
clearTimers()
const run = () => {
phase.value = 'draw'
timers.push(
setTimeout(() => {
phase.value = 'fade'
}, 6200)
)
timers.push(
setTimeout(() => {
phase.value = 'reset'
}, 7600)
)
timers.push(
setTimeout(() => {
const currentIndex = themes.indexOf(theme.value)
theme.value = themes[(currentIndex + 1) % themes.length]
run()
}, 7800)
)
}
timers.push(setTimeout(run, 80))
}
const enterHome = () => {
const params = new URLSearchParams(window.location.search)
const nextPath = params.get('next')
window.localStorage.setItem(WELCOME_SEEN_KEY, '1')
if (nextPath) {
router.go(nextPath)
return
}
router.go(withBase('/'))
}
onMounted(() => {
runLoop()
})
onUnmounted(() => {
clearTimers()
})
</script>
<template>
<div
class="welcome-overlay"
:class="themeClass"
@click="enterHome"
>
<div class="welcome-content">
<div
class="welcome-logo"
:style="{ '--welcome-theme-color': themeColor }"
:class="{
'welcome-fin': phase === 'draw' || phase === 'fade',
'welcome-fade': phase === 'fade',
'welcome-reset': phase === 'reset'
}"
>
<svg
viewBox="0 0 460 220"
class="welcome-svg"
>
<defs>
<linearGradient
id="welcome-rainbow"
x1="0"
y1="0"
x2="460"
y2="0"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stop-color="#00a6ff" />
<stop offset="18%" stop-color="#00c6a2" />
<stop offset="36%" stop-color="#53d93e" />
<stop offset="54%" stop-color="#f4c732" />
<stop offset="72%" stop-color="#ff7a1a" />
<stop offset="86%" stop-color="#ff3c81" />
<stop offset="100%" stop-color="#9d4edd" />
</linearGradient>
<linearGradient
id="welcome-ocean"
x1="0"
y1="0"
x2="460"
y2="0"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stop-color="#06b6d4" />
<stop offset="50%" stop-color="#0ea5e9" />
<stop offset="100%" stop-color="#3b82f6" />
</linearGradient>
<linearGradient
id="welcome-sunset"
x1="0"
y1="0"
x2="460"
y2="0"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stop-color="#f43f5e" />
<stop offset="50%" stop-color="#f97316" />
<stop offset="100%" stop-color="#f59e0b" />
</linearGradient>
</defs>
<path
v-for="(path, index) in easyVibePaths"
:key="index"
:d="path"
class="welcome-path"
:class="`welcome-path-${index}`"
/>
</svg>
</div>
<p class="welcome-tip">
Click anywhere to enter home
</p>
</div>
</div>
</template>
<style scoped>
.welcome-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
isolation: isolate;
background:
radial-gradient(120% 90% at 50% -20%, rgba(255, 255, 255, 0.86), rgba(255, 255, 255, 0)),
linear-gradient(135deg, #e8f8ff 0%, #e9edff 36%, #efe7ff 68%, #ffeef4 100%);
background-size: 130% 130%;
background-position: 0% 0%;
cursor: pointer;
animation: welcome-bg-base-flow 42s ease-in-out infinite alternate;
}
.welcome-overlay::before,
.welcome-overlay::after {
content: '';
position: absolute;
inset: -18%;
pointer-events: none;
will-change: transform, opacity;
}
.welcome-overlay::before {
background:
radial-gradient(60% 58% at 18% 45%, rgba(182, 225, 255, 0.32), rgba(182, 225, 255, 0)),
radial-gradient(48% 52% at 82% 62%, rgba(223, 199, 255, 0.28), rgba(223, 199, 255, 0));
animation: welcome-bg-wave-a 26s ease-in-out infinite alternate;
}
.welcome-overlay::after {
background:
radial-gradient(54% 52% at 68% 26%, rgba(186, 245, 228, 0.24), rgba(186, 245, 228, 0)),
radial-gradient(56% 48% at 30% 82%, rgba(255, 219, 189, 0.22), rgba(255, 219, 189, 0));
animation: welcome-bg-wave-b 34s ease-in-out infinite alternate;
}
.welcome-theme-ocean {
background:
radial-gradient(120% 90% at 50% -20%, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0)),
linear-gradient(135deg, #e0f7fa 0%, #e7f0ff 45%, #eef3ff 100%);
}
.welcome-theme-rainbow {
background:
radial-gradient(120% 90% at 50% -20%, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0)),
linear-gradient(135deg, #e8f8ff 0%, #e9edff 36%, #efe7ff 68%, #ffeef4 100%);
}
.welcome-theme-sunset {
background:
radial-gradient(120% 90% at 50% -20%, rgba(255, 255, 255, 0.86), rgba(255, 255, 255, 0)),
linear-gradient(135deg, #fff0e8 0%, #ffe9dc 45%, #ffe1f0 100%);
}
.welcome-content {
width: min(88vw, 700px);
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
position: relative;
z-index: 1;
}
.welcome-logo {
width: 100%;
opacity: 1;
}
.welcome-svg {
width: 100%;
height: auto;
}
.welcome-path {
fill: var(--welcome-theme-color);
fill-opacity: 0;
stroke: var(--welcome-theme-color);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1000;
stroke-dashoffset: 1000;
transition: none;
}
.welcome-fin .welcome-path {
stroke-dashoffset: 0;
fill-opacity: 1;
}
.welcome-fin .welcome-path-0 { transition: stroke-dashoffset 0.62s ease-in-out 0s, fill-opacity 0.45s ease-in 2.75s; }
.welcome-fin .welcome-path-1 { transition: stroke-dashoffset 0.62s ease-in-out 0.28s, fill-opacity 0.45s ease-in 2.75s; }
.welcome-fin .welcome-path-2 { transition: stroke-dashoffset 0.62s ease-in-out 0.56s, fill-opacity 0.45s ease-in 2.75s; }
.welcome-fin .welcome-path-3 { transition: stroke-dashoffset 0.62s ease-in-out 0.84s, fill-opacity 0.45s ease-in 2.75s; }
.welcome-fin .welcome-path-4 { transition: stroke-dashoffset 0.62s ease-in-out 1.12s, fill-opacity 0.45s ease-in 2.75s; }
.welcome-fin .welcome-path-5 { transition: stroke-dashoffset 0.62s ease-in-out 1.4s, fill-opacity 0.45s ease-in 2.75s; }
.welcome-fin .welcome-path-6 { transition: stroke-dashoffset 0.62s ease-in-out 1.68s, fill-opacity 0.45s ease-in 2.75s; }
.welcome-fin .welcome-path-7 { transition: stroke-dashoffset 0.62s ease-in-out 1.96s, fill-opacity 0.45s ease-in 2.75s; }
.welcome-fin .welcome-path-8 { transition: stroke-dashoffset 0.62s ease-in-out 2.24s, fill-opacity 0.45s ease-in 2.75s; }
.welcome-fade {
opacity: 0;
transition: opacity 0.85s ease-out;
}
.welcome-reset {
opacity: 0;
transition: none;
}
.welcome-tip {
margin: 24px 0 0;
font-size: 11px;
letter-spacing: 0.08em;
color: rgba(34, 34, 34, 0.38);
text-transform: uppercase;
animation: welcome-tip-breathe 7s ease-in-out infinite;
}
@keyframes welcome-tip-breathe {
0% {
opacity: 0.24;
}
50% {
opacity: 0.72;
}
100% {
opacity: 0.24;
}
}
@keyframes welcome-bg-base-flow {
0% {
background-position: 0% 0%;
}
100% {
background-position: 100% 100%;
}
}
@keyframes welcome-bg-wave-a {
0% {
transform: translate3d(-2.5%, 1.8%, 0) scale(1.02);
opacity: 0.72;
}
50% {
transform: translate3d(2%, -1.6%, 0) scale(1.06);
opacity: 0.9;
}
100% {
transform: translate3d(4%, -2.4%, 0) scale(1.08);
opacity: 0.72;
}
}
@keyframes welcome-bg-wave-b {
0% {
transform: translate3d(2.2%, -1.4%, 0) scale(1.01);
opacity: 0.6;
}
50% {
transform: translate3d(-2.6%, 1.6%, 0) scale(1.05);
opacity: 0.86;
}
100% {
transform: translate3d(-4.4%, 2.4%, 0) scale(1.07);
opacity: 0.6;
}
}
</style>