e38d587602
## 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. -->
81 lines
2.8 KiB
TypeScript
81 lines
2.8 KiB
TypeScript
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 };
|
|
};
|