+
{canGoBack &&
}
{customBanner}
{isBannerAvailable &&
}
@@ -197,35 +246,74 @@ function MainLayoutComponent({
/>
)}
-
+ {!sidebarOwnsHeader && (
+
+ )}
{isAuthReady && showSidebar && (
)}
- {children}
+ {sidebarOwnsHeader ? (
+
+ {shouldShowTopHero && (
+
+ onEnableReadingReminder(NotificationCtaPlacement.TopHero)
+ }
+ onClose={() =>
+ onDismissReadingReminder(NotificationCtaPlacement.TopHero)
+ }
+ />
+ )}
+ {topBanner}
+
+
+ {children}
+
+
+ ) : (
+ children
+ )}
- {!hideFeedbackWidget &&
}
+ {!hideFeedbackWidget && !sidebarOwnsHeader &&
}
);
}
diff --git a/packages/shared/src/components/RouteProgressBar.module.css b/packages/shared/src/components/RouteProgressBar.module.css
new file mode 100644
index 00000000000..28b88b982bb
--- /dev/null
+++ b/packages/shared/src/components/RouteProgressBar.module.css
@@ -0,0 +1,12 @@
+@keyframes route-progress-slide {
+ 0% {
+ transform: translateX(-100%);
+ }
+ 100% {
+ transform: translateX(400%);
+ }
+}
+
+.bar {
+ animation: route-progress-slide 1s cubic-bezier(0.65, 0, 0.35, 1) infinite;
+}
diff --git a/packages/shared/src/components/RouteProgressBar.tsx b/packages/shared/src/components/RouteProgressBar.tsx
new file mode 100644
index 00000000000..e010e434ce9
--- /dev/null
+++ b/packages/shared/src/components/RouteProgressBar.tsx
@@ -0,0 +1,69 @@
+import type { ReactElement } from 'react';
+import React, { useEffect, useState } from 'react';
+import { useRouter } from 'next/router';
+import classNames from 'classnames';
+import styles from './RouteProgressBar.module.css';
+
+interface RouteProgressBarProps {
+ className?: string;
+}
+
+/**
+ * Thin indeterminate progress bar rendered at the top of the v2 floating
+ * card while Next.js is between `routeChangeStart` and `routeChangeComplete`.
+ *
+ * Without this, the v2 sidebar updates instantly on rail-icon click (the
+ * optimistic `pendingCategory` swap) but the page content can stay on the
+ * old route for a noticeable beat while the destination chunk loads —
+ * making the two halves of the screen feel out of sync. The progress bar
+ * signals "we heard you, the page is loading" so the gap reads as
+ * intentional rather than broken.
+ *
+ * The bar is absolute / pointer-events-none, so consumers just drop it
+ * inside their already-positioned container (e.g. the floating-card
+ * wrapper) and it floats at the top edge without affecting layout.
+ */
+export const RouteProgressBar = ({
+ className,
+}: RouteProgressBarProps): ReactElement | null => {
+ const router = useRouter();
+ const [isRouteChanging, setIsRouteChanging] = useState(false);
+
+ useEffect(() => {
+ const handleStart = () => setIsRouteChanging(true);
+ const handleEnd = () => setIsRouteChanging(false);
+
+ router.events.on('routeChangeStart', handleStart);
+ router.events.on('routeChangeComplete', handleEnd);
+ router.events.on('routeChangeError', handleEnd);
+
+ return () => {
+ router.events.off('routeChangeStart', handleStart);
+ router.events.off('routeChangeComplete', handleEnd);
+ router.events.off('routeChangeError', handleEnd);
+ };
+ }, [router.events]);
+
+ if (!isRouteChanging) {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/packages/shared/src/components/bookmark/BookmarkFolderContextMenu.tsx b/packages/shared/src/components/bookmark/BookmarkFolderContextMenu.tsx
index ed12993f232..51595a9191b 100644
--- a/packages/shared/src/components/bookmark/BookmarkFolderContextMenu.tsx
+++ b/packages/shared/src/components/bookmark/BookmarkFolderContextMenu.tsx
@@ -14,14 +14,17 @@ import {
DropdownMenuTrigger,
} from '../dropdown/DropdownMenu';
import { Button } from '../buttons/Button';
+import type { ButtonProps } from '../buttons/Button';
import type { MenuItemProps } from '../dropdown/common';
interface BookmarkFolderContextMenuProps {
folder: BookmarkFolder;
+ buttonProps?: Pick
, 'className' | 'size' | 'variant'>;
}
export const BookmarkFolderContextMenu = ({
folder,
+ buttonProps,
}: BookmarkFolderContextMenuProps): ReactElement => {
const { openModal, closeModal } = useLazyModal();
const { showPrompt } = usePrompt();
@@ -68,9 +71,9 @@ export const BookmarkFolderContextMenu = ({
}
/>
diff --git a/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx b/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx
index 5a238c5a371..3e5adbf57ad 100644
--- a/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx
+++ b/packages/shared/src/components/buttons/ToggleClickbaitShield.tsx
@@ -22,6 +22,7 @@ import { webappUrl } from '../../lib/constants';
import { FeedSettingsMenu } from '../feeds/FeedSettings/types';
import { Tooltip } from '../tooltip/Tooltip';
import { useNewD1ExperienceFeature } from '../../hooks/useNewD1ExperienceFeature';
+import { useLayoutVariant } from '../../hooks/layout/useLayoutVariant';
export const ToggleClickbaitShield = ({
origin,
@@ -43,10 +44,15 @@ export const ToggleClickbaitShield = ({
const { value: isNewD1Experience } = useNewD1ExperienceFeature({
shouldEvaluate: !isPlus,
});
+ // v2 dual-sidebar laptop renders this button inside the page-header
+ // strip alongside MyFeedHeading; use the compact ghost sizing
+ // (Small + Tertiary) so the strip stays consistent with the mock.
+ const { isV2 } = useLayoutVariant();
+ const isV2Compact = isV2;
const commonIconProps: ButtonProps<'button'> = {
- size: ButtonSize.Medium,
- variant: ButtonVariant.Float,
+ size: isV2Compact ? ButtonSize.Small : ButtonSize.Medium,
+ variant: isV2Compact ? ButtonVariant.Tertiary : ButtonVariant.Float,
iconSecondaryOnHover: true,
...buttonProps,
};
diff --git a/packages/shared/src/components/feedback/FeedbackWidget.tsx b/packages/shared/src/components/feedback/FeedbackWidget.tsx
index 21a691f3c91..33a407a2681 100644
--- a/packages/shared/src/components/feedback/FeedbackWidget.tsx
+++ b/packages/shared/src/components/feedback/FeedbackWidget.tsx
@@ -1,15 +1,30 @@
import type { ReactElement } from 'react';
-import React, { useEffect, useMemo, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import { getDayOfYear } from 'date-fns';
import { Button, ButtonVariant, ButtonSize } from '../buttons/Button';
import { useAuthContext } from '../../contexts/AuthContext';
import { useSettingsContext } from '../../contexts/SettingsContext';
+import { useLogContext } from '../../contexts/LogContext';
import { useViewSize, ViewSize } from '../../hooks/useViewSize';
import { useLazyModal } from '../../hooks/useLazyModal';
import { LazyModal } from '../modals/common/types';
import { ProfilePicture, ProfileImageSize } from '../ProfilePicture';
import { useCustomizeNewTab } from '../../features/customizeNewTab/CustomizeNewTabContext';
+import { IconSize } from '../Icon';
+import { MiniCloseIcon } from '../icons';
+import { LogEvent, TargetType } from '../../lib/log';
+
+interface FeedbackWidgetProps {
+ // `fixed` (default) — floating bottom-right pill (legacy/v1 chrome).
+ // `sidebar` — inline button rendered inside the v2 expanded sidebar
+ // panel, with a hover-revealed × to hide it (toggles
+ // `showFeedbackButton`).
+ // `support` — same inline button rendered inside the v2 rail support
+ // popover; not gated by `showFeedbackButton` so users can re-open the
+ // widget after dismissing it from the sidebar.
+ placement?: 'fixed' | 'sidebar' | 'support';
+}
const TEAM_MEMBERS = [
{
@@ -68,22 +83,39 @@ const getDailyTrio = (): ReadonlyArray<(typeof TEAM_MEMBERS)[number]> => {
return [0, 1, 2].map((i) => TEAM_MEMBERS[(dayOfYear + i) % len]);
};
-export function FeedbackWidget(): ReactElement | null {
+export function FeedbackWidget({
+ placement = 'fixed',
+}: FeedbackWidgetProps = {}): ReactElement | null {
const { user } = useAuthContext();
- const { showFeedbackButton } = useSettingsContext();
+ const { showFeedbackButton, toggleShowFeedbackButton } = useSettingsContext();
+ const { logEvent } = useLogContext();
const isMobile = useViewSize(ViewSize.MobileL);
const { openModal } = useLazyModal();
const dailyTrio = useMemo(getDailyTrio, []);
const [isCompact, setIsCompact] = useState(false);
const { panelWidth } = useCustomizeNewTab();
- // Only show for authenticated users on desktop when the setting is on.
- // Mobile feedback is handled by FooterPlusButton. Hide during the
- // panel rather than a competing pill in the corner.
- const isVisible = !!user && !isMobile && showFeedbackButton;
+ // Only show for authenticated users on desktop. The fixed/sidebar
+ // variants are additionally gated by the `showFeedbackButton` setting;
+ // the support variant lives inside the rail support popover and is
+ // always reachable so users can re-open the widget after hiding it
+ // from the sidebar.
+ const isSupport = placement === 'support';
+ const isSidebar = placement === 'sidebar';
+ const isInline = isSupport || isSidebar;
+ const isVisible = !!user && !isMobile && (isSupport || showFeedbackButton);
+
+ const onHideFeedbackButton = useCallback(() => {
+ logEvent({
+ event_name: LogEvent.ChangeSettings,
+ target_type: TargetType.FeedbackButton,
+ target_id: 'hide',
+ });
+ return toggleShowFeedbackButton();
+ }, [logEvent, toggleShowFeedbackButton]);
useEffect(() => {
- if (!isVisible) {
+ if (!isVisible || isInline) {
return undefined;
}
const callback = () => {
@@ -94,12 +126,59 @@ export function FeedbackWidget(): ReactElement | null {
callback();
window.addEventListener('scroll', callback, { passive: true });
return () => window.removeEventListener('scroll', callback);
- }, [isVisible]);
+ }, [isVisible, isInline]);
if (!isVisible) {
return null;
}
+ if (isInline) {
+ return (
+
+
openModal({ type: LazyModal.Feedback })}
+ aria-label="Send feedback. Real people reply."
+ >
+
+ Feedback
+
+ Real people reply
+
+
+
+ {dailyTrio.map((member, index) => (
+
+ ))}
+
+
+
+ {isSidebar && (
+
+
+
+ )}
+
+ );
+ }
+
return (
{!!actionButtons && (
-
+
{actionButtons}
)}
{shortcuts}
-
+
)}
{
if (isCustomDefaultFeed && pathname === '/') {
@@ -53,19 +59,37 @@ function MyFeedHeading({
return push(editFeedUrl);
}, [editFeedUrl, onOpenFeedFilters, push]);
+ // Button's discriminated union requires `iconPosition` whenever `icon`
+ // is set — `undefined` and conditional spreads both break it under
+ // strict typecheck. Always pass a concrete value: `Right` when the
+ // list-frame layout is active, otherwise the default `Left`.
+ const iconPosition = shouldUseListFeedLayout
+ ? ButtonIconPosition.Right
+ : ButtonIconPosition.Left;
+
return (
<>
- }
- iconPosition={
- shouldUseListFeedLayout ? ButtonIconPosition.Right : undefined
- }
- >
- {!isMobile ? 'Feed settings' : null}
-
+ {isV2Compact ? (
+ }
+ iconPosition={iconPosition}
+ >
+ {!isMobile ? 'Feed settings' : null}
+
+ ) : (
+ }
+ iconPosition={iconPosition}
+ >
+ {!isMobile ? 'Feed settings' : null}
+
+ )}
{showToggleShortcuts && (
}
- iconPosition={
- shouldUseListFeedLayout ? ButtonIconPosition.Right : undefined
- }
+ iconPosition={iconPosition}
>
Shortcuts
diff --git a/packages/shared/src/components/layout/PageHeader.tsx b/packages/shared/src/components/layout/PageHeader.tsx
new file mode 100644
index 00000000000..f2410b0f6d6
--- /dev/null
+++ b/packages/shared/src/components/layout/PageHeader.tsx
@@ -0,0 +1,38 @@
+import type { ReactElement, ReactNode } from 'react';
+import React from 'react';
+import classNames from 'classnames';
+
+// `min-h-14` locks the strip to a Small-button + py-3 height so the
+// header reads at a consistent 56px regardless of action contents.
+export const pageHeaderClassName =
+ 'flex min-h-14 w-full items-center gap-2 border-b border-border-subtlest-quaternary px-6 py-3';
+
+export interface PageHeaderProps {
+ title?: ReactNode;
+ children?: ReactNode;
+ className?: string;
+}
+
+export const PageHeader = ({
+ title,
+ children,
+ className,
+}: PageHeaderProps): ReactElement => (
+
+ {title !== undefined &&
+ (typeof title === 'string' ? (
+
+ {title}
+
+ ) : (
+
+ {title}
+
+ ))}
+ {children !== undefined && (
+
+ {children}
+
+ )}
+
+);
diff --git a/packages/shared/src/components/layout/common.tsx b/packages/shared/src/components/layout/common.tsx
index b39c2526f8e..2a7caf75707 100644
--- a/packages/shared/src/components/layout/common.tsx
+++ b/packages/shared/src/components/layout/common.tsx
@@ -50,6 +50,7 @@ import { downloadBrowserExtension } from '../../lib/constants';
import { anchorDefaultRel } from '../../lib/strings';
import ConditionalWrapper from '../ConditionalWrapper';
import { useNewD1ExperienceFeature } from '../../hooks/useNewD1ExperienceFeature';
+import { useLayoutVariant } from '../../hooks/layout/useLayoutVariant';
type State = [T, Dispatch>];
@@ -94,6 +95,8 @@ export const SearchControlHeader = ({
const { isUpvoted, isSortableFeed } = useFeedName({ feedName });
const isLaptop = useViewSize(ViewSize.Laptop);
const isMobile = useViewSize(ViewSize.MobileL);
+ const { isV2 } = useLayoutVariant();
+ const isV2Strip = isV2;
const { streak, isLoading, isStreaksEnabled } = useReadingStreak();
const { checkHasCompleted, completeAction, isActionsFetched } = useActions();
const browserName = getCurrentBrowserName();
@@ -128,13 +131,18 @@ export const SearchControlHeader = ({
className: {
label: 'hidden',
chevron: 'hidden',
- button: '!px-1',
+ // V2 ghost styling is applied via descendant selectors on the
+ // wrapping strip (see `v2CompactButtons`); the dropdown's own
+ // button only needs the legacy tight horizontal padding for the
+ // non-v2 layout.
+ button: isV2Strip ? undefined : '!px-1',
container: 'flex',
},
shouldIndicateSelected: true,
- buttonSize: isMobile ? ButtonSize.Small : ButtonSize.Medium,
+ buttonSize: isMobile || isV2Strip ? ButtonSize.Small : ButtonSize.Medium,
iconOnly: true,
- buttonVariant: isLaptop ? ButtonVariant.Float : ButtonVariant.Tertiary,
+ buttonVariant:
+ isV2Strip || !isLaptop ? ButtonVariant.Tertiary : ButtonVariant.Float,
};
const shouldShowInstallExtensionPrompt =
@@ -169,13 +177,14 @@ export const SearchControlHeader = ({
);
+ const dropdownIconSize = isV2Strip ? IconSize.XSmall : IconSize.Medium;
const primaryActions = [
hasFeedActions && ,
isUpvoted ? (
}
+ icon={ }
selectedIndex={selectedPeriod}
options={periodTexts}
onChange={(_, index) => setSelectedPeriod(index)}
@@ -185,7 +194,7 @@ export const SearchControlHeader = ({
}
+ icon={ }
selectedIndex={selectedAlgo}
options={algorithmsList}
onChange={(_, index) => setSelectedAlgo(index)}
@@ -208,6 +217,14 @@ export const SearchControlHeader = ({
const actions = primaryActions.filter(Boolean);
const sideActions = secondaryActions.filter(Boolean);
+ // In v2 the FeedContainer wraps these actions inside its own
+ // strip (with compact-button descendant selectors), so we
+ // skip the local `` wrapper here and just return the action
+ // contents inline. Control + mobile paths keep the legacy structure.
+ if (isV2Strip) {
+ return <>{actions}>;
+ }
+
return (
-
+
{!!chips && {chips}
}
{actions}
{sideActions.length > 0 && (
{sideActions}
)}
-
+
);
};
diff --git a/packages/shared/src/components/notifications/NotificationsBell.tsx b/packages/shared/src/components/notifications/NotificationsBell.tsx
index 1122dc10e27..9afc5d799ec 100644
--- a/packages/shared/src/components/notifications/NotificationsBell.tsx
+++ b/packages/shared/src/components/notifications/NotificationsBell.tsx
@@ -13,8 +13,15 @@ import { webappUrl } from '../../lib/constants';
import { useViewSize, ViewSize } from '../../hooks';
import { Tooltip } from '../tooltip/Tooltip';
import Link from '../utilities/Link';
+import { IconSize } from '../Icon';
-function NotificationsBell({ compact }: { compact?: boolean }): ReactElement {
+function NotificationsBell({
+ compact,
+ rail,
+}: {
+ compact?: boolean;
+ rail?: boolean;
+}): ReactElement {
const router = useRouter();
const atNotificationsPage = router.pathname === notificationsUrl;
const { logEvent } = useLogContext();
@@ -31,6 +38,39 @@ function NotificationsBell({ compact }: { compact?: boolean }): ReactElement {
const mobileVariant = atNotificationsPage ? undefined : ButtonVariant.Option;
+ if (rail) {
+ return (
+
+
+
+ );
+ }
+
return (
diff --git a/packages/shared/src/components/quest/QuestButton.tsx b/packages/shared/src/components/quest/QuestButton.tsx
index 8865a4d3344..e84ccb5ad82 100644
--- a/packages/shared/src/components/quest/QuestButton.tsx
+++ b/packages/shared/src/components/quest/QuestButton.tsx
@@ -507,6 +507,7 @@ const QuestLevelFireworkLayer = ({
interface QuestButtonProps {
compact?: boolean;
+ panelOnly?: boolean;
}
interface QuestDropdownPanelProps {
@@ -659,6 +660,7 @@ const QuestDropdownPanel = ({
export const QuestButton = ({
compact = false,
+ panelOnly = false,
}: QuestButtonProps): ReactElement => {
const router = useRouter();
const { optOutLevelSystem } = useSettingsContext();
@@ -1210,6 +1212,44 @@ export const QuestButton = ({
};
}, [clearClaimedStampTimers, clearProgressTimers]);
+ if (panelOnly) {
+ return (
+ <>
+
+
+
+ {rewardFlightLayers.map((layer) => (
+
handleRewardFlightLayerDone(layer.claimRotationId)}
+ />
+ ))}
+ {levelFireworkParticles.length > 0 && (
+
+ )}
+ >
+ );
+ }
+
return (
<>
diff --git a/packages/shared/src/components/sidebar/RailHoverPanel.tsx b/packages/shared/src/components/sidebar/RailHoverPanel.tsx
new file mode 100644
index 00000000000..7ea5c433697
--- /dev/null
+++ b/packages/shared/src/components/sidebar/RailHoverPanel.tsx
@@ -0,0 +1,37 @@
+import type { ReactNode } from 'react';
+import React from 'react';
+import classNames from 'classnames';
+import { Typography, TypographyType } from '../typography/Typography';
+import { Nav, SidebarScrollWrapper } from './common';
+
+export interface RailHoverPanelProps {
+ title: string;
+ children: ReactNode;
+ className?: string;
+}
+
+const floatingListSpacingOverrides = '[&_li.mx-3]:!mx-2';
+
+export const RailHoverPanel = ({
+ title,
+ children,
+ className,
+}: RailHoverPanelProps) => (
+
+
+
+ {title}
+
+
+
+
+ {children}
+
+
+
+);
diff --git a/packages/shared/src/components/sidebar/Section.tsx b/packages/shared/src/components/sidebar/Section.tsx
index 9bcb2a90861..edd36f7b135 100644
--- a/packages/shared/src/components/sidebar/Section.tsx
+++ b/packages/shared/src/components/sidebar/Section.tsx
@@ -76,7 +76,11 @@ export function Section({
{/* Header content shown when sidebar is expanded */}
diff --git a/packages/shared/src/components/sidebar/Sidebar.tsx b/packages/shared/src/components/sidebar/Sidebar.tsx
index 347ed949036..ea4c4fcdb52 100644
--- a/packages/shared/src/components/sidebar/Sidebar.tsx
+++ b/packages/shared/src/components/sidebar/Sidebar.tsx
@@ -1,8 +1,9 @@
-import type { ReactElement } from 'react';
+import type { ReactElement, ReactNode } from 'react';
import React from 'react';
import dynamic from 'next/dynamic';
import { useViewSize, ViewSize } from '../../hooks';
import { useFeatureTheme } from '../../hooks/utils/useFeatureTheme';
+import { useLayoutVariant } from '../../hooks/layout/useLayoutVariant';
import { isExtension } from '../../lib/func';
const SidebarTablet = dynamic(() =>
@@ -15,16 +16,25 @@ const SidebarDesktop = dynamic(() =>
(mod) => mod.SidebarDesktop,
),
);
+const SidebarDesktopV2 = dynamic(() =>
+ import(/* webpackChunkName: "sidebarDesktopV2" */ './SidebarDesktopV2').then(
+ (mod) => mod.SidebarDesktopV2,
+ ),
+);
interface SidebarProps {
activePage: string;
+ additionalButtons?: ReactNode;
isNavButtons?: boolean;
+ showFeedbackWidget?: boolean;
onNavTabClick?: (tab: string) => void;
onLogoClick?: (e: React.MouseEvent) => unknown;
}
export const Sidebar = ({
+ additionalButtons,
isNavButtons,
+ showFeedbackWidget,
onNavTabClick,
onLogoClick,
activePage,
@@ -32,6 +42,7 @@ export const Sidebar = ({
const isLaptop = useViewSize(ViewSize.Laptop);
const isTablet = useViewSize(ViewSize.Tablet);
const featureTheme = useFeatureTheme();
+ const { isV2 } = useLayoutVariant();
if (!isLaptop && isTablet) {
return (
@@ -44,10 +55,21 @@ export const Sidebar = ({
}
if (isLaptop) {
+ if (isV2) {
+ return (
+
+ );
+ }
return (
ReactElement;
+ defaultPath?: string;
+};
+
+const sidebarCategories: SidebarCategoryConfig[] = [
+ {
+ id: SidebarCategory.Main,
+ label: 'Home',
+ defaultPath: webappUrl,
+ icon: (active) => (
+
+ ),
+ },
+ {
+ id: SidebarCategory.Squads,
+ label: 'Squads',
+ defaultPath: `${webappUrl}squads/discover`,
+ icon: (active) => (
+
+ ),
+ },
+ {
+ id: SidebarCategory.Discover,
+ label: 'Discover',
+ defaultPath: `${webappUrl}tags`,
+ icon: (active) => (
+
+ ),
+ },
+ {
+ id: SidebarCategory.Saved,
+ label: 'Saved',
+ defaultPath: `${webappUrl}bookmarks`,
+ icon: (active) => (
+
+ ),
+ },
+ {
+ id: SidebarCategory.GameCenter,
+ label: 'Game Center',
+ defaultPath: `${webappUrl}game-center`,
+ icon: (active) => (
+
+ ),
+ },
+ {
+ id: SidebarCategory.Profile,
+ label: 'Profile',
+ icon: (active) => (
+
+ ),
+ },
+];
+
+const discoverPathFragments = ['/tags', '/sources', '/users', '/discussed'];
+const profilePathFragments = [
+ '/analytics',
+ '/jobs',
+ '/settings/customization/devcard',
+ '/wallet',
+];
+
+const getSidebarCategoryForPath = (activePage: string): SidebarCategoryId => {
+ if (activePage.includes('/bookmarks') || activePage.includes('/briefing')) {
+ return SidebarCategory.Saved;
+ }
+ if (activePage.includes('/squads')) {
+ return SidebarCategory.Squads;
+ }
+ if (activePage.includes('/settings')) {
+ return SidebarCategory.Settings;
+ }
+ if (activePage.includes('/game-center')) {
+ return SidebarCategory.GameCenter;
+ }
+ if (discoverPathFragments.some((path) => activePage.includes(path))) {
+ return SidebarCategory.Discover;
+ }
+ if (profilePathFragments.some((path) => activePage.includes(path))) {
+ return SidebarCategory.Profile;
+ }
+ return SidebarCategory.Main;
+};
+
+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';
+const shortcutKeys = [isAppleDevice() ? '⌘' : 'Ctrl', 'K'];
+const settingsDefaultPath = `${settingsUrl}/profile`;
+
+const RAIL_HOVER_OPEN_DELAY = 250;
+const RAIL_HOVER_CLOSE_DELAY = 120;
+const RAIL_HOVER_SIDE_OFFSET = 12;
+const RAIL_HOVER_PROFILE_ALIGN_OFFSET = -304;
+// The shared Tooltip primitive bakes in `collisionPadding={{ top: 75 }}` —
+// a leftover from the global-header layout. With the dual-sidebar there's
+// no top chrome to clip against, so a snug override re-centers tooltips
+// with their triggers.
+const RAIL_TOOLTIP_COLLISION_PADDING = 4;
+
+interface RailHoverCardProps {
+ label: string;
+ children: ReactNode;
+ panel: ReactElement;
+ enabled?: boolean;
+ alignOffset?: number;
+}
+
+const RailHoverCard = ({
+ label,
+ children,
+ panel,
+ enabled = true,
+ alignOffset,
+}: RailHoverCardProps) => {
+ if (!enabled) {
+ return <>{children}>;
+ }
+ return (
+
+
+ {children}
+
+
+
+ {panel}
+
+
+
+ );
+};
+
+const themeIconMap: Record<
+ ThemeMode,
+ React.ComponentType<{
+ secondary?: boolean;
+ size?: IconSize;
+ 'aria-hidden'?: boolean;
+ }>
+> = {
+ [ThemeMode.Dark]: MoonIcon,
+ [ThemeMode.Light]: SunIcon,
+ [ThemeMode.Auto]: ThemeAutoIcon,
+};
+
+const SidebarThemeButton = (): ReactElement => {
+ const { setTheme, themeMode } = useSettingsContext();
+ const { logEvent } = useLogContext();
+ const ActiveIcon = themeIconMap[themeMode];
+
+ const onSelectTheme = useCallback(
+ (mode: ThemeMode) => {
+ logEvent({
+ event_name: LogEvent.ChangeSettings,
+ target_type: TargetType.Theme,
+ target_id: mode,
+ });
+ setTheme(mode);
+ },
+ [logEvent, setTheme],
+ );
+
+ const options: MenuItemProps[] = themes.map((theme) => {
+ const Icon = themeIconMap[theme.value];
+ const isActive = theme.value === themeMode;
+ return {
+ label: theme.label,
+ icon: ,
+ action: () => onSelectTheme(theme.value),
+ };
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const SidebarSupportButton = (): ReactElement => {
+ const { isOpen, onUpdate, wrapHandler } = useInteractivePopup();
+
+ return (
+ <>
+
+ onUpdate(!isOpen))}
+ className={classNames(
+ railButtonClass,
+ isOpen && 'bg-background-default text-text-primary',
+ )}
+ >
+
+
+
+ {isOpen && (
+ onUpdate(false)}
+ position={InteractivePopupPosition.SidebarSupportMenu}
+ className="flex w-64 flex-col gap-2 !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3"
+ >
+
+
+
+
+
+ )}
+ >
+ );
+};
+
+type SidebarDesktopV2Props = {
+ activePage?: string;
+ featureTheme?: {
+ logo?: string;
+ logoText?: string;
+ };
+ isNavButtons?: boolean;
+ showFeedbackWidget?: boolean;
+ onNavTabClick?: (tab: string) => void;
+ onLogoClick?: (e: React.MouseEvent) => unknown;
+ additionalButtons?: ReactNode;
+};
+
+export const SidebarDesktopV2 = ({
+ activePage: activePageProp,
+ featureTheme,
+ isNavButtons,
+ showFeedbackWidget,
+ onNavTabClick,
+ onLogoClick,
+ additionalButtons,
+}: SidebarDesktopV2Props): ReactElement => {
+ const router = useRouter();
+ const {
+ sidebarExpanded,
+ toggleSidebarExpanded,
+ loadedSettings,
+ optOutQuestSystem,
+ } = useSettingsContext();
+ const { logEvent } = useLogContext();
+ const { isAvailable: isBannerAvailable } = useBanner();
+ const { open: openSpotlight } = useSpotlight();
+ const { openNewSquad } = useSquadNavigation();
+ const { isLoggedIn, user } = useAuthContext();
+ const activePage = activePageProp || router.asPath || router.pathname || '';
+ const isUserProfileActive =
+ !!user?.username && activePage.includes(`/${user.username}`);
+ const isFeedPage = activePage.includes('/feeds/');
+
+ const resolvedCategory = useMemo((): SidebarCategoryId => {
+ if (isFeedPage) {
+ return SidebarCategory.Main;
+ }
+ if (isUserProfileActive) {
+ return SidebarCategory.Profile;
+ }
+ return getSidebarCategoryForPath(activePage);
+ }, [activePage, isFeedPage, isUserProfileActive]);
+
+ // Optimistic override so a rail click feels instant even when
+ // router.push is async. Cleared once the URL catches up.
+ const [pendingCategory, setPendingCategory] =
+ useState(null);
+ const selectedCategory = pendingCategory ?? resolvedCategory;
+
+ useEffect(() => {
+ if (pendingCategory !== null && pendingCategory === resolvedCategory) {
+ setPendingCategory(null);
+ }
+ }, [pendingCategory, resolvedCategory]);
+
+ const defaultRenderSectionProps = useMemo(
+ () => ({
+ sidebarExpanded: true,
+ shouldShowLabel: true,
+ activePage,
+ }),
+ [activePage],
+ );
+
+ const getCategoryDefaultPath = useCallback(
+ (category: SidebarCategoryId): string | null => {
+ if (category === SidebarCategory.Profile) {
+ return user?.username ? `${webappUrl}${user.username}` : null;
+ }
+ if (category === SidebarCategory.Settings) {
+ return settingsDefaultPath;
+ }
+ return (
+ sidebarCategories.find((entry) => entry.id === category)?.defaultPath ??
+ null
+ );
+ },
+ [user?.username],
+ );
+
+ const onSelectCategory = useCallback(
+ (category: SidebarCategoryId) => {
+ setPendingCategory(category);
+
+ const targetPath = getCategoryDefaultPath(category);
+ if (!targetPath) {
+ return;
+ }
+ const targetPathname = new URL(targetPath, 'http://_').pathname;
+ const currentPathname = activePage.split('?')[0];
+ if (targetPathname !== currentPathname) {
+ // `Promise.resolve` wraps the call so `.catch` still works when
+ // the next/router test mock returns `undefined` from `push`.
+ Promise.resolve(router.push(targetPath)).catch(() => undefined);
+ }
+ },
+ [activePage, getCategoryDefaultPath, router],
+ );
+
+ const onPrefetchCategory = useCallback(
+ (category: SidebarCategoryId) => {
+ const targetPath = getCategoryDefaultPath(category);
+ if (!targetPath) {
+ return;
+ }
+ router.prefetch(targetPath).catch(() => undefined);
+ },
+ [getCategoryDefaultPath, router],
+ );
+
+ const onToggleExpanded = useCallback(() => {
+ logEvent({
+ event_name: `${sidebarExpanded ? 'open' : 'close'} sidebar`,
+ });
+ toggleSidebarExpanded();
+ }, [logEvent, sidebarExpanded, toggleSidebarExpanded]);
+
+ const renderCategorySection = (category: SidebarCategoryId): ReactElement => {
+ if (category === SidebarCategory.Squads) {
+ return (
+
+ );
+ }
+ if (category === SidebarCategory.Saved) {
+ return (
+
+ );
+ }
+ if (category === SidebarCategory.Discover) {
+ return (
+
+ );
+ }
+ if (category === SidebarCategory.Settings) {
+ return (
+
+ );
+ }
+ if (category === SidebarCategory.GameCenter) {
+ const canRenderQuestPanel =
+ isLoggedIn && loadedSettings && !optOutQuestSystem;
+ return (
+
+ );
+ }
+ if (category === SidebarCategory.Profile) {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+ };
+
+ const renderSelectedSection = (): ReactElement =>
+ renderCategorySection(selectedCategory);
+
+ const selectedLabel = sidebarCategories.find(
+ (category) => category.id === selectedCategory,
+ )?.label;
+ const isSettingsSelected = selectedCategory === SidebarCategory.Settings;
+ const isHomePanel = selectedCategory === SidebarCategory.Main;
+ const isSquadsPanel = selectedCategory === SidebarCategory.Squads;
+ const isUtilityPanelSelected = !isHomePanel;
+ const utilityPanelTitle = isSettingsSelected
+ ? 'Settings'
+ : selectedLabel ?? '';
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {shortcutKeys.map((key) => (
+
+ {key}
+
+ ))}
+
+
+
+
+
+ {sidebarCategories.map((category) => {
+ if (category.id === SidebarCategory.Profile && !isLoggedIn) {
+ return null;
+ }
+
+ const isSelected = selectedCategory === category.id;
+
+ return (
+
+ {category.id === SidebarCategory.Saved && isLoggedIn && (
+
+ )}
+
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ Slide-between-anchors toggle button. Two positions:
+ - Open (panel right edge): ghost, no border/bg/shadow
+ - Closed (rail/panel boundary): bordered chip with shadow
+ Same SidebarArrowLeft glyph, rotated 180° when closed.
+ */}
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/shared/src/components/sidebar/SidebarHeaderStats.tsx b/packages/shared/src/components/sidebar/SidebarHeaderStats.tsx
new file mode 100644
index 00000000000..72a3fd8b2fe
--- /dev/null
+++ b/packages/shared/src/components/sidebar/SidebarHeaderStats.tsx
@@ -0,0 +1,236 @@
+import type { MouseEvent, ReactElement, ReactNode } from 'react';
+import React, { useCallback, useState } from 'react';
+import classNames from 'classnames';
+import { useAuthContext } from '../../contexts/AuthContext';
+import { useReadingStreak } from '../../hooks/streaks';
+import { useLogContext } from '../../contexts/LogContext';
+import { walletUrl, isTesting } from '../../lib/constants';
+import { largeNumberFormat } from '../../lib';
+import { formatCurrency } from '../../lib/utils';
+import { LogEvent } from '../../lib/log';
+import Link from '../utilities/Link';
+import { Tooltip } from '../tooltip/Tooltip';
+import { SimpleTooltip } from '../tooltips';
+import type { TooltipProps } from '../tooltips/BaseTooltip';
+import { IconSize } from '../Icon';
+import { CoreIcon, ReadingStreakIcon, ReputationIcon } from '../icons';
+import { Typography, TypographyType } from '../typography/Typography';
+import { ReadingStreakPopup } from '../streak/popup/ReadingStreakPopup';
+import type { UserStreak } from '../../graphql/users';
+
+const slotClass =
+ 'focus-outline group flex flex-1 items-center justify-center gap-1 px-1.5 py-1.5 transition-colors hover:bg-surface-hover min-w-0';
+const valueClass = 'text-text-primary tabular-nums';
+const iconBoxClass = 'flex size-4 shrink-0 items-center justify-center';
+
+type StatSlotProps = {
+ ariaLabel: string;
+ icon: ReactNode;
+ value: string | number | null;
+ href?: string;
+ onClick?: (event: MouseEvent) => void;
+ id?: string;
+};
+
+const StatSlot = ({
+ ariaLabel,
+ icon,
+ value,
+ href,
+ onClick,
+ id,
+}: StatSlotProps): ReactElement => {
+ const inner = (
+ <>
+ {icon}
+
+ {value}
+
+ >
+ );
+
+ if (onClick) {
+ return (
+
+ {inner}
+
+ );
+ }
+
+ if (!href) {
+ return (
+
+ {inner}
+
+ );
+ }
+
+ return (
+
+
+ {inner}
+
+
+ );
+};
+
+const dividerClass = 'w-px self-stretch bg-border-subtlest-quaternary';
+
+type StreakPopupTooltipProps = Pick & {
+ streak: UserStreak;
+ shouldShowStreaks: boolean;
+ onClose: () => void;
+};
+
+const StreakPopupTooltip = ({
+ children,
+ streak,
+ shouldShowStreaks,
+ onClose,
+}: StreakPopupTooltipProps): ReactElement => (
+ document.body}
+ zIndex={1000}
+ offset={[0, 8]}
+ container={{
+ paddingClassName: 'p-0',
+ bgClassName: 'bg-accent-pepper-subtlest',
+ textClassName: 'text-text-primary typo-callout',
+ className: 'border border-border-subtlest-tertiary rounded-16',
+ }}
+ content={ }
+ onClickOutside={onClose}
+ >
+ {children}
+
+);
+
+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 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 streakSlot = (
+