diff --git a/src/web-ui/src/app/scenes/welcome/WelcomeScene.scss b/src/web-ui/src/app/scenes/welcome/WelcomeScene.scss index 06d13aacbf..9a7683cd9d 100644 --- a/src/web-ui/src/app/scenes/welcome/WelcomeScene.scss +++ b/src/web-ui/src/app/scenes/welcome/WelcomeScene.scss @@ -1,5 +1,5 @@ /** - * The welcome fades between three phrases, then rests on the final invitation. + * The welcome types and erases localized phrases in a fixed-width slot. */ .welcome-scene { position: relative; @@ -78,33 +78,32 @@ letter-spacing: var(--openbitfun-type-display-sm-letter-spacing); } - &__word-slot { + &__phrase-slot { display: grid; flex: 0 0 auto; - text-align: end; + text-align: start; } - &__word { + &__phrase-sizer, + &__typed-phrase { grid-area: 1 / 1; - white-space: nowrap; + white-space: pre; + } + + &__phrase-sizer { visibility: hidden; - opacity: 0; - transition: - opacity var(--openbitfun-motion-duration-slow) var(--openbitfun-motion-easing-smooth), - visibility 0s var(--openbitfun-motion-duration-slow); - - &[data-active='true'] { - visibility: inherit; - opacity: 1; - // Briefly overlap the fades without leaving two legible phrases stacked. - transition-delay: - calc(var(--openbitfun-motion-duration-slow) - var(--openbitfun-motion-duration-instant)), - 0s; - } + // Include the cursor's width and gap in the reserved space. + padding-inline-end: 0.14em; } - &__suffix { - white-space: pre; + &__cursor { + display: inline-block; + width: 0.06em; + height: 0.85em; + margin-inline-start: 0.08em; + vertical-align: -0.06em; + background: currentColor; + animation: welcome-cursor-blink 1s steps(1, end) infinite; } } @@ -118,6 +117,11 @@ } } +@keyframes welcome-cursor-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + @container (max-width: 560px) { .welcome-scene { &__content { @@ -154,8 +158,9 @@ .welcome-scene { animation: none; - &__word { - transition: none; + &__cursor { + animation: none; + visibility: hidden; } } } diff --git a/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx b/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx index abd5832dac..0091109955 100644 --- a/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx +++ b/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx @@ -3,52 +3,93 @@ * SceneViewport until the user opens a scene. */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useI18n } from '@/infrastructure/i18n'; import { isReducedMotionPreferred } from '@/shared/utils/motionPreference'; import './WelcomeScene.scss'; -const WORD_HOLD_MS = 3000; +const TYPE_MS = 100; +const DELETE_MS = 55; +const PHRASE_HOLD_MS = 2200; +const PHRASE_GAP_MS = 450; const YOUR_WORD_INDEX = 2; const WelcomeScene: React.FC = () => { const { t } = useI18n('common'); - const [wordIndex, setWordIndex] = useState(() => isReducedMotionPreferred() ? YOUR_WORD_INDEX : 0); + const [text, setText] = useState(''); const [reducedMotion, setReducedMotion] = useState(isReducedMotionPreferred); - const [isVisible, setIsVisible] = useState(() => !document.hidden); - const [isHovered, setIsHovered] = useState(false); - const words = [ - t('welcomeScene.space.work'), - t('welcomeScene.space.play'), - t('welcomeScene.space.your'), - ]; + const impossible = t('welcomeScene.phrases.impossible'); + const dreams = t('welcomeScene.phrases.dreams'); + const your = t('welcomeScene.space.your'); const suffix = t('welcomeScene.space.suffix'); + const think = t('welcomeScene.phrases.think'); + const ideas = t('welcomeScene.phrases.ideas'); + const create = t('welcomeScene.phrases.create'); + const explore = t('welcomeScene.phrases.explore'); + const phrases = useMemo( + () => [ + impossible, + dreams, + `${your}${suffix}`, + think, + ideas, + create, + explore, + ], + [impossible, dreams, your, suffix, think, ideas, create, explore], + ); useEffect(() => { const media = window.matchMedia?.('(prefers-reduced-motion: reduce)'); - const handleMotionChange = () => { - const prefersReducedMotion = isReducedMotionPreferred(); - setReducedMotion(prefersReducedMotion); - if (prefersReducedMotion) setWordIndex(YOUR_WORD_INDEX); - }; - const handleVisibilityChange = () => setIsVisible(!document.hidden); + const handleMotionChange = () => setReducedMotion(isReducedMotionPreferred()); media?.addEventListener('change', handleMotionChange); - document.addEventListener('visibilitychange', handleVisibilityChange); - return () => { - media?.removeEventListener('change', handleMotionChange); - document.removeEventListener('visibilitychange', handleVisibilityChange); - }; + return () => media?.removeEventListener('change', handleMotionChange); }, []); useEffect(() => { - if (reducedMotion || !isVisible || isHovered || wordIndex >= YOUR_WORD_INDEX) return; - const timer = window.setTimeout(() => { - setWordIndex(index => Math.min(index + 1, YOUR_WORD_INDEX)); - }, WORD_HOLD_MS); - return () => window.clearTimeout(timer); - }, [wordIndex, reducedMotion, isVisible, isHovered]); + if (reducedMotion) return; - const displayedWordIndex = reducedMotion ? YOUR_WORD_INDEX : wordIndex; + // Reset on locale changes; keep the full phrase, including punctuation, in the loop. + const characters = phrases.map(phrase => Array.from(phrase)); + let phraseIndex = 0; + let characterCount = 0; + let deleting = false; + let nextDelay = PHRASE_GAP_MS; + let timer: number | undefined; + setText(''); + + const schedule = () => { + if (!document.hidden) timer = window.setTimeout(tick, nextDelay); + }; + function tick() { + const phrase = characters[phraseIndex]; + characterCount += deleting ? -1 : 1; + setText(phrase.slice(0, characterCount).join('')); + + if (!deleting && characterCount === phrase.length) { + deleting = true; + nextDelay = PHRASE_HOLD_MS; + } else if (deleting && characterCount === 0) { + deleting = false; + phraseIndex = (phraseIndex + 1) % characters.length; + nextDelay = PHRASE_GAP_MS; + } else { + nextDelay = deleting ? DELETE_MS : TYPE_MS; + } + schedule(); + } + const handleVisibilityChange = () => { + window.clearTimeout(timer); + schedule(); + }; + + schedule(); + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + window.clearTimeout(timer); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [phrases, reducedMotion]); return (
{ className="welcome-scene__tagline" data-openbitfun-scene="welcome" data-openbitfun-part="subtitle" - aria-label={`${words[YOUR_WORD_INDEX]}${suffix}`} - onPointerEnter={() => setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} + aria-label={phrases[YOUR_WORD_INDEX]} > -