Welcome animation when a workspace is set up (#22622)
## Desktop https://github.com/user-attachments/assets/87999ff5-9257-4fed-90e0-781322b1db98 ## Mobile https://github.com/user-attachments/assets/0d115cb6-7bca-4229-8fde-0f188e862b00 Greets the user the moment they finish onboarding: a halftone Twenty mark (brand purple) assembles from scattered particles, shimmers, then bursts outward to reveal the freshly loaded workspace behind it. Plays once, ~3s, with a reduced-motion fallback. `PageChangeEffect` sets a transient `isWelcomeAnimationVisibleState` atom at the onboarding to workspace redirect seam (gated by `shouldShowWelcomeAnimationOnNavigate`); `WelcomeOverlay`, mounted in `WorkspaceAppProviders`, plays it and clears it. Transient by design, so it never replays on reload. ## Why a canvas rendered on another thread The overlay plays at the exact moment the workspace mounts behind it, which is one of the heaviest main-thread stretches in the app (metadata, providers, first data fetch). Anything animating on the main thread competes with that work: - framer-motion and plain main-thread `requestAnimationFrame` visibly stuttered and froze during the mount, because long tasks starve rAF. - CSS keyframes stay smooth (they run on the compositor), but can't drive a ~2900-particle halftone with per-particle physics (staggered assemble, moving shimmer band, directional burst). So the halftone runs as a Canvas 2D particle system inside a Web Worker, drawing to an `OffscreenCanvas` transferred from the main thread. The whole render loop lives off the main thread, so app-mount jank can't reach it. A main-thread path is kept as a fallback for browsers without `OffscreenCanvas` / `transferControlToOffscreen`, and Safari workers (which lack rAF) fall back to a fixed-interval loop. The renderer is a pure, DOM-free module so it bundles cleanly into the worker; dot colors come from CSS vars read on the main thread and passed in. Also fires for every onboarding completion path and skips the billing `PlanRequired` detour. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22622?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -20,6 +20,7 @@ import { IsMinimalMetadataReadyEffect } from '@/metadata-store/effect-components
|
||||
import { MinimalMetadataLoadEffect } from '@/metadata-store/effect-components/MinimalMetadataLoadEffect';
|
||||
import { UserMetadataProviderInitialEffect } from '@/metadata-store/effect-components/UserMetadataProviderInitialEffect';
|
||||
import { ApolloCoreProvider } from '@/object-metadata/components/ApolloCoreProvider';
|
||||
import { WelcomeOverlay } from '@/onboarding/components/WelcomeOverlay/WelcomeOverlay';
|
||||
import { ApolloAdminProvider } from '@/settings/admin-panel/apollo/components/ApolloAdminProvider';
|
||||
import { EndTrialAfterPaymentMethodGater } from '@/settings/billing/components/EndTrialAfterPaymentMethodGater';
|
||||
import { SSEProvider } from '@/sse-db-event/components/SSEProvider';
|
||||
@@ -80,6 +81,7 @@ export const WorkspaceAppProviders = () => {
|
||||
<TrackPageViewEffect />
|
||||
<RequestFreshCaptchaTokenEffect />
|
||||
<PageChangeEffect />
|
||||
<WelcomeOverlay />
|
||||
<SignOutOnOtherTabSignOutEffect />
|
||||
</SSEProvider>
|
||||
</ApolloAdminProvider>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useExecuteTasksOnAnyLocationChange } from '@/app/hooks/useExecuteTasksOnAnyLocationChange';
|
||||
import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState';
|
||||
import { ONBOARDING_PATHS } from '@/auth/constants/OnboardingPaths';
|
||||
import { ONGOING_USER_CREATION_PATHS } from '@/auth/constants/OngoingUserCreationPaths';
|
||||
import { useReturnToPath } from '@/auth/hooks/useReturnToPath';
|
||||
import { useIsOnAuthOrOnboardingPage } from '@/auth/hooks/useIsOnAuthOrOnboardingPage';
|
||||
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
||||
@@ -19,6 +21,9 @@ import { useActiveRecordTableRow } from '@/object-record/record-table/hooks/useA
|
||||
import { useFocusedRecordTableRow } from '@/object-record/record-table/hooks/useFocusedRecordTableRow';
|
||||
import { useOpenNewRecordTitleCell } from '@/object-record/record-title-cell/hooks/useOpenNewRecordTitleCell';
|
||||
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
|
||||
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
|
||||
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
|
||||
import { shouldShowWelcomeAnimationOnNavigate } from '@/onboarding/utils/shouldShowWelcomeAnimationOnNavigate';
|
||||
import { PageFocusId } from '@/types/PageFocusId';
|
||||
import { useResetFocusStackToFocusItem } from '@/ui/utilities/focus/hooks/useResetFocusStackToFocusItem';
|
||||
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
||||
@@ -39,6 +44,12 @@ import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffect
|
||||
import { getPageLayoutIdForLocation } from '~/modules/app/utils/getPageLayoutIdForLocation';
|
||||
import { isMatchingLocation } from '~/utils/isMatchingLocation';
|
||||
|
||||
const ONBOARDING_OR_AUTH_NAVIGATE_PATHS = [
|
||||
...ONBOARDING_PATHS,
|
||||
...ONGOING_USER_CREATION_PATHS,
|
||||
AppPath.ResetPassword,
|
||||
];
|
||||
|
||||
// TODO: break down into smaller functions and / or hooks
|
||||
// - moved usePageChangeEffectNavigateLocation into dedicated hook
|
||||
export const PageChangeEffect = () => {
|
||||
@@ -95,6 +106,12 @@ export const PageChangeEffect = () => {
|
||||
|
||||
const isOnAuthOrOnboardingPage = useIsOnAuthOrOnboardingPage();
|
||||
|
||||
const onboardingStatus = useOnboardingStatus();
|
||||
|
||||
const isOnOnboardingPage = ONBOARDING_PATHS.some((appPath) =>
|
||||
isMatchingLocation(location, appPath),
|
||||
);
|
||||
|
||||
const closeSidePanelUnlessNotRelevant = useCallback(() => {
|
||||
const currentPage = store.get(sidePanelPageState.atom);
|
||||
|
||||
@@ -152,6 +169,21 @@ export const PageChangeEffect = () => {
|
||||
const consumedReturnToPath =
|
||||
getReturnToPath() === pageChangeEffectNavigateLocation;
|
||||
|
||||
const isNavigatingToOnboardingOrAuthPath =
|
||||
ONBOARDING_OR_AUTH_NAVIGATE_PATHS.some(
|
||||
(appPath) => pageChangeEffectNavigateLocation === appPath,
|
||||
);
|
||||
|
||||
if (
|
||||
shouldShowWelcomeAnimationOnNavigate({
|
||||
onboardingStatus,
|
||||
isOnOnboardingPage,
|
||||
isNavigatingToOnboardingOrAuthPath,
|
||||
})
|
||||
) {
|
||||
store.set(isWelcomeAnimationVisibleState.atom, true);
|
||||
}
|
||||
|
||||
navigate(pageChangeEffectNavigateLocation);
|
||||
|
||||
if (consumedReturnToPath) {
|
||||
@@ -163,9 +195,12 @@ export const PageChangeEffect = () => {
|
||||
pageChangeEffectNavigateLocation,
|
||||
isAppEffectRedirectEnabled,
|
||||
isOnAuthOrOnboardingPage,
|
||||
isOnOnboardingPage,
|
||||
onboardingStatus,
|
||||
saveReturnToPath,
|
||||
getReturnToPath,
|
||||
clearReturnToPath,
|
||||
store,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const WELCOME_HOLD_DURATION_MS = 2900;
|
||||
|
||||
type WelcomeAnimationAutoLeaveEffectProps = {
|
||||
onAutoLeave: () => void;
|
||||
};
|
||||
|
||||
export const WelcomeAnimationAutoLeaveEffect = ({
|
||||
onAutoLeave,
|
||||
}: WelcomeAnimationAutoLeaveEffectProps) => {
|
||||
useEffect(() => {
|
||||
const autoLeaveTimeoutId = setTimeout(
|
||||
onAutoLeave,
|
||||
WELCOME_HOLD_DURATION_MS,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearTimeout(autoLeaveTimeoutId);
|
||||
};
|
||||
}, [onAutoLeave]);
|
||||
|
||||
return null;
|
||||
};
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { WelcomeHalftoneCanvasEffect } from '@/onboarding/components/WelcomeOverlay/WelcomeHalftoneCanvasEffect';
|
||||
|
||||
import './welcomeHalftone.css';
|
||||
|
||||
const StyledCanvas = styled.canvas`
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type WelcomeHalftoneCanvasProps = {
|
||||
isLeaving: boolean;
|
||||
};
|
||||
|
||||
export const WelcomeHalftoneCanvas = ({
|
||||
isLeaving,
|
||||
}: WelcomeHalftoneCanvasProps) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [hasWorkerFailed, setHasWorkerFailed] = useState(false);
|
||||
const markWorkerAsFailed = useCallback(() => setHasWorkerFailed(true), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledCanvas
|
||||
key={hasWorkerFailed ? 'main-thread' : 'worker'}
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<WelcomeHalftoneCanvasEffect
|
||||
canvasRef={canvasRef}
|
||||
isLeaving={isLeaving}
|
||||
hasWorkerFailed={hasWorkerFailed}
|
||||
onWorkerFailed={markWorkerAsFailed}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { type RefObject, useEffect, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { createWelcomeHalftoneMainThreadController } from '@/onboarding/components/WelcomeOverlay/createWelcomeHalftoneMainThreadController';
|
||||
import { createWelcomeHalftoneWorkerController } from '@/onboarding/components/WelcomeOverlay/createWelcomeHalftoneWorkerController';
|
||||
import { type WelcomeHalftoneController } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneController.type';
|
||||
|
||||
type WelcomeHalftoneCanvasEffectProps = {
|
||||
canvasRef: RefObject<HTMLCanvasElement | null>;
|
||||
isLeaving: boolean;
|
||||
hasWorkerFailed: boolean;
|
||||
onWorkerFailed: () => void;
|
||||
};
|
||||
|
||||
export const WelcomeHalftoneCanvasEffect = ({
|
||||
canvasRef,
|
||||
isLeaving,
|
||||
hasWorkerFailed,
|
||||
onWorkerFailed,
|
||||
}: WelcomeHalftoneCanvasEffectProps) => {
|
||||
const [activeHalftoneController, setActiveHalftoneController] =
|
||||
useState<WelcomeHalftoneController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!isDefined(canvas)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prefersReducedMotion =
|
||||
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||
const canvasStyles = getComputedStyle(canvas);
|
||||
const dotColor = canvasStyles
|
||||
.getPropertyValue('--welcome-dot-color')
|
||||
.trim();
|
||||
const dotHighlightColor =
|
||||
canvasStyles.getPropertyValue('--welcome-dot-highlight').trim() ||
|
||||
dotColor;
|
||||
const readDevicePixelRatio = () =>
|
||||
Math.min(window.devicePixelRatio || 1, 2);
|
||||
const readCanvasClientSize = () => ({
|
||||
width: canvas.clientWidth,
|
||||
height: canvas.clientHeight,
|
||||
});
|
||||
|
||||
const initialCanvasSize = readCanvasClientSize();
|
||||
const sharedControllerOptions = {
|
||||
canvas,
|
||||
dotColor,
|
||||
dotHighlightColor,
|
||||
prefersReducedMotion,
|
||||
initialCanvasWidth: initialCanvasSize.width,
|
||||
initialCanvasHeight: initialCanvasSize.height,
|
||||
devicePixelRatio: readDevicePixelRatio(),
|
||||
};
|
||||
|
||||
const activeController =
|
||||
(hasWorkerFailed
|
||||
? null
|
||||
: createWelcomeHalftoneWorkerController({
|
||||
...sharedControllerOptions,
|
||||
onWorkerUnavailable: onWorkerFailed,
|
||||
})) ??
|
||||
createWelcomeHalftoneMainThreadController(sharedControllerOptions);
|
||||
|
||||
if (!isDefined(activeController)) {
|
||||
return;
|
||||
}
|
||||
setActiveHalftoneController(activeController);
|
||||
|
||||
const handleWindowResize = () => {
|
||||
const nextCanvasSize = readCanvasClientSize();
|
||||
activeController.resize(
|
||||
nextCanvasSize.width,
|
||||
nextCanvasSize.height,
|
||||
readDevicePixelRatio(),
|
||||
);
|
||||
};
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleWindowResize);
|
||||
activeController.destroy();
|
||||
};
|
||||
}, [canvasRef, hasWorkerFailed, onWorkerFailed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLeaving) {
|
||||
activeHalftoneController?.leave();
|
||||
}
|
||||
}, [isLeaving, activeHalftoneController]);
|
||||
|
||||
return null;
|
||||
};
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import {
|
||||
type AnimationEvent,
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { WelcomeAnimationAutoLeaveEffect } from '@/onboarding/components/WelcomeOverlay/WelcomeAnimationAutoLeaveEffect';
|
||||
import { WelcomeHalftoneCanvas } from '@/onboarding/components/WelcomeOverlay/WelcomeHalftoneCanvas';
|
||||
import { WelcomePersonChip } from '@/onboarding/components/WelcomeOverlay/WelcomePersonChip';
|
||||
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
|
||||
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
|
||||
const WELCOME_TITLE_WORDS = ['Welcome', 'to', 'your', 'workspace'];
|
||||
|
||||
const StyledOverlay = styled.div`
|
||||
align-items: center;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: ${RootStackingContextZIndices.WelcomeOverlay};
|
||||
`;
|
||||
|
||||
const StyledBackdrop = styled.div`
|
||||
background: ${themeCssVariables.background.primary};
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
will-change: opacity;
|
||||
z-index: 0;
|
||||
|
||||
&.is-leaving {
|
||||
animation: welcomeBackdropOut 0.7s cubic-bezier(0.5, 0, 0.1, 1) 0.08s
|
||||
forwards;
|
||||
}
|
||||
|
||||
@keyframes welcomeBackdropOut {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
&.is-leaving {
|
||||
animation-duration: 0.4s;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCanvasLayer = styled.div`
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.div`
|
||||
align-items: center;
|
||||
animation: welcomeTitleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) 0.8s both;
|
||||
background: ${themeCssVariables.background.primary};
|
||||
border-radius: ${themeCssVariables.border.radius.pill};
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
font-size: 26px;
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
justify-content: center;
|
||||
padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[8]};
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
will-change: transform, opacity;
|
||||
z-index: 2;
|
||||
|
||||
@keyframes welcomeTitleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
flex-wrap: wrap;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
&.is-leaving {
|
||||
animation: welcomeTitleOut 0.34s cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes welcomeTitleOut {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
|
||||
&.is-leaving {
|
||||
animation-name: welcomeTitleFadeOut;
|
||||
}
|
||||
|
||||
@keyframes welcomeTitleFadeOut {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledWord = styled.span`
|
||||
animation: welcomeWordIn 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
animation-delay: calc(1.1s + var(--word-index) * 0.07s);
|
||||
display: inline-flex;
|
||||
|
||||
@keyframes welcomeWordIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation-name: welcomeWordFadeIn;
|
||||
animation-delay: 0s;
|
||||
|
||||
@keyframes welcomeWordFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const WelcomeOverlay = () => {
|
||||
const isWelcomeAnimationVisible = useAtomStateValue(
|
||||
isWelcomeAnimationVisibleState,
|
||||
);
|
||||
const setIsWelcomeAnimationVisible = useSetAtomState(
|
||||
isWelcomeAnimationVisibleState,
|
||||
);
|
||||
const [isLeaving, setIsLeaving] = useState(false);
|
||||
|
||||
const startLeaving = useCallback(() => setIsLeaving(true), []);
|
||||
|
||||
if (!isWelcomeAnimationVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleBackdropAnimationEnd = (
|
||||
event: AnimationEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (event.target === event.currentTarget && isLeaving) {
|
||||
setIsWelcomeAnimationVisible(false);
|
||||
setIsLeaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const leavingClassName = isLeaving ? 'is-leaving' : undefined;
|
||||
|
||||
return createPortal(
|
||||
<StyledOverlay>
|
||||
<WelcomeAnimationAutoLeaveEffect onAutoLeave={startLeaving} />
|
||||
<StyledBackdrop
|
||||
className={leavingClassName}
|
||||
onAnimationEnd={handleBackdropAnimationEnd}
|
||||
/>
|
||||
<StyledCanvasLayer>
|
||||
<WelcomeHalftoneCanvas isLeaving={isLeaving} />
|
||||
</StyledCanvasLayer>
|
||||
<StyledTitle className={leavingClassName}>
|
||||
{WELCOME_TITLE_WORDS.map((word, index) => (
|
||||
<StyledWord
|
||||
key={word}
|
||||
style={{ '--word-index': index } as CSSProperties}
|
||||
>
|
||||
{word}
|
||||
</StyledWord>
|
||||
))}
|
||||
<StyledWord
|
||||
style={
|
||||
{ '--word-index': WELCOME_TITLE_WORDS.length } as CSSProperties
|
||||
}
|
||||
>
|
||||
<WelcomePersonChip />
|
||||
</StyledWord>
|
||||
</StyledTitle>
|
||||
</StyledOverlay>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { Avatar } from 'twenty-ui/data-display';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
|
||||
|
||||
const StyledChip = styled.div`
|
||||
align-items: center;
|
||||
background: ${themeCssVariables.background.transparent.light};
|
||||
border-radius: ${themeCssVariables.border.radius.md};
|
||||
display: inline-flex;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
padding: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledPersonName = styled.span`
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
max-width: min(40vw, 360px);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const WelcomePersonChip = () => {
|
||||
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
|
||||
const firstName = currentWorkspaceMember?.name?.firstName ?? '';
|
||||
const lastName = currentWorkspaceMember?.name?.lastName ?? '';
|
||||
const fullName = `${firstName} ${lastName}`.trim();
|
||||
|
||||
return (
|
||||
<StyledChip>
|
||||
<Avatar
|
||||
type="rounded"
|
||||
size="lg"
|
||||
placeholder={fullName}
|
||||
placeholderColorSeed={currentWorkspaceMember?.id}
|
||||
avatarUrl={getAbsoluteImageUrl(currentWorkspaceMember?.avatarUrl)}
|
||||
/>
|
||||
<StyledPersonName>{fullName}</StyledPersonName>
|
||||
</StyledChip>
|
||||
);
|
||||
};
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { WelcomeOverlay } from '@/onboarding/components/WelcomeOverlay/WelcomeOverlay';
|
||||
import { isWelcomeAnimationVisibleState } from '@/onboarding/states/isWelcomeAnimationVisibleState';
|
||||
import {
|
||||
jotaiStore,
|
||||
resetJotaiStore,
|
||||
} from '@/ui/utilities/state/jotai/jotaiStore';
|
||||
|
||||
import { mockedWorkspaceMemberData } from '~/testing/mock-data/users';
|
||||
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
createElement(JotaiProvider, { store: jotaiStore }, children);
|
||||
|
||||
describe('WelcomeOverlay', () => {
|
||||
beforeEach(() => {
|
||||
resetJotaiStore();
|
||||
jotaiStore.set(currentWorkspaceMemberState.atom, {
|
||||
...mockedWorkspaceMemberData,
|
||||
name: { firstName: 'Marie', lastName: 'Curie' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should render nothing when the welcome animation is not visible', () => {
|
||||
render(<WelcomeOverlay />, { wrapper: Wrapper });
|
||||
|
||||
expect(screen.queryByText('Welcome')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the welcome message and the member full name when visible', () => {
|
||||
jotaiStore.set(isWelcomeAnimationVisibleState.atom, true);
|
||||
|
||||
render(<WelcomeOverlay />, { wrapper: Wrapper });
|
||||
|
||||
expect(screen.getByText('Welcome')).toBeInTheDocument();
|
||||
expect(screen.getByText('workspace')).toBeInTheDocument();
|
||||
expect(screen.getByText('Marie Curie')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { type WelcomeHalftoneParticle } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneParticle.type';
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
const VIEWBOX_WIDTH = 297.037;
|
||||
const VIEWBOX_CENTER_X = 148.5;
|
||||
const VIEWBOX_CENTER_Y = 119.5;
|
||||
const ASSEMBLE_STAGGER_SECONDS = 0.18;
|
||||
const ASSEMBLE_JITTER_SECONDS = 0.12;
|
||||
const MINIMUM_STROKE_WIDTH = 0.6;
|
||||
|
||||
const pseudoRandomFromSeed = (seed: number) => {
|
||||
const noise = Math.sin(seed) * 43758.5453;
|
||||
return noise - Math.floor(noise);
|
||||
};
|
||||
|
||||
type WelcomeHalftoneParticleLayout = {
|
||||
particles: WelcomeHalftoneParticle[];
|
||||
halftoneSize: number;
|
||||
maxDistanceToCenter: number;
|
||||
};
|
||||
|
||||
export const buildWelcomeHalftoneParticles = (
|
||||
dashes: readonly (readonly [number, number, number, number])[],
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
): WelcomeHalftoneParticleLayout => {
|
||||
const halftoneSize = Math.max(canvasWidth * 1.05, canvasHeight * 1.3);
|
||||
const viewboxToCanvasScale = halftoneSize / VIEWBOX_WIDTH;
|
||||
const canvasCenterX = canvasWidth / 2;
|
||||
const canvasCenterY = canvasHeight / 2;
|
||||
const maxDistanceToCenter =
|
||||
Math.hypot(VIEWBOX_CENTER_X, VIEWBOX_CENTER_Y) * viewboxToCanvasScale || 1;
|
||||
|
||||
const particles = dashes.map(
|
||||
([dashStartX, dashEndX, dashY, dashStrokeWidth], dashIndex) => {
|
||||
const targetX =
|
||||
canvasCenterX +
|
||||
((dashStartX + dashEndX) / 2 - VIEWBOX_CENTER_X) * viewboxToCanvasScale;
|
||||
const targetY =
|
||||
canvasCenterY + (dashY - VIEWBOX_CENTER_Y) * viewboxToCanvasScale;
|
||||
const distanceToCenter = Math.hypot(
|
||||
targetX - canvasCenterX,
|
||||
targetY - canvasCenterY,
|
||||
);
|
||||
const scatterAngle = pseudoRandomFromSeed(dashIndex * 1.3) * TAU;
|
||||
const scatterRadius =
|
||||
halftoneSize * (0.35 + 0.5 * pseudoRandomFromSeed(dashIndex * 2.1));
|
||||
|
||||
return {
|
||||
targetX,
|
||||
targetY,
|
||||
scatterStartX: canvasCenterX + Math.cos(scatterAngle) * scatterRadius,
|
||||
scatterStartY: canvasCenterY + Math.sin(scatterAngle) * scatterRadius,
|
||||
dashLength: Math.max(dashEndX - dashStartX, 0) * viewboxToCanvasScale,
|
||||
strokeWidth: Math.max(
|
||||
dashStrokeWidth * viewboxToCanvasScale,
|
||||
MINIMUM_STROKE_WIDTH,
|
||||
),
|
||||
burstDirectionX:
|
||||
distanceToCenter > 0
|
||||
? (targetX - canvasCenterX) / distanceToCenter
|
||||
: 0,
|
||||
burstDirectionY:
|
||||
distanceToCenter > 0
|
||||
? (targetY - canvasCenterY) / distanceToCenter
|
||||
: -1,
|
||||
distanceToCenter,
|
||||
assembleDelaySeconds:
|
||||
ASSEMBLE_STAGGER_SECONDS * (distanceToCenter / maxDistanceToCenter) +
|
||||
ASSEMBLE_JITTER_SECONDS * pseudoRandomFromSeed(dashIndex * 3.7),
|
||||
driftPhase: pseudoRandomFromSeed(dashIndex * 5.1) * TAU,
|
||||
positionAtLeaveStartX: 0,
|
||||
positionAtLeaveStartY: 0,
|
||||
opacityAtLeaveStart: 0,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return { particles, halftoneSize, maxDistanceToCenter };
|
||||
};
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
const FALLBACK_FRAME_INTERVAL_MS = 16;
|
||||
|
||||
type WelcomeHalftoneAnimationFrameLoop = {
|
||||
requestNextFrame: (onFrame: (timeMs: number) => void) => void;
|
||||
cancelPendingFrame: () => void;
|
||||
};
|
||||
|
||||
// requestAnimationFrame is unavailable inside a dedicated worker (the primary
|
||||
// rendering path), so fall back to a fixed-interval timeout there.
|
||||
export const createWelcomeHalftoneAnimationFrameLoop =
|
||||
(): WelcomeHalftoneAnimationFrameLoop => {
|
||||
const supportsAnimationFrame = typeof requestAnimationFrame === 'function';
|
||||
let pendingFrameHandle = 0;
|
||||
|
||||
return {
|
||||
requestNextFrame: (onFrame) => {
|
||||
pendingFrameHandle = supportsAnimationFrame
|
||||
? requestAnimationFrame(onFrame)
|
||||
: (setTimeout(
|
||||
() => onFrame(performance.now()),
|
||||
FALLBACK_FRAME_INTERVAL_MS,
|
||||
) as unknown as number);
|
||||
},
|
||||
cancelPendingFrame: () => {
|
||||
if (supportsAnimationFrame) {
|
||||
cancelAnimationFrame(pendingFrameHandle);
|
||||
} else {
|
||||
clearTimeout(pendingFrameHandle);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { WELCOME_HALFTONE_DASHES } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneDots';
|
||||
import { type WelcomeHalftoneController } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneController.type';
|
||||
import { createWelcomeHalftoneRenderer } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneRenderer';
|
||||
|
||||
type CreateWelcomeHalftoneMainThreadControllerOptions = {
|
||||
canvas: HTMLCanvasElement;
|
||||
dotColor: string;
|
||||
dotHighlightColor: string;
|
||||
prefersReducedMotion: boolean;
|
||||
initialCanvasWidth: number;
|
||||
initialCanvasHeight: number;
|
||||
devicePixelRatio: number;
|
||||
};
|
||||
|
||||
export const createWelcomeHalftoneMainThreadController = ({
|
||||
canvas,
|
||||
dotColor,
|
||||
dotHighlightColor,
|
||||
prefersReducedMotion,
|
||||
initialCanvasWidth,
|
||||
initialCanvasHeight,
|
||||
devicePixelRatio,
|
||||
}: CreateWelcomeHalftoneMainThreadControllerOptions): WelcomeHalftoneController | null => {
|
||||
let renderingContext: CanvasRenderingContext2D | null = null;
|
||||
try {
|
||||
renderingContext = canvas.getContext('2d');
|
||||
} catch {
|
||||
renderingContext = null;
|
||||
}
|
||||
if (!isDefined(renderingContext)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const applyBackingStoreSize = (
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
activeDevicePixelRatio: number,
|
||||
) => {
|
||||
canvas.width = Math.round(canvasWidth * activeDevicePixelRatio);
|
||||
canvas.height = Math.round(canvasHeight * activeDevicePixelRatio);
|
||||
};
|
||||
|
||||
applyBackingStoreSize(
|
||||
initialCanvasWidth,
|
||||
initialCanvasHeight,
|
||||
devicePixelRatio,
|
||||
);
|
||||
const renderer = createWelcomeHalftoneRenderer({
|
||||
context: renderingContext,
|
||||
dashes: WELCOME_HALFTONE_DASHES,
|
||||
width: initialCanvasWidth,
|
||||
height: initialCanvasHeight,
|
||||
devicePixelRatio,
|
||||
color: dotColor,
|
||||
highlightColor: dotHighlightColor,
|
||||
reducedMotion: prefersReducedMotion,
|
||||
});
|
||||
|
||||
return {
|
||||
leave: () => renderer.leave(),
|
||||
resize: (nextCanvasWidth, nextCanvasHeight, nextDevicePixelRatio) => {
|
||||
applyBackingStoreSize(
|
||||
nextCanvasWidth,
|
||||
nextCanvasHeight,
|
||||
nextDevicePixelRatio,
|
||||
);
|
||||
renderer.resize(nextCanvasWidth, nextCanvasHeight, nextDevicePixelRatio);
|
||||
},
|
||||
destroy: () => renderer.destroy(),
|
||||
};
|
||||
};
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type WelcomeHalftoneController } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneController.type';
|
||||
|
||||
import WelcomeHalftoneWorker from './welcomeHalftone.worker?worker';
|
||||
|
||||
const WORKER_READY_TIMEOUT_MS = 1500;
|
||||
|
||||
type CreateWelcomeHalftoneWorkerControllerOptions = {
|
||||
canvas: HTMLCanvasElement;
|
||||
dotColor: string;
|
||||
dotHighlightColor: string;
|
||||
prefersReducedMotion: boolean;
|
||||
initialCanvasWidth: number;
|
||||
initialCanvasHeight: number;
|
||||
devicePixelRatio: number;
|
||||
onWorkerUnavailable: () => void;
|
||||
};
|
||||
|
||||
export const createWelcomeHalftoneWorkerController = ({
|
||||
canvas,
|
||||
dotColor,
|
||||
dotHighlightColor,
|
||||
prefersReducedMotion,
|
||||
initialCanvasWidth,
|
||||
initialCanvasHeight,
|
||||
devicePixelRatio,
|
||||
onWorkerUnavailable,
|
||||
}: CreateWelcomeHalftoneWorkerControllerOptions): WelcomeHalftoneController | null => {
|
||||
const isOffscreenCanvasSupported =
|
||||
typeof Worker !== 'undefined' &&
|
||||
typeof OffscreenCanvas !== 'undefined' &&
|
||||
typeof canvas.transferControlToOffscreen === 'function';
|
||||
if (!isOffscreenCanvasSupported) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let worker: Worker | null = null;
|
||||
let workerReadyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let hasWorkerSettled = false;
|
||||
let hasCanvasBeenTransferred = false;
|
||||
|
||||
const settleWorker = () => {
|
||||
hasWorkerSettled = true;
|
||||
if (isDefined(workerReadyTimeoutId)) {
|
||||
clearTimeout(workerReadyTimeoutId);
|
||||
workerReadyTimeoutId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleWorkerUnavailable = () => {
|
||||
if (hasWorkerSettled) {
|
||||
return;
|
||||
}
|
||||
settleWorker();
|
||||
worker?.terminate();
|
||||
onWorkerUnavailable();
|
||||
};
|
||||
|
||||
try {
|
||||
worker = new WelcomeHalftoneWorker();
|
||||
workerReadyTimeoutId = setTimeout(
|
||||
handleWorkerUnavailable,
|
||||
WORKER_READY_TIMEOUT_MS,
|
||||
);
|
||||
worker.onerror = handleWorkerUnavailable;
|
||||
worker.onmessage = (event: MessageEvent<{ type?: string }>) => {
|
||||
if (event.data?.type === 'ready') {
|
||||
settleWorker();
|
||||
}
|
||||
};
|
||||
|
||||
const offscreenCanvas = canvas.transferControlToOffscreen();
|
||||
hasCanvasBeenTransferred = true;
|
||||
worker.postMessage(
|
||||
{
|
||||
type: 'init',
|
||||
canvas: offscreenCanvas,
|
||||
width: initialCanvasWidth,
|
||||
height: initialCanvasHeight,
|
||||
devicePixelRatio,
|
||||
color: dotColor,
|
||||
highlightColor: dotHighlightColor,
|
||||
reducedMotion: prefersReducedMotion,
|
||||
},
|
||||
[offscreenCanvas],
|
||||
);
|
||||
|
||||
return {
|
||||
leave: () => worker?.postMessage({ type: 'leave' }),
|
||||
resize: (nextCanvasWidth, nextCanvasHeight, nextDevicePixelRatio) =>
|
||||
worker?.postMessage({
|
||||
type: 'resize',
|
||||
width: nextCanvasWidth,
|
||||
height: nextCanvasHeight,
|
||||
devicePixelRatio: nextDevicePixelRatio,
|
||||
}),
|
||||
destroy: () => {
|
||||
settleWorker();
|
||||
worker?.postMessage({ type: 'stop' });
|
||||
worker?.terminate();
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
settleWorker();
|
||||
worker?.terminate();
|
||||
if (hasCanvasBeenTransferred) {
|
||||
onWorkerUnavailable();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
:root {
|
||||
--welcome-dot-color: #4a38f5;
|
||||
--welcome-dot-highlight: #9b91f9;
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import { WELCOME_HALFTONE_DASHES } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneDots';
|
||||
import { createWelcomeHalftoneRenderer } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneRenderer';
|
||||
|
||||
type InitMessage = {
|
||||
type: 'init';
|
||||
canvas: OffscreenCanvas;
|
||||
width: number;
|
||||
height: number;
|
||||
devicePixelRatio: number;
|
||||
color: string;
|
||||
highlightColor: string;
|
||||
reducedMotion: boolean;
|
||||
};
|
||||
|
||||
type ResizeMessage = {
|
||||
type: 'resize';
|
||||
width: number;
|
||||
height: number;
|
||||
devicePixelRatio: number;
|
||||
};
|
||||
|
||||
type WelcomeHalftoneWorkerMessage =
|
||||
| InitMessage
|
||||
| ResizeMessage
|
||||
| { type: 'leave' }
|
||||
| { type: 'stop' };
|
||||
|
||||
const workerSelf = self as unknown as {
|
||||
postMessage: (message: unknown) => void;
|
||||
};
|
||||
|
||||
let renderer: ReturnType<typeof createWelcomeHalftoneRenderer> | null = null;
|
||||
let offscreenCanvas: OffscreenCanvas | null = null;
|
||||
|
||||
const applyBackingSize = (
|
||||
width: number,
|
||||
height: number,
|
||||
devicePixelRatio: number,
|
||||
) => {
|
||||
if (offscreenCanvas === null) {
|
||||
return;
|
||||
}
|
||||
offscreenCanvas.width = Math.round(width * devicePixelRatio);
|
||||
offscreenCanvas.height = Math.round(height * devicePixelRatio);
|
||||
};
|
||||
|
||||
addEventListener(
|
||||
'message',
|
||||
(event: MessageEvent<WelcomeHalftoneWorkerMessage>) => {
|
||||
const message = event.data;
|
||||
|
||||
if (message.type === 'init') {
|
||||
offscreenCanvas = message.canvas;
|
||||
applyBackingSize(message.width, message.height, message.devicePixelRatio);
|
||||
const context = offscreenCanvas.getContext('2d');
|
||||
if (context === null) {
|
||||
return;
|
||||
}
|
||||
renderer = createWelcomeHalftoneRenderer({
|
||||
context,
|
||||
dashes: WELCOME_HALFTONE_DASHES,
|
||||
width: message.width,
|
||||
height: message.height,
|
||||
devicePixelRatio: message.devicePixelRatio,
|
||||
color: message.color,
|
||||
highlightColor: message.highlightColor,
|
||||
reducedMotion: message.reducedMotion,
|
||||
});
|
||||
workerSelf.postMessage({ type: 'ready' });
|
||||
} else if (message.type === 'resize') {
|
||||
applyBackingSize(message.width, message.height, message.devicePixelRatio);
|
||||
renderer?.resize(message.width, message.height, message.devicePixelRatio);
|
||||
} else if (message.type === 'leave') {
|
||||
renderer?.leave();
|
||||
} else if (message.type === 'stop') {
|
||||
renderer?.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export type WelcomeHalftoneController = {
|
||||
leave: () => void;
|
||||
resize: (
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
devicePixelRatio: number,
|
||||
) => void;
|
||||
destroy: () => void;
|
||||
};
|
||||
+2904
File diff suppressed because it is too large
Load Diff
+16
@@ -0,0 +1,16 @@
|
||||
export type WelcomeHalftoneParticle = {
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
scatterStartX: number;
|
||||
scatterStartY: number;
|
||||
dashLength: number;
|
||||
strokeWidth: number;
|
||||
burstDirectionX: number;
|
||||
burstDirectionY: number;
|
||||
distanceToCenter: number;
|
||||
assembleDelaySeconds: number;
|
||||
driftPhase: number;
|
||||
positionAtLeaveStartX: number;
|
||||
positionAtLeaveStartY: number;
|
||||
opacityAtLeaveStart: number;
|
||||
};
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { buildWelcomeHalftoneParticles } from '@/onboarding/components/WelcomeOverlay/buildWelcomeHalftoneParticles';
|
||||
import { createWelcomeHalftoneAnimationFrameLoop } from '@/onboarding/components/WelcomeOverlay/createWelcomeHalftoneAnimationFrameLoop';
|
||||
import { type WelcomeHalftoneParticle } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneParticle.type';
|
||||
|
||||
const DASH_ASSEMBLE_DURATION_SECONDS = 0.62;
|
||||
const BURST_DURATION_SECONDS = 0.75;
|
||||
const SETTLE_DRIFT_DURATION_SECONDS = 0.6;
|
||||
const SHIMMER_BAND_HALF_WIDTH = 0.028;
|
||||
const MINIMUM_VISIBLE_OPACITY = 0.01;
|
||||
|
||||
const clampToUnitRange = (value: number) => Math.max(0, Math.min(1, value));
|
||||
const interpolate = (from: number, to: number, ratio: number) =>
|
||||
from + (to - from) * ratio;
|
||||
const easeOutCubic = (ratio: number) => 1 - Math.pow(1 - ratio, 3);
|
||||
const easeOutExpo = (ratio: number) =>
|
||||
ratio >= 1 ? 1 : 1 - Math.pow(2, -10 * ratio);
|
||||
const smootherStep = (ratio: number) => ratio * ratio * (3 - 2 * ratio);
|
||||
|
||||
type WelcomeHalftoneRendererOptions = {
|
||||
context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
|
||||
dashes: readonly (readonly [number, number, number, number])[];
|
||||
width: number;
|
||||
height: number;
|
||||
devicePixelRatio: number;
|
||||
color: string;
|
||||
highlightColor: string;
|
||||
reducedMotion: boolean;
|
||||
};
|
||||
|
||||
export const createWelcomeHalftoneRenderer = (
|
||||
options: WelcomeHalftoneRendererOptions,
|
||||
) => {
|
||||
const { context, dashes, reducedMotion } = options;
|
||||
const baseColor = options.color;
|
||||
const highlightColor = options.highlightColor;
|
||||
|
||||
let canvasWidth = options.width;
|
||||
let canvasHeight = options.height;
|
||||
let devicePixelRatio = options.devicePixelRatio;
|
||||
|
||||
let particles: WelcomeHalftoneParticle[] = [];
|
||||
let halftoneSize = 0;
|
||||
let maxDistanceToCenter = 1;
|
||||
|
||||
let firstFrameTimeMs: number | null = null;
|
||||
let leaveStartSeconds: number | null = null;
|
||||
let hasLeaveBeenRequested = false;
|
||||
|
||||
const animationFrameLoop = createWelcomeHalftoneAnimationFrameLoop();
|
||||
|
||||
const rebuildParticlesForCurrentSize = () => {
|
||||
context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
|
||||
context.lineCap = 'round';
|
||||
const layout = buildWelcomeHalftoneParticles(
|
||||
dashes,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
);
|
||||
particles = layout.particles;
|
||||
halftoneSize = layout.halftoneSize;
|
||||
maxDistanceToCenter = layout.maxDistanceToCenter;
|
||||
};
|
||||
|
||||
const computeAssembleState = (
|
||||
particle: WelcomeHalftoneParticle,
|
||||
elapsedSeconds: number,
|
||||
) => {
|
||||
const assembleProgress = easeOutExpo(
|
||||
clampToUnitRange(
|
||||
(elapsedSeconds - particle.assembleDelaySeconds) /
|
||||
DASH_ASSEMBLE_DURATION_SECONDS,
|
||||
),
|
||||
);
|
||||
let particleX = interpolate(
|
||||
particle.scatterStartX,
|
||||
particle.targetX,
|
||||
assembleProgress,
|
||||
);
|
||||
let particleY = interpolate(
|
||||
particle.scatterStartY,
|
||||
particle.targetY,
|
||||
assembleProgress,
|
||||
);
|
||||
const settleDriftProgress = clampToUnitRange(
|
||||
(elapsedSeconds -
|
||||
(particle.assembleDelaySeconds + DASH_ASSEMBLE_DURATION_SECONDS)) /
|
||||
SETTLE_DRIFT_DURATION_SECONDS,
|
||||
);
|
||||
particleX +=
|
||||
Math.sin(elapsedSeconds * 1.6 + particle.driftPhase) *
|
||||
settleDriftProgress *
|
||||
1.2;
|
||||
particleY +=
|
||||
Math.cos(elapsedSeconds * 1.4 + particle.driftPhase) *
|
||||
settleDriftProgress *
|
||||
0.8;
|
||||
return { particleX, particleY, opacity: assembleProgress };
|
||||
};
|
||||
|
||||
const drawFrame = (timeMs: number) => {
|
||||
if (!isDefined(firstFrameTimeMs)) {
|
||||
firstFrameTimeMs = timeMs;
|
||||
}
|
||||
const elapsedSeconds = (timeMs - firstFrameTimeMs) / 1000;
|
||||
|
||||
if (hasLeaveBeenRequested && !isDefined(leaveStartSeconds)) {
|
||||
leaveStartSeconds = elapsedSeconds;
|
||||
for (const particle of particles) {
|
||||
if (reducedMotion) {
|
||||
particle.positionAtLeaveStartX = particle.targetX;
|
||||
particle.positionAtLeaveStartY = particle.targetY;
|
||||
particle.opacityAtLeaveStart = 1;
|
||||
} else {
|
||||
const assembleState = computeAssembleState(particle, elapsedSeconds);
|
||||
particle.positionAtLeaveStartX = assembleState.particleX;
|
||||
particle.positionAtLeaveStartY = assembleState.particleY;
|
||||
particle.opacityAtLeaveStart = assembleState.opacity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isLeaving = isDefined(leaveStartSeconds);
|
||||
const burstProgress = isDefined(leaveStartSeconds)
|
||||
? clampToUnitRange(
|
||||
(elapsedSeconds - leaveStartSeconds) / BURST_DURATION_SECONDS,
|
||||
)
|
||||
: 0;
|
||||
|
||||
context.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
|
||||
const shimmerSweepPosition = ((elapsedSeconds * 0.5) % 1.3) / 1.3;
|
||||
const isShimmerActive = !reducedMotion && !isLeaving;
|
||||
const inverseViewportSpan = 1 / (canvasWidth + canvasHeight);
|
||||
let currentStrokeColor = '';
|
||||
|
||||
for (const particle of particles) {
|
||||
let particleX: number;
|
||||
let particleY: number;
|
||||
let particleOpacity: number;
|
||||
let capsuleLength = particle.dashLength;
|
||||
let capsuleDirectionX = 1;
|
||||
let capsuleDirectionY = 0;
|
||||
|
||||
if (reducedMotion) {
|
||||
particleX = particle.targetX;
|
||||
particleY = particle.targetY;
|
||||
particleOpacity = isLeaving ? 1 - burstProgress : 1;
|
||||
} else if (!isLeaving) {
|
||||
const assembleState = computeAssembleState(particle, elapsedSeconds);
|
||||
particleX = assembleState.particleX;
|
||||
particleY = assembleState.particleY;
|
||||
particleOpacity = assembleState.opacity;
|
||||
} else {
|
||||
const easedBurstProgress = smootherStep(burstProgress);
|
||||
const outwardPushDistance =
|
||||
easedBurstProgress *
|
||||
halftoneSize *
|
||||
1.1 *
|
||||
(0.6 + 0.8 * (particle.distanceToCenter / maxDistanceToCenter));
|
||||
particleX =
|
||||
particle.positionAtLeaveStartX +
|
||||
particle.burstDirectionX * outwardPushDistance;
|
||||
particleY =
|
||||
particle.positionAtLeaveStartY +
|
||||
particle.burstDirectionY * outwardPushDistance;
|
||||
particleOpacity =
|
||||
particle.opacityAtLeaveStart *
|
||||
(1 - easeOutCubic(clampToUnitRange(burstProgress / 0.85)));
|
||||
capsuleLength =
|
||||
particle.dashLength + outwardPushDistance * 0.12 * burstProgress;
|
||||
capsuleDirectionX = particle.burstDirectionX;
|
||||
capsuleDirectionY = particle.burstDirectionY;
|
||||
}
|
||||
|
||||
if (particleOpacity <= MINIMUM_VISIBLE_OPACITY) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isParticleInShimmerBand =
|
||||
isShimmerActive &&
|
||||
Math.abs(
|
||||
(particleX + particleY) * inverseViewportSpan - shimmerSweepPosition,
|
||||
) < SHIMMER_BAND_HALF_WIDTH;
|
||||
const strokeColor = isParticleInShimmerBand ? highlightColor : baseColor;
|
||||
if (strokeColor !== currentStrokeColor) {
|
||||
context.strokeStyle = strokeColor;
|
||||
currentStrokeColor = strokeColor;
|
||||
}
|
||||
|
||||
context.globalAlpha = particleOpacity;
|
||||
context.lineWidth = particle.strokeWidth;
|
||||
context.beginPath();
|
||||
const halfCapsuleX = (capsuleDirectionX * capsuleLength) / 2;
|
||||
const halfCapsuleY = (capsuleDirectionY * capsuleLength) / 2;
|
||||
context.moveTo(particleX - halfCapsuleX, particleY - halfCapsuleY);
|
||||
context.lineTo(particleX + halfCapsuleX, particleY + halfCapsuleY);
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
context.globalAlpha = 1;
|
||||
animationFrameLoop.requestNextFrame(drawFrame);
|
||||
};
|
||||
|
||||
rebuildParticlesForCurrentSize();
|
||||
animationFrameLoop.requestNextFrame(drawFrame);
|
||||
|
||||
return {
|
||||
leave: () => {
|
||||
hasLeaveBeenRequested = true;
|
||||
},
|
||||
resize: (
|
||||
nextCanvasWidth: number,
|
||||
nextCanvasHeight: number,
|
||||
nextDevicePixelRatio: number,
|
||||
) => {
|
||||
canvasWidth = nextCanvasWidth;
|
||||
canvasHeight = nextCanvasHeight;
|
||||
devicePixelRatio = nextDevicePixelRatio;
|
||||
rebuildParticlesForCurrentSize();
|
||||
},
|
||||
destroy: () => {
|
||||
animationFrameLoop.cancelPendingFrame();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const isWelcomeAnimationVisibleState = createAtomState<boolean>({
|
||||
key: 'isWelcomeAnimationVisibleState',
|
||||
defaultValue: false,
|
||||
});
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { shouldShowWelcomeAnimationOnNavigate } from '@/onboarding/utils/shouldShowWelcomeAnimationOnNavigate';
|
||||
import { OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
|
||||
describe('shouldShowWelcomeAnimationOnNavigate', () => {
|
||||
it('should return true when leaving an onboarding page for the workspace after completion', () => {
|
||||
expect(
|
||||
shouldShowWelcomeAnimationOnNavigate({
|
||||
onboardingStatus: OnboardingStatus.COMPLETED,
|
||||
isOnOnboardingPage: true,
|
||||
isNavigatingToOnboardingOrAuthPath: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when onboarding is not yet completed', () => {
|
||||
expect(
|
||||
shouldShowWelcomeAnimationOnNavigate({
|
||||
onboardingStatus: OnboardingStatus.INVITE_TEAM,
|
||||
isOnOnboardingPage: true,
|
||||
isNavigatingToOnboardingOrAuthPath: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when navigating to another onboarding or auth path', () => {
|
||||
expect(
|
||||
shouldShowWelcomeAnimationOnNavigate({
|
||||
onboardingStatus: OnboardingStatus.COMPLETED,
|
||||
isOnOnboardingPage: true,
|
||||
isNavigatingToOnboardingOrAuthPath: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when the current page is not an onboarding page', () => {
|
||||
expect(
|
||||
shouldShowWelcomeAnimationOnNavigate({
|
||||
onboardingStatus: OnboardingStatus.COMPLETED,
|
||||
isOnOnboardingPage: false,
|
||||
isNavigatingToOnboardingOrAuthPath: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when the onboarding status is undefined', () => {
|
||||
expect(
|
||||
shouldShowWelcomeAnimationOnNavigate({
|
||||
onboardingStatus: undefined,
|
||||
isOnOnboardingPage: true,
|
||||
isNavigatingToOnboardingOrAuthPath: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { OnboardingStatus } from '~/generated-metadata/graphql';
|
||||
|
||||
type ShouldShowWelcomeAnimationOnNavigateArgs = {
|
||||
onboardingStatus: OnboardingStatus | null | undefined;
|
||||
isOnOnboardingPage: boolean;
|
||||
isNavigatingToOnboardingOrAuthPath: boolean;
|
||||
};
|
||||
|
||||
export const shouldShowWelcomeAnimationOnNavigate = ({
|
||||
onboardingStatus,
|
||||
isOnOnboardingPage,
|
||||
isNavigatingToOnboardingOrAuthPath,
|
||||
}: ShouldShowWelcomeAnimationOnNavigateArgs): boolean =>
|
||||
onboardingStatus === OnboardingStatus.COMPLETED &&
|
||||
isOnOnboardingPage &&
|
||||
!isNavigatingToOnboardingOrAuthPath;
|
||||
@@ -20,6 +20,7 @@ export enum RootStackingContextZIndices {
|
||||
RootModal = 40,
|
||||
DropdownPortalAboveModal = 50,
|
||||
Dialog = 9999,
|
||||
WelcomeOverlay = 10000,
|
||||
SnackBar = 10002,
|
||||
NotFound = 10001,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user