From 31188009bd6159de4693fe84e8415ace00b49604 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 27 Feb 2026 15:00:14 +0200 Subject: [PATCH 1/3] Improve leaderboard top-3 badges and fix hydration --- .../cards/Leaderboard/CompanyTopList.tsx | 32 +++++++- .../cards/Leaderboard/LeaderboardListItem.tsx | 6 +- .../cards/Leaderboard/SourceTopList.tsx | 6 +- .../cards/Leaderboard/TopRankBadge.tsx | 43 +++++++++++ .../cards/Leaderboard/UserTopList.tsx | 32 +++++++- .../components/cards/Leaderboard/common.ts | 76 ++++++++++++++++--- .../widgets/PostUsersHighlights.tsx | 4 +- 7 files changed, 175 insertions(+), 24 deletions(-) create mode 100644 packages/shared/src/components/cards/Leaderboard/TopRankBadge.tsx diff --git a/packages/shared/src/components/cards/Leaderboard/CompanyTopList.tsx b/packages/shared/src/components/cards/Leaderboard/CompanyTopList.tsx index 6b0da8c242..816db03059 100644 --- a/packages/shared/src/components/cards/Leaderboard/CompanyTopList.tsx +++ b/packages/shared/src/components/cards/Leaderboard/CompanyTopList.tsx @@ -1,11 +1,13 @@ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useCallback } from 'react'; +import classNames from 'classnames'; import type { CommonLeaderboardProps } from './LeaderboardList'; import { LeaderboardList } from './LeaderboardList'; import { LeaderboardListItem } from './LeaderboardListItem'; import type { Company } from '../../../lib/userCompany'; import { ProfileImageSize, ProfilePicture } from '../../ProfilePicture'; -import { indexToEmoji } from './common'; +import { runIconPopAnimation, runSparkAnimation, TOP_RANK_STYLES } from './common'; +import { TopRankBadge } from './TopRankBadge'; export interface CompanyLeaderboard { score: number; @@ -16,15 +18,37 @@ export function CompanyTopList({ items, ...props }: CommonLeaderboardProps): ReactElement { + const createRowMouseEnter = useCallback( + (rankIndex: number) => (e: React.MouseEvent) => { + const rankStyle = TOP_RANK_STYLES[rankIndex]; + if (!rankStyle) { + return; + } + + runIconPopAnimation(e.currentTarget, 'leaderboard-medal-wrapper'); + runSparkAnimation( + e.currentTarget, + '.leaderboard-medal-spark', + rankStyle.glowColor, + 16, + ); + }, + [], + ); + return ( {items?.map((item, i) => ( - {indexToEmoji(i)} +
; } export function LeaderboardListItem({ @@ -19,6 +20,7 @@ export function LeaderboardListItem({ children, className, concatScore = true, + onMouseEnter, }: LeaderboardListItemProps): ReactElement { const formattedNumber = concatScore ? largeNumberFormat(index) : index; const shouldShowTooltip = @@ -26,7 +28,7 @@ export function LeaderboardListItem({ const actualNumber = index.toLocaleString(); return ( -
  • +
  • ( @@ -38,7 +40,7 @@ export function LeaderboardListItem({ )} > - + {formattedNumber} diff --git a/packages/shared/src/components/cards/Leaderboard/SourceTopList.tsx b/packages/shared/src/components/cards/Leaderboard/SourceTopList.tsx index 932ec2b37c..9de8d735d2 100644 --- a/packages/shared/src/components/cards/Leaderboard/SourceTopList.tsx +++ b/packages/shared/src/components/cards/Leaderboard/SourceTopList.tsx @@ -13,7 +13,11 @@ export function SourceTopList({ return ( {items?.map((item, i) => ( - + ; + } + + return ( + + + + + + {Array.from({ length: SPARK_COUNT }, (_, i) => ( + + ))} + + + + ); +} diff --git a/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx b/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx index cea0b504cd..12f2710dee 100644 --- a/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx +++ b/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx @@ -1,11 +1,13 @@ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useCallback } from 'react'; +import classNames from 'classnames'; import type { CommonLeaderboardProps } from './LeaderboardList'; import { LeaderboardList } from './LeaderboardList'; import { LeaderboardListItem } from './LeaderboardListItem'; import { UserHighlight } from '../../widgets/PostUsersHighlights'; import type { LoggedUser } from '../../../lib/user'; -import { indexToEmoji } from './common'; +import { runIconPopAnimation, runSparkAnimation, TOP_RANK_STYLES } from './common'; +import { TopRankBadge } from './TopRankBadge'; export interface UserLeaderboard { score: number; @@ -17,16 +19,38 @@ export function UserTopList({ concatScore = true, ...props }: CommonLeaderboardProps): ReactElement { + const createRowMouseEnter = useCallback( + (rankIndex: number) => (e: React.MouseEvent) => { + const rankStyle = TOP_RANK_STYLES[rankIndex]; + if (!rankStyle) { + return; + } + + runIconPopAnimation(e.currentTarget, 'leaderboard-medal-wrapper'); + runSparkAnimation( + e.currentTarget, + '.leaderboard-medal-spark', + rankStyle.glowColor, + 16, + ); + }, + [], + ); + return ( {items?.map((item, i) => ( - {indexToEmoji(i)} + { - switch (index) { - case 0: - return '🏆'; - case 1: - return '🥈'; - case 2: - return '🥉'; - default: - return ''; +export interface TopRankStyle { + iconColor: string; + glowColor: string; + hoverClass: string; +} + +export const TOP_RANK_STYLES: TopRankStyle[] = [ + { + iconColor: 'text-accent-cheese-default', + glowColor: 'var(--theme-accent-cheese-default)', + hoverClass: 'gear-rank-gold', + }, + { + iconColor: 'text-text-tertiary', + glowColor: 'var(--theme-text-tertiary)', + hoverClass: 'gear-rank-silver', + }, + { + iconColor: 'text-accent-bacon-default', + glowColor: 'var(--theme-accent-bacon-default)', + hoverClass: 'gear-rank-bronze', + }, +]; + +const forceReflow = (element: Element): void => { + element.getBoundingClientRect(); +}; + +export const runSparkAnimation = ( + container: HTMLElement, + selector: string, + glowColor: string, + radius: number, +): void => { + const sparks = container.querySelectorAll(selector); + sparks.forEach((el, i) => { + const { style } = el; + const slot = 360 / Math.max(sparks.length, 1); + const jitter = (Math.random() - 0.5) * slot * 0.4; + const angle = i * slot + jitter; + const rad = (angle * Math.PI) / 180; + const r = radius + Math.random() * (radius * 0.4); + + style.setProperty('--spark-fx', `${Math.round(Math.sin(rad) * r)}px`); + style.setProperty('--spark-fy', `${Math.round(-Math.cos(rad) * r)}px`); + style.backgroundColor = glowColor; + + style.animation = 'none'; + forceReflow(el); + style.animation = `gear-medal-spark 0.7s ease-out ${( + Math.random() * 0.06 + ).toFixed(2)}s forwards`; + }); +}; + +export const runIconPopAnimation = ( + container: HTMLElement, + wrapperClass: string, +): void => { + const iconWrapper = container.querySelector(`.${wrapperClass}`); + if (!iconWrapper) { + return; } + + iconWrapper.style.animation = 'none'; + forceReflow(iconWrapper); + iconWrapper.style.animation = 'crown-icon-pop 0.8s ease-out'; }; diff --git a/packages/shared/src/components/widgets/PostUsersHighlights.tsx b/packages/shared/src/components/widgets/PostUsersHighlights.tsx index 717f924ff7..5d1b27ecac 100644 --- a/packages/shared/src/components/widgets/PostUsersHighlights.tsx +++ b/packages/shared/src/components/widgets/PostUsersHighlights.tsx @@ -165,9 +165,7 @@ export const UserHighlight = (props: UserHighlightProps): ReactElement => { )} > - - - + {userType && Icon && ( Date: Fri, 27 Feb 2026 15:16:03 +0200 Subject: [PATCH 2/3] Fix leaderboard lint formatting --- .../src/components/cards/Leaderboard/CompanyTopList.tsx | 6 +++++- .../components/cards/Leaderboard/LeaderboardListItem.tsx | 2 +- .../shared/src/components/cards/Leaderboard/UserTopList.tsx | 6 +++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/components/cards/Leaderboard/CompanyTopList.tsx b/packages/shared/src/components/cards/Leaderboard/CompanyTopList.tsx index 816db03059..47f46b2d5d 100644 --- a/packages/shared/src/components/cards/Leaderboard/CompanyTopList.tsx +++ b/packages/shared/src/components/cards/Leaderboard/CompanyTopList.tsx @@ -6,7 +6,11 @@ import { LeaderboardList } from './LeaderboardList'; import { LeaderboardListItem } from './LeaderboardListItem'; import type { Company } from '../../../lib/userCompany'; import { ProfileImageSize, ProfilePicture } from '../../ProfilePicture'; -import { runIconPopAnimation, runSparkAnimation, TOP_RANK_STYLES } from './common'; +import { + runIconPopAnimation, + runSparkAnimation, + TOP_RANK_STYLES, +} from './common'; import { TopRankBadge } from './TopRankBadge'; export interface CompanyLeaderboard { diff --git a/packages/shared/src/components/cards/Leaderboard/LeaderboardListItem.tsx b/packages/shared/src/components/cards/Leaderboard/LeaderboardListItem.tsx index 15fa4056df..5bfeb3eeb6 100644 --- a/packages/shared/src/components/cards/Leaderboard/LeaderboardListItem.tsx +++ b/packages/shared/src/components/cards/Leaderboard/LeaderboardListItem.tsx @@ -40,7 +40,7 @@ export function LeaderboardListItem({ )} > - + {formattedNumber} diff --git a/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx b/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx index 12f2710dee..1ec5c3a6da 100644 --- a/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx +++ b/packages/shared/src/components/cards/Leaderboard/UserTopList.tsx @@ -6,7 +6,11 @@ import { LeaderboardList } from './LeaderboardList'; import { LeaderboardListItem } from './LeaderboardListItem'; import { UserHighlight } from '../../widgets/PostUsersHighlights'; import type { LoggedUser } from '../../../lib/user'; -import { runIconPopAnimation, runSparkAnimation, TOP_RANK_STYLES } from './common'; +import { + runIconPopAnimation, + runSparkAnimation, + TOP_RANK_STYLES, +} from './common'; import { TopRankBadge } from './TopRankBadge'; export interface UserLeaderboard { From a5dfd1ed8f88a5aff8693c817bdd7337ea3fcf44 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 27 Feb 2026 15:45:45 +0200 Subject: [PATCH 3/3] Restore leaderboard spark animations with dedicated utility classes --- .../cards/Leaderboard/TopRankBadge.tsx | 31 ++++++++++-- .../components/cards/Leaderboard/common.ts | 8 ++-- packages/shared/src/styles/utilities.css | 47 +++++++++++++++++++ 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/components/cards/Leaderboard/TopRankBadge.tsx b/packages/shared/src/components/cards/Leaderboard/TopRankBadge.tsx index 3c0d951cac..ee4022e502 100644 --- a/packages/shared/src/components/cards/Leaderboard/TopRankBadge.tsx +++ b/packages/shared/src/components/cards/Leaderboard/TopRankBadge.tsx @@ -1,8 +1,12 @@ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useCallback } from 'react'; import { MedalBadgeIcon } from '../../icons/MedalBadge'; import { IconSize } from '../../Icon'; -import { TOP_RANK_STYLES } from './common'; +import { + runIconPopAnimation, + runSparkAnimation, + TOP_RANK_STYLES, +} from './common'; const SPARK_COUNT = 5; @@ -13,12 +17,31 @@ interface TopRankBadgeProps { export function TopRankBadge({ rankIndex }: TopRankBadgeProps): ReactElement { const rankStyle = TOP_RANK_STYLES[rankIndex]; + const handleMouseEnter = useCallback( + (e: React.MouseEvent) => { + if (!rankStyle) { + return; + } + runIconPopAnimation(e.currentTarget, 'leaderboard-medal-wrapper'); + runSparkAnimation( + e.currentTarget, + '.leaderboard-medal-spark', + rankStyle.glowColor, + 16, + ); + }, + [rankStyle], + ); + if (!rankStyle) { return ; } return ( - + ( ))} diff --git a/packages/shared/src/components/cards/Leaderboard/common.ts b/packages/shared/src/components/cards/Leaderboard/common.ts index d358adec0a..f025d171e8 100644 --- a/packages/shared/src/components/cards/Leaderboard/common.ts +++ b/packages/shared/src/components/cards/Leaderboard/common.ts @@ -23,17 +23,17 @@ export const TOP_RANK_STYLES: TopRankStyle[] = [ { iconColor: 'text-accent-cheese-default', glowColor: 'var(--theme-accent-cheese-default)', - hoverClass: 'gear-rank-gold', + hoverClass: 'leaderboard-rank-gold', }, { iconColor: 'text-text-tertiary', glowColor: 'var(--theme-text-tertiary)', - hoverClass: 'gear-rank-silver', + hoverClass: 'leaderboard-rank-silver', }, { iconColor: 'text-accent-bacon-default', glowColor: 'var(--theme-accent-bacon-default)', - hoverClass: 'gear-rank-bronze', + hoverClass: 'leaderboard-rank-bronze', }, ]; @@ -62,7 +62,7 @@ export const runSparkAnimation = ( style.animation = 'none'; forceReflow(el); - style.animation = `gear-medal-spark 0.7s ease-out ${( + style.animation = `leaderboard-medal-spark 0.7s ease-out ${( Math.random() * 0.06 ).toFixed(2)}s forwards`; }); diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css index c3ba393f92..a12d9300a8 100644 --- a/packages/shared/src/styles/utilities.css +++ b/packages/shared/src/styles/utilities.css @@ -162,6 +162,53 @@ opacity: 0; } +@keyframes leaderboard-medal-spark { + 0% { + transform: translate(-50%, -50%) scale(1); + opacity: 0; + } + + 15% { + opacity: 0.9; + } + + 100% { + transform: translate( + calc(-50% + var(--spark-fx, 0px)), + calc(-50% + var(--spark-fy, 0px)) + ) + scale(0); + opacity: 0; + } +} + +.leaderboard-medal-spark { + opacity: 0; +} + +.leaderboard-rank-gold:hover { + box-shadow: 0 0 24px + color-mix( + in srgb, + var(--theme-accent-cheese-default) 18%, + transparent + ); +} + +.leaderboard-rank-silver:hover { + box-shadow: 0 0 24px + color-mix(in srgb, var(--theme-text-tertiary) 14%, transparent); +} + +.leaderboard-rank-bronze:hover { + box-shadow: 0 0 24px + color-mix( + in srgb, + var(--theme-accent-bacon-default) 18%, + transparent + ); +} + @keyframes crown-icon-bloom-pulse { 0%, 60%,