Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 27 additions & 22 deletions src/web-ui/src/app/scenes/welcome/WelcomeScene.scss
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
}
}

Expand All @@ -118,6 +117,11 @@
}
}

@keyframes welcome-cursor-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}

@container (max-width: 560px) {
.welcome-scene {
&__content {
Expand Down Expand Up @@ -154,8 +158,9 @@
.welcome-scene {
animation: none;

&__word {
transition: none;
&__cursor {
animation: none;
visibility: hidden;
}
}
}
120 changes: 79 additions & 41 deletions src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<section
Expand Down Expand Up @@ -84,23 +125,20 @@ const WelcomeScene: React.FC = () => {
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]}
>
<span className="welcome-scene__word-slot" aria-hidden="true">
{/* Keep every phrase in the same grid cell so the suffix stays still. */}
{words.map((word, index) => (
<span
className="welcome-scene__word"
data-active={index === displayedWordIndex ? 'true' : 'false'}
key={index}
>
{word}
<span className="welcome-scene__phrase-slot" aria-hidden="true">
{/* Reserve the longest phrase so the brand never shifts while typing. */}
{phrases.map((phrase, index) => (
<span className="welcome-scene__phrase-sizer" key={index}>
{phrase}
</span>
))}
<span className="welcome-scene__typed-phrase">
{reducedMotion ? phrases[YOUR_WORD_INDEX] : text}
<span className="welcome-scene__cursor" />
</span>
</span>
<span className="welcome-scene__suffix" aria-hidden="true">{suffix}</span>
</h2>
</div>
</div>
Expand Down
10 changes: 8 additions & 2 deletions src/web-ui/src/locales/en-US/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1517,11 +1517,17 @@
"welcomeScene": {
"space": {
"separator": ", ",
"work": "Work",
"play": "Play",
"your": "Your",
"suffix": " space."
},
"phrases": {
"impossible": "Create the impossible.",
"dreams": "Let dreams bloom.",
"think": "Try an idea.",
"ideas": "Hello, ideas.",
"create": "Make it real.",
"explore": "Try a twist."
},
"tabLabel": "Welcome",
"welcomeBack": "Welcome back to",
"welcomeTabTitle": "Welcome",
Expand Down
10 changes: 8 additions & 2 deletions src/web-ui/src/locales/zh-CN/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1517,11 +1517,17 @@
"welcomeScene": {
"space": {
"separator": ",",
"work": "干点活儿",
"play": "玩点花样",
"your": "随你发挥",
"suffix": "。"
},
"phrases": {
"impossible": "创造不可能。",
"dreams": "让梦想开出花。",
"think": "试试新想法。",
"ideas": "灵感来敲门。",
"create": "把如果变成真。",
"explore": "折腾点新鲜的。"
},
"tabLabel": "欢迎使用",
"welcomeBack": "欢迎回到",
"welcomeTabTitle": "欢迎使用",
Expand Down
10 changes: 8 additions & 2 deletions src/web-ui/src/locales/zh-TW/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -1517,11 +1517,17 @@
"welcomeScene": {
"space": {
"separator": ",",
"work": "做點正事",
"play": "玩點花樣",
"your": "隨你發揮",
"suffix": "。"
},
"phrases": {
"impossible": "創造不可能。",
"dreams": "讓夢想開出花。",
"think": "試試新點子。",
"ideas": "靈感來敲門。",
"create": "把如果變成真。",
"explore": "來玩點新花樣。"
},
"tabLabel": "歡迎使用",
"welcomeBack": "歡迎回到",
"welcomeTabTitle": "歡迎使用",
Expand Down
Loading