From e38d58760283e1e5453a2bfc4035af65143201bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:47:07 +0200 Subject: [PATCH] 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. Review in cubic --- packages/twenty-front/.oxlintrc.json | 1 + packages/twenty-front/__mocks__/workerMock.js | 6 + packages/twenty-front/jest.config.mjs | 1 + .../images/onboarding/welcome-halftone.svg | 365 +++ .../app/components/WorkspaceAppProviders.tsx | 2 + .../effect-components/PageChangeEffect.tsx | 35 + .../WelcomeAnimationAutoLeaveEffect.tsx | 24 + .../WelcomeOverlay/WelcomeHalftoneCanvas.tsx | 40 + .../WelcomeHalftoneCanvasEffect.tsx | 94 + .../WelcomeOverlay/WelcomeOverlay.tsx | 211 ++ .../WelcomeOverlay/WelcomePersonChip.tsx | 45 + .../__tests__/WelcomeOverlay.test.tsx | 42 + .../buildWelcomeHalftoneParticles.ts | 80 + ...createWelcomeHalftoneAnimationFrameLoop.ts | 32 + ...eateWelcomeHalftoneMainThreadController.ts | 73 + .../createWelcomeHalftoneWorkerController.ts | 112 + .../WelcomeOverlay/welcomeHalftone.css | 4 + .../WelcomeOverlay/welcomeHalftone.worker.ts | 80 + .../welcomeHalftoneController.type.ts | 9 + .../WelcomeOverlay/welcomeHalftoneDots.ts | 2904 +++++++++++++++++ .../welcomeHalftoneParticle.type.ts | 16 + .../WelcomeOverlay/welcomeHalftoneRenderer.ts | 227 ++ .../states/isWelcomeAnimationVisibleState.ts | 6 + ...ouldShowWelcomeAnimationOnNavigate.test.ts | 54 + .../shouldShowWelcomeAnimationOnNavigate.ts | 16 + .../constants/RootStackingContextZIndices.ts | 1 + 26 files changed, 4480 insertions(+) create mode 100644 packages/twenty-front/__mocks__/workerMock.js create mode 100644 packages/twenty-front/public/images/onboarding/welcome-halftone.svg create mode 100644 packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeAnimationAutoLeaveEffect.tsx create mode 100644 packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeHalftoneCanvas.tsx create mode 100644 packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeHalftoneCanvasEffect.tsx create mode 100644 packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeOverlay.tsx create mode 100644 packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomePersonChip.tsx create mode 100644 packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/__tests__/WelcomeOverlay.test.tsx create mode 100644 packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/buildWelcomeHalftoneParticles.ts create mode 100644 packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/createWelcomeHalftoneAnimationFrameLoop.ts create mode 100644 packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/createWelcomeHalftoneMainThreadController.ts create mode 100644 packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/createWelcomeHalftoneWorkerController.ts create mode 100644 packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/welcomeHalftone.css create mode 100644 packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/welcomeHalftone.worker.ts create mode 100644 packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/welcomeHalftoneController.type.ts create mode 100644 packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/welcomeHalftoneDots.ts create mode 100644 packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/welcomeHalftoneParticle.type.ts create mode 100644 packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/welcomeHalftoneRenderer.ts create mode 100644 packages/twenty-front/src/modules/onboarding/states/isWelcomeAnimationVisibleState.ts create mode 100644 packages/twenty-front/src/modules/onboarding/utils/__tests__/shouldShowWelcomeAnimationOnNavigate.test.ts create mode 100644 packages/twenty-front/src/modules/onboarding/utils/shouldShowWelcomeAnimationOnNavigate.ts diff --git a/packages/twenty-front/.oxlintrc.json b/packages/twenty-front/.oxlintrc.json index 5c52c2c103..1529aace44 100644 --- a/packages/twenty-front/.oxlintrc.json +++ b/packages/twenty-front/.oxlintrc.json @@ -11,6 +11,7 @@ "src/generated-metadata", "src/generated-admin", "src/locales/generated", + "src/modules/onboarding/components/WelcomeOverlay/welcomeHalftoneDots.ts", "src/testing/mock-data", "**/__mocks__/**", "**/*.stories.ts", diff --git a/packages/twenty-front/__mocks__/workerMock.js b/packages/twenty-front/__mocks__/workerMock.js new file mode 100644 index 0000000000..54440cc48b --- /dev/null +++ b/packages/twenty-front/__mocks__/workerMock.js @@ -0,0 +1,6 @@ +export default class WorkerMock { + postMessage() {} + terminate() {} + addEventListener() {} + removeEventListener() {} +} diff --git a/packages/twenty-front/jest.config.mjs b/packages/twenty-front/jest.config.mjs index 82279f3fe8..6a09f82205 100644 --- a/packages/twenty-front/jest.config.mjs +++ b/packages/twenty-front/jest.config.mjs @@ -56,6 +56,7 @@ const jestConfig = { '\\.(jpg|jpeg|png|gif|webp|svg|svg\\?react)$': '/__mocks__/imageMockFront.js', '\\.css$': '/__mocks__/styleMock.js', + '\\?worker$': '/__mocks__/workerMock.js', ...pathsToModuleNameMapper(tsConfig.compilerOptions.paths, { prefix: '/', }), diff --git a/packages/twenty-front/public/images/onboarding/welcome-halftone.svg b/packages/twenty-front/public/images/onboarding/welcome-halftone.svg new file mode 100644 index 0000000000..01172ed046 --- /dev/null +++ b/packages/twenty-front/public/images/onboarding/welcome-halftone.svg @@ -0,0 +1,365 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/twenty-front/src/modules/app/components/WorkspaceAppProviders.tsx b/packages/twenty-front/src/modules/app/components/WorkspaceAppProviders.tsx index ca5b611c2e..ec80c8f3ef 100644 --- a/packages/twenty-front/src/modules/app/components/WorkspaceAppProviders.tsx +++ b/packages/twenty-front/src/modules/app/components/WorkspaceAppProviders.tsx @@ -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 = () => { + diff --git a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx index 6e277d833a..bf1b7b0e45 100644 --- a/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx +++ b/packages/twenty-front/src/modules/app/effect-components/PageChangeEffect.tsx @@ -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(() => { diff --git a/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeAnimationAutoLeaveEffect.tsx b/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeAnimationAutoLeaveEffect.tsx new file mode 100644 index 0000000000..6483abae10 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeAnimationAutoLeaveEffect.tsx @@ -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; +}; diff --git a/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeHalftoneCanvas.tsx b/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeHalftoneCanvas.tsx new file mode 100644 index 0000000000..1fd99d7783 --- /dev/null +++ b/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/WelcomeHalftoneCanvas.tsx @@ -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(null); + const [hasWorkerFailed, setHasWorkerFailed] = useState(false); + const markWorkerAsFailed = useCallback(() => setHasWorkerFailed(true), []); + + return ( + <> +