Restore the Figma halftone shapes and calm the welcome animation (#23495)

https://github.com/user-attachments/assets/0c06d190-977e-4ad6-8a02-251f341ee310



The welcome overlay settled into circles instead of the halftone from
Figma: the densify step that took the dot set from 681 to 2897 emitted
points, so every dash had zero length. All 681 dashes in the Figma
export (`public/images/onboarding/welcome-halftone.svg`) share a
constant length / stroke width ratio of 1.452, so the shape is restored
by deriving the length from the stroke width, with no data regeneration.
Dashes now stretch into shape while they are still flying in, rather
than popping once they have landed.

The rest of the pass makes the animation quieter. The shine sweep is
gone, along with the highlight colour that only fed it. Particles
approach from much closer on a gentler ease, the idle drift is roughly
halved, and the exit is a soft outward drift instead of a burst that
threw everything off screen. The white pill behind the title and the
person chip's surface are removed too, so the title reads directly
against the halftone.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23495?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-29 18:14:46 +02:00
committed by GitHub
parent 684259fd5e
commit ada7eb1d88
11 changed files with 123 additions and 92 deletions
@@ -33,9 +33,6 @@ export const WelcomeHalftoneCanvasEffect = ({
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 = () => ({
@@ -47,7 +44,6 @@ export const WelcomeHalftoneCanvasEffect = ({
const sharedControllerOptions = {
canvas,
dotColor,
dotHighlightColor,
prefersReducedMotion,
initialCanvasWidth: initialCanvasSize.width,
initialCanvasHeight: initialCanvasSize.height,
@@ -141,23 +141,6 @@ const StyledTitle = styled.div`
}
`;
const StyledTitleSurface = styled.div`
background: ${themeCssVariables.background.primary};
border-radius: ${themeCssVariables.border.radius.pill};
inset: 0;
position: absolute;
.is-flying & {
animation: welcomeTitleSurfaceOut 0.24s ease-out forwards;
}
@keyframes welcomeTitleSurfaceOut {
to {
opacity: 0;
}
}
`;
const StyledTitleBoldRun = styled.span`
align-items: center;
display: inline-flex;
@@ -303,7 +286,6 @@ export const WelcomeOverlay = () => {
className={titleClassName}
style={titleStyle}
>
<StyledTitleSurface />
<StyledTitleBoldRun>
{welcomeTitleWords.map((word, index) => (
<StyledWord
@@ -10,11 +10,6 @@ type WelcomePersonChipSizeVariant = 'default' | 'compact';
const StyledChip = styled.div<{ sizeVariant: WelcomePersonChipSizeVariant }>`
align-items: center;
background: ${themeCssVariables.background.transparent.light};
border-radius: ${({ sizeVariant }) =>
sizeVariant === 'compact'
? themeCssVariables.border.radius.sm
: themeCssVariables.border.radius.md};
display: inline-flex;
gap: ${({ sizeVariant }) =>
sizeVariant === 'compact'
@@ -0,0 +1,75 @@
import { buildWelcomeHalftoneParticles } from '@/onboarding/components/WelcomeOverlay/buildWelcomeHalftoneParticles';
const CANVAS_WIDTH = 1280;
const CANVAS_HEIGHT = 832;
const VIEWBOX_WIDTH = 297.037;
const MINIMUM_STROKE_WIDTH = 0.6;
const VIEWBOX_TO_CANVAS_SCALE =
Math.max(CANVAS_WIDTH * 1.05, CANVAS_HEIGHT * 1.3) / VIEWBOX_WIDTH;
describe('buildWelcomeHalftoneParticles', () => {
it('should elongate zero length dashes to the halftone aspect ratio', () => {
const { particles } = buildWelcomeHalftoneParticles(
[[148.5, 148.5, 119.5, 2]],
CANVAS_WIDTH,
CANVAS_HEIGHT,
);
expect(particles[0].dashLength).toBeCloseTo(
2 * VIEWBOX_TO_CANVAS_SCALE * 1.452,
);
expect(particles[0].strokeWidth).toBeCloseTo(2 * VIEWBOX_TO_CANVAS_SCALE);
});
it('should keep the dash length when the source dash is longer than the aspect ratio', () => {
const { particles } = buildWelcomeHalftoneParticles(
[[148.5, 158.5, 119.5, 2]],
CANVAS_WIDTH,
CANVAS_HEIGHT,
);
expect(particles[0].dashLength).toBeCloseTo(10 * VIEWBOX_TO_CANVAS_SCALE);
});
it('should keep the dash length proportional to the source stroke width when the minimum stroke width applies', () => {
const smallCanvasWidth = 600;
const smallCanvasHeight = 500;
const smallCanvasScale =
Math.max(smallCanvasWidth * 1.05, smallCanvasHeight * 1.3) /
VIEWBOX_WIDTH;
const fainterSourceStrokeWidth = 0.19;
const denserSourceStrokeWidth = 0.25;
const { particles } = buildWelcomeHalftoneParticles(
[
[148.5, 148.5, 119.5, fainterSourceStrokeWidth],
[148.5, 148.5, 119.5, denserSourceStrokeWidth],
],
smallCanvasWidth,
smallCanvasHeight,
);
expect(particles[0].strokeWidth).toBe(MINIMUM_STROKE_WIDTH);
expect(particles[1].strokeWidth).toBe(MINIMUM_STROKE_WIDTH);
expect(particles[0].dashLength).toBeCloseTo(
fainterSourceStrokeWidth * smallCanvasScale * 1.452,
);
expect(particles[1].dashLength / particles[0].dashLength).toBeCloseTo(
denserSourceStrokeWidth / fainterSourceStrokeWidth,
);
});
it('should point the burst direction outward from the canvas center', () => {
const { particles } = buildWelcomeHalftoneParticles(
[
[0, 0, 119.5, 2],
[297.037, 297.037, 119.5, 2],
],
CANVAS_WIDTH,
CANVAS_HEIGHT,
);
expect(particles[0].burstDirectionX).toBeLessThan(0);
expect(particles[1].burstDirectionX).toBeGreaterThan(0);
});
});
@@ -7,6 +7,7 @@ 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 HALFTONE_DASH_LENGTH_TO_STROKE_WIDTH_RATIO = 1.452;
const pseudoRandomFromSeed = (seed: number) => {
const noise = Math.sin(seed) * 43758.5453;
@@ -44,26 +45,27 @@ export const buildWelcomeHalftoneParticles = (
);
const scatterAngle = pseudoRandomFromSeed(dashIndex * 1.3) * TAU;
const scatterRadius =
halftoneSize * (0.35 + 0.5 * pseudoRandomFromSeed(dashIndex * 2.1));
halftoneSize * (0.1 + 0.1 * pseudoRandomFromSeed(dashIndex * 2.1));
const scaledStrokeWidth = dashStrokeWidth * viewboxToCanvasScale;
const burstDirectionX =
distanceToCenter > 0 ? (targetX - canvasCenterX) / distanceToCenter : 0;
const burstDirectionY =
distanceToCenter > 0
? (targetY - canvasCenterY) / distanceToCenter
: -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,
dashLength: Math.max(
(dashEndX - dashStartX) * viewboxToCanvasScale,
scaledStrokeWidth * HALFTONE_DASH_LENGTH_TO_STROKE_WIDTH_RATIO,
),
burstDirectionX:
distanceToCenter > 0
? (targetX - canvasCenterX) / distanceToCenter
: 0,
burstDirectionY:
distanceToCenter > 0
? (targetY - canvasCenterY) / distanceToCenter
: -1,
strokeWidth: Math.max(scaledStrokeWidth, MINIMUM_STROKE_WIDTH),
burstDirectionX,
burstDirectionY,
distanceToCenter,
assembleDelaySeconds:
ASSEMBLE_STAGGER_SECONDS * (distanceToCenter / maxDistanceToCenter) +
@@ -72,6 +74,7 @@ export const buildWelcomeHalftoneParticles = (
positionAtLeaveStartX: 0,
positionAtLeaveStartY: 0,
opacityAtLeaveStart: 0,
capsuleLengthAtLeaveStart: 0,
};
},
);
@@ -7,7 +7,6 @@ import { createWelcomeHalftoneRenderer } from '@/onboarding/components/WelcomeOv
type CreateWelcomeHalftoneMainThreadControllerOptions = {
canvas: HTMLCanvasElement;
dotColor: string;
dotHighlightColor: string;
prefersReducedMotion: boolean;
initialCanvasWidth: number;
initialCanvasHeight: number;
@@ -17,7 +16,6 @@ type CreateWelcomeHalftoneMainThreadControllerOptions = {
export const createWelcomeHalftoneMainThreadController = ({
canvas,
dotColor,
dotHighlightColor,
prefersReducedMotion,
initialCanvasWidth,
initialCanvasHeight,
@@ -54,7 +52,6 @@ export const createWelcomeHalftoneMainThreadController = ({
height: initialCanvasHeight,
devicePixelRatio,
color: dotColor,
highlightColor: dotHighlightColor,
reducedMotion: prefersReducedMotion,
});
@@ -9,7 +9,6 @@ const WORKER_READY_TIMEOUT_MS = 1500;
type CreateWelcomeHalftoneWorkerControllerOptions = {
canvas: HTMLCanvasElement;
dotColor: string;
dotHighlightColor: string;
prefersReducedMotion: boolean;
initialCanvasWidth: number;
initialCanvasHeight: number;
@@ -20,7 +19,6 @@ type CreateWelcomeHalftoneWorkerControllerOptions = {
export const createWelcomeHalftoneWorkerController = ({
canvas,
dotColor,
dotHighlightColor,
prefersReducedMotion,
initialCanvasWidth,
initialCanvasHeight,
@@ -80,7 +78,6 @@ export const createWelcomeHalftoneWorkerController = ({
height: initialCanvasHeight,
devicePixelRatio,
color: dotColor,
highlightColor: dotHighlightColor,
reducedMotion: prefersReducedMotion,
},
[offscreenCanvas],
@@ -1,4 +1,3 @@
:root {
--welcome-dot-color: #4a38f5;
--welcome-dot-highlight: #9b91f9;
}
@@ -8,7 +8,6 @@ type InitMessage = {
height: number;
devicePixelRatio: number;
color: string;
highlightColor: string;
reducedMotion: boolean;
};
@@ -63,7 +62,6 @@ addEventListener(
height: message.height,
devicePixelRatio: message.devicePixelRatio,
color: message.color,
highlightColor: message.highlightColor,
reducedMotion: message.reducedMotion,
});
workerSelf.postMessage({ type: 'ready' });
@@ -13,4 +13,5 @@ export type WelcomeHalftoneParticle = {
positionAtLeaveStartX: number;
positionAtLeaveStartY: number;
opacityAtLeaveStart: number;
capsuleLengthAtLeaveStart: number;
};
@@ -4,18 +4,16 @@ import { buildWelcomeHalftoneParticles } from '@/onboarding/components/WelcomeOv
import { createWelcomeHalftoneAnimationFrameLoop } from '@/onboarding/components/WelcomeOverlay/createWelcomeHalftoneAnimationFrameLoop';
import { type WelcomeHalftoneParticle } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneParticle.type';
const DASH_ASSEMBLE_DURATION_SECONDS = 0.62;
const DASH_ASSEMBLE_DURATION_SECONDS = 0.9;
const DASH_STRETCH_START_ASSEMBLE_PROGRESS = 0.35;
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 = {
@@ -25,7 +23,6 @@ type WelcomeHalftoneRendererOptions = {
height: number;
devicePixelRatio: number;
color: string;
highlightColor: string;
reducedMotion: boolean;
};
@@ -34,7 +31,6 @@ export const createWelcomeHalftoneRenderer = (
) => {
const { context, dashes, reducedMotion } = options;
const baseColor = options.color;
const highlightColor = options.highlightColor;
let canvasWidth = options.width;
let canvasHeight = options.height;
@@ -67,7 +63,7 @@ export const createWelcomeHalftoneRenderer = (
particle: WelcomeHalftoneParticle,
elapsedSeconds: number,
) => {
const assembleProgress = easeOutExpo(
const assembleProgress = easeOutCubic(
clampToUnitRange(
(elapsedSeconds - particle.assembleDelaySeconds) /
DASH_ASSEMBLE_DURATION_SECONDS,
@@ -83,20 +79,30 @@ export const createWelcomeHalftoneRenderer = (
particle.targetY,
assembleProgress,
);
const secondsSinceLanding =
elapsedSeconds -
(particle.assembleDelaySeconds + DASH_ASSEMBLE_DURATION_SECONDS);
const settleDriftProgress = clampToUnitRange(
(elapsedSeconds -
(particle.assembleDelaySeconds + DASH_ASSEMBLE_DURATION_SECONDS)) /
SETTLE_DRIFT_DURATION_SECONDS,
secondsSinceLanding / SETTLE_DRIFT_DURATION_SECONDS,
);
particleX +=
Math.sin(elapsedSeconds * 1.6 + particle.driftPhase) *
Math.sin(elapsedSeconds * 1.1 + particle.driftPhase) *
settleDriftProgress *
1.2;
0.5;
particleY +=
Math.cos(elapsedSeconds * 1.4 + particle.driftPhase) *
Math.cos(elapsedSeconds * 0.95 + particle.driftPhase) *
settleDriftProgress *
0.8;
return { particleX, particleY, opacity: assembleProgress };
0.35;
const stretchProgress = clampToUnitRange(
(assembleProgress - DASH_STRETCH_START_ASSEMBLE_PROGRESS) /
(1 - DASH_STRETCH_START_ASSEMBLE_PROGRESS),
);
return {
particleX,
particleY,
opacity: assembleProgress,
capsuleLength: particle.dashLength * stretchProgress,
};
};
const drawFrame = (timeMs: number) => {
@@ -112,11 +118,13 @@ export const createWelcomeHalftoneRenderer = (
particle.positionAtLeaveStartX = particle.targetX;
particle.positionAtLeaveStartY = particle.targetY;
particle.opacityAtLeaveStart = 1;
particle.capsuleLengthAtLeaveStart = particle.dashLength;
} else {
const assembleState = computeAssembleState(particle, elapsedSeconds);
particle.positionAtLeaveStartX = assembleState.particleX;
particle.positionAtLeaveStartY = assembleState.particleY;
particle.opacityAtLeaveStart = assembleState.opacity;
particle.capsuleLengthAtLeaveStart = assembleState.capsuleLength;
}
}
}
@@ -130,18 +138,13 @@ export const createWelcomeHalftoneRenderer = (
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 = '';
context.strokeStyle = baseColor;
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;
@@ -152,12 +155,12 @@ export const createWelcomeHalftoneRenderer = (
particleX = assembleState.particleX;
particleY = assembleState.particleY;
particleOpacity = assembleState.opacity;
capsuleLength = assembleState.capsuleLength;
} else {
const easedBurstProgress = smootherStep(burstProgress);
const outwardPushDistance =
easedBurstProgress *
smootherStep(burstProgress) *
halftoneSize *
1.1 *
0.25 *
(0.6 + 0.8 * (particle.distanceToCenter / maxDistanceToCenter));
particleX =
particle.positionAtLeaveStartX +
@@ -168,34 +171,19 @@ export const createWelcomeHalftoneRenderer = (
particleOpacity =
particle.opacityAtLeaveStart *
(1 - easeOutCubic(clampToUnitRange(burstProgress / 0.85)));
capsuleLength =
particle.dashLength + outwardPushDistance * 0.12 * burstProgress;
capsuleDirectionX = particle.burstDirectionX;
capsuleDirectionY = particle.burstDirectionY;
capsuleLength = particle.capsuleLengthAtLeaveStart;
}
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);
const halfCapsuleLength = capsuleLength / 2;
context.moveTo(particleX - halfCapsuleLength, particleY);
context.lineTo(particleX + halfCapsuleLength, particleY);
context.stroke();
}