From 9a252f084e00787dc5b0aeecb133400fd961ea7c Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 10 Jun 2026 15:58:01 +0300 Subject: [PATCH 001/137] feat(layout-v2): label rail icons and pin streak/cores/reputation Make the v2 desktop rail legible and keep gamification signals at a glance: - Add a text label under every rail category icon; widen the rail 64px->80px and update the coupled content offset, separator, and toggle positions. - Rename the Game Center rail category to "Quests". - Pin a compact streak / cores / reputation cluster to the rail so the stats stay visible regardless of collapsed/expanded state or selected panel. - Label the rail notifications bell ("Alerts") to match. Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/components/MainLayout.tsx | 4 +- .../notifications/NotificationsBell.tsx | 32 +-- .../components/sidebar/SidebarDesktopV2.tsx | 122 +++++----- .../components/sidebar/SidebarHeaderStats.tsx | 33 ++- .../components/sidebar/SidebarRailStats.tsx | 208 ++++++++++++++++++ .../shared/src/components/sidebar/common.tsx | 6 + .../components/tooltips/InteractivePopup.tsx | 2 +- 7 files changed, 326 insertions(+), 81 deletions(-) create mode 100644 packages/shared/src/components/sidebar/SidebarRailStats.tsx diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 9cd66162d0b..a105c7598f6 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -305,13 +305,13 @@ function MainLayoutComponent({ 'transition-[padding] duration-300 ease-in-out', !sidebarOwnsHeader && 'laptop:pt-16', showSidebar && - (isV2 ? 'tablet:pl-16 laptop:pl-16' : 'tablet:pl-16 laptop:pl-11'), + (isV2 ? 'tablet:pl-16 laptop:pl-20' : 'tablet:pl-16 laptop:pl-11'), className, isAuthReady && showSidebar && (sidebarExpanded || forceSidebarExpanded) && (isV2 - ? 'laptop:!pl-[19rem]' + ? 'laptop:!pl-[20rem]' : !isScreenCentered && 'laptop:!pl-60'), isBannerAvailable && !sidebarOwnsHeader && 'laptop:pt-24', )} diff --git a/packages/shared/src/components/notifications/NotificationsBell.tsx b/packages/shared/src/components/notifications/NotificationsBell.tsx index 263d57185bf..f9598107bec 100644 --- a/packages/shared/src/components/notifications/NotificationsBell.tsx +++ b/packages/shared/src/components/notifications/NotificationsBell.tsx @@ -14,6 +14,7 @@ import { useViewSize, ViewSize } from '../../hooks'; import { Tooltip } from '../tooltip/Tooltip'; import Link from '../utilities/Link'; import { IconSize } from '../Icon'; +import { railTabClass, railTabLabelClass } from '../sidebar/common'; function NotificationsBell({ compact, @@ -61,24 +62,25 @@ function NotificationsBell({ href={`${webappUrl}notifications`} aria-label="Notifications" className={classNames( - 'focus-outline relative flex h-10 w-10 items-center justify-center rounded-12 transition-colors hover:bg-surface-hover hover:text-text-primary', - atNotificationsPage - ? 'bg-background-default text-text-primary' - : 'text-text-tertiary', + railTabClass, + atNotificationsPage && 'bg-background-default !text-text-primary', )} onClick={onNavigateNotifications} > - - {hasNotification && ( - - {getUnreadText(unreadCount)} - - )} + + + {hasNotification && ( + + {getUnreadText(unreadCount)} + + )} + + Alerts diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index e25c98ceab1..7d1a740dfb1 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -9,7 +9,13 @@ import React, { } from 'react'; import { useRouter } from 'next/router'; import * as HoverCardPrimitive from '@radix-ui/react-hover-card'; -import { Nav, SidebarAside, SidebarScrollWrapper } from './common'; +import { + Nav, + railTabClass, + railTabLabelClass, + SidebarAside, + SidebarScrollWrapper, +} from './common'; import { getSidebarCategoryForPath, SidebarCategory } from './sidebarCategory'; import type { SidebarCategoryId } from './sidebarCategory'; import { ThemeMode, useSettingsContext } from '../../contexts/SettingsContext'; @@ -56,6 +62,7 @@ import NotificationsBell from '../notifications/NotificationsBell'; import { NotificationsRailPanel } from '../notifications/NotificationsRailPanel'; import { ProfilePicture, ProfileImageSize } from '../ProfilePicture'; import { SidebarHeaderStats } from './SidebarHeaderStats'; +import { SidebarRailStats } from './SidebarRailStats'; import Link from '../utilities/Link'; import { settingsUrl, webappUrl } from '../../lib/constants'; import { isAppleDevice } from '../../lib/func'; @@ -114,7 +121,7 @@ const sidebarCategories: SidebarCategoryConfig[] = [ }, { id: SidebarCategory.GameCenter, - label: 'Game Center', + label: 'Quests', // First sub-page in the Game Center category is the Daily quests // page (the panel that used to live in the sidebar). Clicking the // rail icon lands you there; Game Center proper is one click away @@ -566,7 +573,7 @@ export const SidebarDesktopV2 = ({ data-testid="sidebar-aside" className={classNames( 'laptop:bottom-0 laptop:h-dvh laptop:min-h-dvh laptop:flex-row laptop:border-r-0 laptop:bg-transparent', - isExpanded ? 'laptop:w-[19rem]' : 'laptop:w-16', + isExpanded ? 'laptop:w-[20rem]' : 'laptop:w-20', isBannerAvailable ? 'laptop:[--safe-area-top-offset:2rem]' : 'laptop:[--safe-area-top-offset:0rem]', @@ -577,12 +584,12 @@ export const SidebarDesktopV2 = ({ {isExpanded && ( )} @@ -786,7 +798,7 @@ export const SidebarDesktopV2 = ({ diff --git a/packages/shared/src/components/sidebar/SidebarRailStats.tsx b/packages/shared/src/components/sidebar/SidebarRailStats.tsx new file mode 100644 index 00000000000..a2995357fd8 --- /dev/null +++ b/packages/shared/src/components/sidebar/SidebarRailStats.tsx @@ -0,0 +1,208 @@ +import type { MouseEvent, ReactElement, ReactNode } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { useReadingStreak } from '../../hooks/streaks'; +import { useLogContext } from '../../contexts/LogContext'; +import { + reputation as reputationDocsUrl, + walletUrl, +} from '../../lib/constants'; +import { largeNumberFormat } from '../../lib'; +import { formatCurrency } from '../../lib/utils'; +import { isSameDayInTimezone } from '../../lib/timezones'; +import { LogEvent } from '../../lib/log'; +import Link from '../utilities/Link'; +import { Tooltip } from '../tooltip/Tooltip'; +import { IconSize } from '../Icon'; +import { CoreIcon, ReadingStreakIcon, ReputationIcon } from '../icons'; +import { Typography, TypographyType } from '../typography/Typography'; +import { StreakPopover } from './SidebarHeaderStats'; + +const slotClass = + 'focus-outline flex w-full items-center justify-center gap-1 py-1.5 text-text-primary transition-colors hover:bg-surface-hover'; +const iconBoxClass = 'flex size-4 shrink-0 items-center justify-center'; +const dividerClass = 'h-px w-full bg-border-subtlest-quaternary'; + +type RailSlotProps = { + ariaLabel: string; + icon: ReactNode; + value: string | number; + href?: string; + target?: string; + onClick?: (event: MouseEvent) => void; +}; + +const RailSlot = ({ + ariaLabel, + icon, + value, + href, + target, + onClick, +}: RailSlotProps): ReactElement => { + const inner = ( + <> + {icon} + + {value} + + + ); + + if (onClick) { + return ( + + ); + } + + if (!href) { + return ( + + {inner} + + ); + } + + return ( + + + {inner} + + + ); +}; + +// Compact streak / reputation / cores cluster pinned to the always-visible +// desktop rail (vertical sibling of the panel's `SidebarHeaderStats`). Keeps +// the loved gamification signals at a glance regardless of whether the +// context panel is collapsed or which category is selected. +export const SidebarRailStats = (): ReactElement | null => { + const { user } = useAuthContext(); + const { streak, isStreaksEnabled } = useReadingStreak(); + const { logEvent } = useLogContext(); + const [isStreaksOpen, setIsStreaksOpen] = useState(false); + const streakSlotRef = useRef(null); + + const handleStreakClick = useCallback(() => { + setIsStreaksOpen((open) => { + const next = !open; + if (next) { + logEvent({ event_name: LogEvent.OpenStreaks }); + } + return next; + }); + }, [logEvent]); + + if (!user) { + return null; + } + + const reputation = user.reputation ?? 0; + const balance = user.balance?.amount ?? 0; + const preciseBalance = formatCurrency(balance, { minimumFractionDigits: 0 }); + const streakValue = streak?.current ?? 0; + const hasReadToday = + !!streak?.lastViewAt && + isSameDayInTimezone(new Date(streak.lastViewAt), new Date(), user.timezone); + + const streakSlot = ( + + + + } + value={streakValue} + onClick={streak ? handleStreakClick : undefined} + /> + ); + + return ( +
+ {isStreaksEnabled && ( + <> +
+ {isStreaksOpen ? ( + streakSlot + ) : ( + + {streakSlot} + + )} +
+ {streak && isStreaksOpen && ( + setIsStreaksOpen(false)} + placement="right" + /> + )} + + + )} + +
+ + + + } + value={largeNumberFormat(reputation)} + href={reputationDocsUrl} + target="_blank" + /> +
+
+ + + Wallet +
+ {preciseBalance} Cores + + } + > +
+ + + + } + value={largeNumberFormat(balance)} + href={walletUrl} + /> +
+
+
+ ); +}; diff --git a/packages/shared/src/components/sidebar/common.tsx b/packages/shared/src/components/sidebar/common.tsx index e25cf19874b..39663303d14 100644 --- a/packages/shared/src/components/sidebar/common.tsx +++ b/packages/shared/src/components/sidebar/common.tsx @@ -51,6 +51,12 @@ interface NavItemProps { export const navBtnClass = 'flex flex-1 items-center pl-2 laptop:pl-0 pr-5 laptop:pr-3 h-10 laptop:h-9 overflow-hidden'; +// Vertical icon+label item used on the v2 desktop rail. Shared so the +// notifications bell matches the hard-coded category tabs. Callers append +// the active state (`bg-background-default !text-text-primary`). +export const railTabClass = + 'focus-outline group relative flex w-full flex-col items-center gap-1 rounded-12 px-1 py-2 text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text-primary'; +export const railTabLabelClass = 'typo-caption2 leading-tight text-center'; export const SidebarAside = classed( 'aside', 'flex flex-col z-sidebarOverlay laptop:z-sidebar laptop:-translate-x-0 left-0 bg-background-default border-r border-border-subtlest-tertiary transition-[width,transform] duration-300 ease-in-out group fixed top-0 h-full', diff --git a/packages/shared/src/components/tooltips/InteractivePopup.tsx b/packages/shared/src/components/tooltips/InteractivePopup.tsx index 72c85ee70ce..b1bc99277aa 100644 --- a/packages/shared/src/components/tooltips/InteractivePopup.tsx +++ b/packages/shared/src/components/tooltips/InteractivePopup.tsx @@ -63,7 +63,7 @@ const positionClass: Record = { leftEnd: classNames(leftClass, endClass), profileMenu: classNames(profileMenuRightClass, 'top-14'), screen: 'inset-0 w-screen h-screen', - sidebarSupportMenu: 'left-16 bottom-3 ml-2', + sidebarSupportMenu: 'left-20 bottom-3 ml-2', }; const leftPositions = [ From 7e3f9266902559f1f15ddf84392ecc5d60ba5385 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 10 Jun 2026 16:02:29 +0300 Subject: [PATCH 002/137] fix(layout-v2): allow null stat value in SidebarRailStats largeNumberFormat returns string | null; widen RailSlot value type to match (mirrors SidebarHeaderStats) so the strict typecheck passes. Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/components/sidebar/SidebarRailStats.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/components/sidebar/SidebarRailStats.tsx b/packages/shared/src/components/sidebar/SidebarRailStats.tsx index a2995357fd8..41acf3ab9fc 100644 --- a/packages/shared/src/components/sidebar/SidebarRailStats.tsx +++ b/packages/shared/src/components/sidebar/SidebarRailStats.tsx @@ -26,7 +26,7 @@ const dividerClass = 'h-px w-full bg-border-subtlest-quaternary'; type RailSlotProps = { ariaLabel: string; icon: ReactNode; - value: string | number; + value: string | number | null; href?: string; target?: string; onClick?: (event: MouseEvent) => void; From fc2c88e6f3f187a964b2f81ec210ddeab7066769 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 10 Jun 2026 18:54:24 +0300 Subject: [PATCH 003/137] refactor(layout-v2): move profile to rail-bottom avatar, tighten padding - Reduce the rail's horizontal padding (px-2 -> px-1.5) so it feels less bulky. - Remove the Profile tab from the top category list and relocate it to the bottom of the rail (below the settings icon) as the profile avatar, with the same click/panel navigation the tab had. - Move the streak / cores / reputation cluster below the avatar. - Drop the avatar + name + stats block from the expanded Home panel (the stats now live only on the rail; create-post action stays). - Extract StreakPopover into its own file and delete the now-unused SidebarHeaderStats component. Co-Authored-By: Claude Opus 4.8 --- .../components/sidebar/SidebarDesktopV2.tsx | 87 +++-- .../components/sidebar/SidebarHeaderStats.tsx | 334 ------------------ .../components/sidebar/SidebarRailStats.tsx | 10 +- .../src/components/sidebar/StreakPopover.tsx | 106 ++++++ 4 files changed, 152 insertions(+), 385 deletions(-) delete mode 100644 packages/shared/src/components/sidebar/SidebarHeaderStats.tsx create mode 100644 packages/shared/src/components/sidebar/StreakPopover.tsx diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index 7d1a740dfb1..6086295680a 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -61,7 +61,6 @@ import { useAuthContext } from '../../contexts/AuthContext'; import NotificationsBell from '../notifications/NotificationsBell'; import { NotificationsRailPanel } from '../notifications/NotificationsRailPanel'; import { ProfilePicture, ProfileImageSize } from '../ProfilePicture'; -import { SidebarHeaderStats } from './SidebarHeaderStats'; import { SidebarRailStats } from './SidebarRailStats'; import Link from '../utilities/Link'; import { settingsUrl, webappUrl } from '../../lib/constants'; @@ -545,6 +544,7 @@ export const SidebarDesktopV2 = ({ (category) => category.id === selectedCategory, )?.label; const isSettingsSelected = selectedCategory === SidebarCategory.Settings; + const isProfileSelected = selectedCategory === SidebarCategory.Profile; const isNotificationsSelected = selectedCategory === SidebarCategory.Notifications; const isHomePanel = selectedCategory === SidebarCategory.Main; @@ -589,7 +589,7 @@ export const SidebarDesktopV2 = ({ )} @@ -841,45 +871,10 @@ export const SidebarDesktopV2 = ({ )} > {isHomePanel ? ( -
+
{isLoggedIn && user ? ( -
+
-
- -
-
) => void; - id?: string; -}; - -// Each slot is rendered inside a wrapper element that acts as the Tooltip -// trigger (see usage below). Keeping the anchor/button as a plain child — -// instead of making it the Radix `asChild` trigger directly — avoids the -// double clone (Radix Slot + Next `legacyBehavior` Link) that swallowed the -// link's click navigation. -const StatSlot = ({ - ariaLabel, - icon, - value, - href, - target, - onClick, - id, -}: StatSlotProps): ReactElement => { - const inner = ( - <> - {icon} - - {value} - - - ); - - if (onClick) { - return ( - - ); - } - - if (!href) { - return ( - - {inner} - - ); - } - - return ( - - - {inner} - - - ); -}; - -const dividerClass = 'w-px self-stretch bg-border-subtlest-quaternary'; - -type StreakPopoverProps = { - streak: UserStreak; - triggerRef: React.RefObject; - onClose: () => void; - // 'bottom' drops below the trigger (panel/header usage). 'right' opens - // beside the trigger and anchors to its bottom edge so it grows upward — - // used by the rail cluster, which sits near the viewport bottom. - placement?: 'bottom' | 'right'; -}; - -// Manually positioned portal popover: read the trigger's bounding rect -// and render the panel via a body-level portal. This keeps the popover -// stable (no Tippy auto-flip surprises inside the sidebar's transform / -// overflow context) and ensures it always drops from the streak button as -// expected. -export const StreakPopover = ({ - streak, - triggerRef, - onClose, - placement = 'bottom', -}: StreakPopoverProps): ReactElement | null => { - const [position, setPosition] = useState<{ - top?: number; - left: number; - bottom?: number; - } | null>(null); - const popoverRef = useRef(null); - - const updatePosition = useCallback(() => { - const trigger = triggerRef.current; - if (!trigger) { - return; - } - const rect = trigger.getBoundingClientRect(); - if (placement === 'right') { - setPosition({ - left: rect.right + 8, - bottom: window.innerHeight - rect.bottom, - }); - return; - } - setPosition({ top: rect.bottom + 8, left: rect.left }); - }, [placement, triggerRef]); - - useLayoutEffect(() => { - updatePosition(); - }, [updatePosition]); - - useEffect(() => { - window.addEventListener('resize', updatePosition); - window.addEventListener('scroll', updatePosition, true); - return () => { - window.removeEventListener('resize', updatePosition); - window.removeEventListener('scroll', updatePosition, true); - }; - }, [updatePosition]); - - useEffect(() => { - const handleClickOutside = (event: globalThis.MouseEvent) => { - const target = event.target as Node | null; - if ( - target && - !popoverRef.current?.contains(target) && - !triggerRef.current?.contains(target) - ) { - onClose(); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [onClose, triggerRef]); - - if (!position) { - return null; - } - - return ( - -
- -
-
- ); -}; - -const StreakHintTooltip = ({ - children, - content, -}: Pick): ReactElement => ( - - {children} - -); - -export const SidebarHeaderStats = (): ReactElement | null => { - const { user } = useAuthContext(); - const { streak, isStreaksEnabled } = useReadingStreak(); - const { logEvent } = useLogContext(); - const [isStreaksOpen, setIsStreaksOpen] = useState(false); - const streakSlotRef = useRef(null); - - const handleStreakClick = useCallback(() => { - setIsStreaksOpen((open) => { - const next = !open; - if (next) { - logEvent({ event_name: LogEvent.OpenStreaks }); - } - return next; - }); - }, [logEvent]); - - if (!user) { - return null; - } - - const reputation = user.reputation ?? 0; - const balance = user.balance?.amount ?? 0; - const showStreak = isStreaksEnabled; - const preciseBalance = formatCurrency(balance, { minimumFractionDigits: 0 }); - const streakValue = streak?.current ?? 0; - const hasReadToday = - !!streak?.lastViewAt && - isSameDayInTimezone(new Date(streak.lastViewAt), new Date(), user.timezone); - - const streakSlot = ( - - - - } - value={streakValue} - onClick={streak ? handleStreakClick : undefined} - /> - ); - - return ( -
- {showStreak && ( - <> -
- {isStreaksOpen ? ( - streakSlot - ) : ( - - {streakSlot} - - )} -
- {streak && isStreaksOpen && ( - setIsStreaksOpen(false)} - /> - )} - - - )} - -
- - - - } - value={largeNumberFormat(reputation)} - href={reputationDocsUrl} - target="_blank" - /> -
-
- - - Wallet -
- {preciseBalance} Cores - - } - side="bottom" - > -
- - - - } - value={largeNumberFormat(balance)} - href={walletUrl} - /> -
-
-
- ); -}; diff --git a/packages/shared/src/components/sidebar/SidebarRailStats.tsx b/packages/shared/src/components/sidebar/SidebarRailStats.tsx index 41acf3ab9fc..466dfb60976 100644 --- a/packages/shared/src/components/sidebar/SidebarRailStats.tsx +++ b/packages/shared/src/components/sidebar/SidebarRailStats.tsx @@ -16,7 +16,7 @@ import { Tooltip } from '../tooltip/Tooltip'; import { IconSize } from '../Icon'; import { CoreIcon, ReadingStreakIcon, ReputationIcon } from '../icons'; import { Typography, TypographyType } from '../typography/Typography'; -import { StreakPopover } from './SidebarHeaderStats'; +import { StreakPopover } from './StreakPopover'; const slotClass = 'focus-outline flex w-full items-center justify-center gap-1 py-1.5 text-text-primary transition-colors hover:bg-surface-hover'; @@ -84,10 +84,10 @@ const RailSlot = ({ ); }; -// Compact streak / reputation / cores cluster pinned to the always-visible -// desktop rail (vertical sibling of the panel's `SidebarHeaderStats`). Keeps -// the loved gamification signals at a glance regardless of whether the -// context panel is collapsed or which category is selected. +// Compact streak / reputation / cores cluster pinned to the bottom of the +// always-visible desktop rail. Keeps the loved gamification signals at a +// glance regardless of whether the context panel is collapsed or which +// category is selected. export const SidebarRailStats = (): ReactElement | null => { const { user } = useAuthContext(); const { streak, isStreaksEnabled } = useReadingStreak(); diff --git a/packages/shared/src/components/sidebar/StreakPopover.tsx b/packages/shared/src/components/sidebar/StreakPopover.tsx new file mode 100644 index 00000000000..4aa8c8c357d --- /dev/null +++ b/packages/shared/src/components/sidebar/StreakPopover.tsx @@ -0,0 +1,106 @@ +import type { ReactElement } from 'react'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { RootPortal } from '../tooltips/Portal'; +import { ReadingStreakPopup } from '../streak/popup/ReadingStreakPopup'; +import type { UserStreak } from '../../graphql/users'; + +type StreakPopoverProps = { + streak: UserStreak; + triggerRef: React.RefObject; + onClose: () => void; + // 'bottom' drops below the trigger (panel/header usage). 'right' opens + // beside the trigger and anchors to its bottom edge so it grows upward — + // used by the rail cluster, which sits near the viewport bottom. + placement?: 'bottom' | 'right'; +}; + +// Manually positioned portal popover: read the trigger's bounding rect +// and render the panel via a body-level portal. This keeps the popover +// stable (no Tippy auto-flip surprises inside the sidebar's transform / +// overflow context) and ensures it always drops from the streak button as +// expected. +export const StreakPopover = ({ + streak, + triggerRef, + onClose, + placement = 'bottom', +}: StreakPopoverProps): ReactElement | null => { + const [position, setPosition] = useState<{ + top?: number; + left: number; + bottom?: number; + } | null>(null); + const popoverRef = useRef(null); + + const updatePosition = useCallback(() => { + const trigger = triggerRef.current; + if (!trigger) { + return; + } + const rect = trigger.getBoundingClientRect(); + if (placement === 'right') { + setPosition({ + left: rect.right + 8, + bottom: window.innerHeight - rect.bottom, + }); + return; + } + setPosition({ top: rect.bottom + 8, left: rect.left }); + }, [placement, triggerRef]); + + useLayoutEffect(() => { + updatePosition(); + }, [updatePosition]); + + useEffect(() => { + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [updatePosition]); + + useEffect(() => { + const handleClickOutside = (event: globalThis.MouseEvent) => { + const target = event.target as Node | null; + if ( + target && + !popoverRef.current?.contains(target) && + !triggerRef.current?.contains(target) + ) { + onClose(); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [onClose, triggerRef]); + + if (!position) { + return null; + } + + return ( + +
+ +
+
+ ); +}; From f73a26a11cbcc2f5b210ff4d55fc09fdee2feb8f Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Wed, 10 Jun 2026 23:30:46 +0300 Subject: [PATCH 004/137] feat(layout-v2): compact sidebar mode + uniform rail hover - Make every rail button's hover the same width; fix the Alerts bell that collapsed to content width (bare wrapper div). Search stays icon-only with the shortcut hint outside the hover. - Add account-synced compact mode via the sidebarCompact flag (stored in the settings flags bag, no backend migration): hides the rail labels and search hint and narrows the rail back to its icon-only width. Toggle on the rail (utilities group) and a switch in Settings > Appearance (v2 only). MainLayout content offset mirrors both width sets. - Move the profile avatar to the very bottom, below the stats cluster. Co-Authored-By: Claude Opus 4.8 --- packages/shared/src/components/MainLayout.tsx | 15 +- .../notifications/NotificationsBell.tsx | 7 +- .../components/sidebar/SidebarDesktopV2.tsx | 141 +++++++++++------- packages/shared/src/graphql/settings.ts | 3 + packages/webapp/pages/settings/appearance.tsx | 14 ++ 5 files changed, 123 insertions(+), 57 deletions(-) diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index a105c7598f6..f1447a6faba 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -104,8 +104,15 @@ function MainLayoutComponent({ const { growthbook } = useGrowthBookContext(); const { sidebarRendered } = useSidebarRendered(); const { isAvailable: isBannerAvailable } = useBanner(); - const { sidebarExpanded, autoDismissNotifications, loadedSettings } = + const { sidebarExpanded, autoDismissNotifications, loadedSettings, flags } = useContext(SettingsContext); + const isSidebarCompact = !!flags?.sidebarCompact; + const v2CollapsedPadding = isSidebarCompact + ? 'tablet:pl-16 laptop:pl-16' + : 'tablet:pl-16 laptop:pl-20'; + const v2ExpandedPadding = isSidebarCompact + ? 'laptop:!pl-[19rem]' + : 'laptop:!pl-[20rem]'; const [hasLoggedImpression, setHasLoggedImpression] = useState(false); const { feedName } = useActiveFeedNameContext(); const page = router?.route?.substring(1).trim() as SharedFeedPage; @@ -305,14 +312,12 @@ function MainLayoutComponent({ 'transition-[padding] duration-300 ease-in-out', !sidebarOwnsHeader && 'laptop:pt-16', showSidebar && - (isV2 ? 'tablet:pl-16 laptop:pl-20' : 'tablet:pl-16 laptop:pl-11'), + (isV2 ? v2CollapsedPadding : 'tablet:pl-16 laptop:pl-11'), className, isAuthReady && showSidebar && (sidebarExpanded || forceSidebarExpanded) && - (isV2 - ? 'laptop:!pl-[20rem]' - : !isScreenCentered && 'laptop:!pl-60'), + (isV2 ? v2ExpandedPadding : !isScreenCentered && 'laptop:!pl-60'), isBannerAvailable && !sidebarOwnsHeader && 'laptop:pt-24', )} > diff --git a/packages/shared/src/components/notifications/NotificationsBell.tsx b/packages/shared/src/components/notifications/NotificationsBell.tsx index f9598107bec..0b17ebff20a 100644 --- a/packages/shared/src/components/notifications/NotificationsBell.tsx +++ b/packages/shared/src/components/notifications/NotificationsBell.tsx @@ -20,11 +20,14 @@ function NotificationsBell({ compact, rail, noTooltip, + railHideLabel, active, }: { compact?: boolean; rail?: boolean; noTooltip?: boolean; + // v2 rail compact mode: hide the "Alerts" label under the bell. + railHideLabel?: boolean; // Optional override — the v2 sidebar wants the bell highlighted on // any page that owns the Notifications category (incl. its settings // sub-page), which extends past the bell's own internal check. @@ -80,7 +83,9 @@ function NotificationsBell({ )} - Alerts + {!railHideLabel && ( + Alerts + )}
diff --git a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx index 6086295680a..64caece2f74 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktopV2.tsx @@ -41,6 +41,7 @@ import { FeedbackIcon, HomeIcon, HotIcon, + MenuIcon, MoonIcon, PlusIcon, SearchIcon, @@ -52,6 +53,7 @@ import { } from '../icons'; import { ThemeAutoIcon } from '../icons/ThemeAuto'; import { useSquadNavigation } from '../../hooks'; +import { useSettingsBooleanFlag } from '../../hooks/useSettingsBooleanFlag'; import { LogEvent, Origin, TargetType } from '../../lib/log'; import { IconSize } from '../Icon'; import { Tooltip } from '../tooltip/Tooltip'; @@ -138,7 +140,7 @@ const sidebarCategories: SidebarCategoryConfig[] = [ ]; const railButtonClass = - 'flex h-10 w-10 items-center justify-center rounded-12 text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text-primary focus-outline'; + 'flex h-10 w-full items-center justify-center rounded-12 text-text-tertiary transition-colors hover:bg-surface-hover hover:text-text-primary focus-outline'; const shortcutKeys = [isAppleDevice() ? '⌘' : 'Ctrl', 'K']; const settingsDefaultPath = `${settingsUrl}/profile`; @@ -340,6 +342,17 @@ export const SidebarDesktopV2 = ({ const { open: openSpotlight } = useSpotlight(); const { openNewSquad } = useSquadNavigation(); const { isLoggedIn, user } = useAuthContext(); + const { value: isCompact, toggle: toggleCompact } = + useSettingsBooleanFlag('sidebarCompact'); + // Compact mode reverts to the original icon-only widths (pre-label rail). + // Both width sets are known-good; MainLayout mirrors the collapsed/expanded + // padding so the content never overlaps the rail. + const railCollapsedWidth = isCompact ? 'laptop:w-16' : 'laptop:w-20'; + const railExpandedWidth = isCompact ? 'laptop:w-[19rem]' : 'laptop:w-[20rem]'; + const railNavWidth = isCompact ? 'w-16' : 'w-20'; + const railSeparatorLeft = isCompact ? 'left-16' : 'left-20'; + const railToggleClosedLeft = isCompact ? 'left-[3.5rem]' : 'left-[4.5rem]'; + const railToggleOpenLeft = isCompact ? 'left-[16.5rem]' : 'left-[17.5rem]'; const claimableQuestCount = useClaimableQuestCount(); const showQuestBadge = !optOutQuestSystem && claimableQuestCount > 0; const activePage = activePageProp || router.asPath || router.pathname || ''; @@ -573,7 +586,7 @@ export const SidebarDesktopV2 = ({ data-testid="sidebar-aside" className={classNames( 'laptop:bottom-0 laptop:h-dvh laptop:min-h-dvh laptop:flex-row laptop:border-r-0 laptop:bg-transparent', - isExpanded ? 'laptop:w-[20rem]' : 'laptop:w-20', + isExpanded ? railExpandedWidth : railCollapsedWidth, isBannerAvailable ? 'laptop:[--safe-area-top-offset:2rem]' : 'laptop:[--safe-area-top-offset:0rem]', @@ -584,12 +597,18 @@ export const SidebarDesktopV2 = ({ {isExpanded && (