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:
Raphaël Bosi
2026-07-09 10:47:07 +02:00
committed by GitHub
parent e0bd4ab732
commit e38d587602
26 changed files with 4480 additions and 0 deletions
@@ -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(() => {
@@ -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;
};
@@ -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}
/>
</>
);
};
@@ -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;
};
@@ -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,
);
};
@@ -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>
);
};
@@ -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();
});
});
@@ -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 };
};
@@ -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);
}
},
};
};
@@ -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(),
};
};
@@ -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;
}
};
@@ -0,0 +1,4 @@
:root {
--welcome-dot-color: #4a38f5;
--welcome-dot-highlight: #9b91f9;
}
@@ -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;
}
},
);
@@ -0,0 +1,9 @@
export type WelcomeHalftoneController = {
leave: () => void;
resize: (
canvasWidth: number,
canvasHeight: number,
devicePixelRatio: number,
) => void;
destroy: () => void;
};
@@ -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;
};
@@ -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,
});
@@ -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);
});
});
@@ -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,
}