From 219fa87b506706fea10802eba2391b8ae10fe88f Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Apr 2026 21:29:36 +0300 Subject: [PATCH 01/20] feat: add Make this tab yours customizer sidebar Introduces a right-side customizer panel on the extension new tab that auto-opens once for new users (gated by the `newtab_customizer` feature flag + a one-time `DismissedNewTabCustomizer` action) and is reachable afterwards via a collapsed rail. It lets users tune appearance (theme, layout, density), toggle shortcuts and open the custom-links editor, and manage new-tab widgets (streak, levels, quests, Do Not Disturb). Includes keyboard (Esc) + ARIA support, impression/click/dismiss telemetry under `TargetType.CustomizeNewTab`, a Jest spec and a Storybook story. Made-with: Cursor --- .../extension/src/newtab/MainFeedPage.tsx | 109 ++++++----- .../CustomizeNewTabSidebar.spec.tsx | 77 ++++++++ .../CustomizeNewTabSidebar.tsx | 178 ++++++++++++++++++ .../components/SidebarSection.tsx | 39 ++++ .../components/SidebarSwitch.tsx | 57 ++++++ .../sections/AppearanceSection.tsx | 123 ++++++++++++ .../sections/ShortcutsSection.tsx | 84 +++++++++ .../sections/WidgetsSection.tsx | 138 ++++++++++++++ .../customizeNewTab/useCustomizeNewTab.ts | 73 +++++++ packages/shared/src/graphql/actions.ts | 1 + packages/shared/src/lib/featureManagement.ts | 2 + packages/shared/src/lib/log.ts | 1 + .../CustomizeNewTabSidebar.stories.tsx | 59 ++++++ 13 files changed, 894 insertions(+), 47 deletions(-) create mode 100644 packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.spec.tsx create mode 100644 packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.tsx create mode 100644 packages/shared/src/features/customizeNewTab/components/SidebarSection.tsx create mode 100644 packages/shared/src/features/customizeNewTab/components/SidebarSwitch.tsx create mode 100644 packages/shared/src/features/customizeNewTab/sections/AppearanceSection.tsx create mode 100644 packages/shared/src/features/customizeNewTab/sections/ShortcutsSection.tsx create mode 100644 packages/shared/src/features/customizeNewTab/sections/WidgetsSection.tsx create mode 100644 packages/shared/src/features/customizeNewTab/useCustomizeNewTab.ts create mode 100644 packages/storybook/stories/features/customizeNewTab/CustomizeNewTabSidebar.stories.tsx diff --git a/packages/extension/src/newtab/MainFeedPage.tsx b/packages/extension/src/newtab/MainFeedPage.tsx index fd29b9f71fa..52c98132b72 100644 --- a/packages/extension/src/newtab/MainFeedPage.tsx +++ b/packages/extension/src/newtab/MainFeedPage.tsx @@ -19,6 +19,11 @@ import { useFeedLayout } from '@dailydotdev/shared/src/hooks'; import { useDndContext } from '@dailydotdev/shared/src/contexts/DndContext'; import { FeedLayoutProvider } from '@dailydotdev/shared/src/contexts/FeedContext'; import useCustomDefaultFeed from '@dailydotdev/shared/src/hooks/feed/useCustomDefaultFeed'; +import { + CustomizeNewTabSidebar, + CUSTOMIZE_NEW_TAB_PANEL_WIDTH_PX, +} from '@dailydotdev/shared/src/features/customizeNewTab/CustomizeNewTabSidebar'; +import { useCustomizeNewTab } from '@dailydotdev/shared/src/features/customizeNewTab/useCustomizeNewTab'; import ShortcutLinks from './ShortcutLinks/ShortcutLinks'; import DndBanner from './DndBanner'; import { CompanionPopupButton } from '../companion/CompanionPopupButton'; @@ -76,6 +81,10 @@ export default function MainFeedPage({ useCompanionSettings(); const { isActive: isDndActive, showDnd, setShowDnd } = useDndContext(); const { isCustomDefaultFeed } = useCustomDefaultFeed(); + const customizer = useCustomizeNewTab(); + const customizerOffset = customizer.isOpen + ? `${CUSTOMIZE_NEW_TAB_PANEL_WIDTH_PX}px` + : '0px'; useLayoutEffect(() => { if (!initialPage || !shouldInitializeCurrentPage) { @@ -135,55 +144,61 @@ export default function MainFeedPage({ return ( <> -
- -
- } - additionalButtons={!loadingUser && } +
- - { - logEvent({ - event_name: LogEvent.SubmitSearch, - extra: JSON.stringify({ - query, - provider: SearchProviderEnum.Posts, - ...extraFlags, - }), - }); - - setSearchQuery(query); - }} - onFocus={() => { - logEvent({ event_name: LogEvent.FocusSearch }); - }} - /> - } - shortcuts={ - shortcuts ?? ( - + +
+ } + additionalButtons={!loadingUser && } + > + + { + logEvent({ + event_name: LogEvent.SubmitSearch, + extra: JSON.stringify({ + query, + provider: SearchProviderEnum.Posts, + ...extraFlags, + }), + }); + + setSearchQuery(query); + }} + onFocus={() => { + logEvent({ event_name: LogEvent.FocusSearch }); + }} /> - ) - } - /> - - setShowDnd(false)} /> - + } + shortcuts={ + shortcuts ?? ( + + ) + } + /> + + setShowDnd(false)} /> +
+ + ); } diff --git a/packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.spec.tsx b/packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.spec.tsx new file mode 100644 index 00000000000..4435fcb3a5f --- /dev/null +++ b/packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.spec.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { CustomizeNewTabSidebar } from './CustomizeNewTabSidebar'; +import type { UseCustomizeNewTab } from './useCustomizeNewTab'; +import { DndContextProvider } from '../../contexts/DndContext'; +import { ShortcutsProvider } from '../shortcuts/contexts/ShortcutsProvider'; + +const renderSidebar = ( + overrides: Partial = {}, + settings = {}, +) => { + const close = jest.fn(); + const open = jest.fn(); + const customizer: UseCustomizeNewTab = { + shouldRender: true, + isOpen: true, + open, + close, + isFlagLoading: false, + ...overrides, + }; + + const client = new QueryClient(); + const utils = render( + + + + + + + , + ); + + return { ...utils, open, close }; +}; + +describe('CustomizeNewTabSidebar', () => { + it('renders nothing when shouldRender is false', () => { + const { container } = renderSidebar({ shouldRender: false }); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders all three sections when open', () => { + renderSidebar(); + expect(screen.getByText('Make this tab yours')).toBeInTheDocument(); + expect(screen.getByText('Appearance')).toBeInTheDocument(); + expect(screen.getByText('Shortcuts')).toBeInTheDocument(); + expect(screen.getByText('New tab widgets')).toBeInTheDocument(); + }); + + it('calls close with via="done" when Done is clicked', () => { + const { close } = renderSidebar(); + fireEvent.click(screen.getByRole('button', { name: 'Done' })); + expect(close).toHaveBeenCalledWith('done'); + }); + + it('calls close with via="x" when the X button is clicked', () => { + const { close } = renderSidebar(); + fireEvent.click(screen.getByRole('button', { name: /close/i })); + expect(close).toHaveBeenCalledWith('x'); + }); + + it('calls close with via="esc" when Escape is pressed', () => { + const { close } = renderSidebar(); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(close).toHaveBeenCalledWith('esc'); + }); + + it('shows the rail (and not the panel title) when closed', () => { + renderSidebar({ isOpen: false }); + expect(screen.getByTitle('Customize new tab')).toBeInTheDocument(); + const panel = screen.getByLabelText('Make this tab yours'); + expect(panel).toHaveAttribute('aria-hidden', 'true'); + }); +}); diff --git a/packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.tsx b/packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.tsx new file mode 100644 index 00000000000..6659a8de604 --- /dev/null +++ b/packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.tsx @@ -0,0 +1,178 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useId, useRef } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../components/buttons/Button'; +import { MagicIcon, SettingsIcon } from '../../components/icons'; +import CloseButton from '../../components/CloseButton'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../components/typography/Typography'; +import { useLogContext } from '../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../lib/log'; +import { settingsUrl } from '../../lib/constants'; +import { AppearanceSection } from './sections/AppearanceSection'; +import { ShortcutsSection } from './sections/ShortcutsSection'; +import { WidgetsSection } from './sections/WidgetsSection'; +import type { useCustomizeNewTab } from './useCustomizeNewTab'; + +export const CUSTOMIZE_NEW_TAB_PANEL_WIDTH_PX = 360; +export const CUSTOMIZE_NEW_TAB_RAIL_WIDTH_PX = 40; + +interface CustomizeNewTabSidebarProps { + customizer: ReturnType; +} + +export const CustomizeNewTabSidebar = ({ + customizer, +}: CustomizeNewTabSidebarProps): ReactElement | null => { + const { shouldRender, isOpen, open, close } = customizer; + const { logEvent } = useLogContext(); + const panelId = useId(); + const impressionLoggedRef = useRef(false); + + const handleClose = (via: 'x' | 'esc' | 'done') => { + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.CustomizeNewTab, + target_id: 'dismiss', + extra: JSON.stringify({ via }), + }); + close(via); + }; + + const handleOpen = () => { + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.CustomizeNewTab, + target_id: 'rail_open', + }); + open(); + }; + + useEffect(() => { + if (!isOpen || impressionLoggedRef.current) { + return; + } + impressionLoggedRef.current = true; + logEvent({ + event_name: LogEvent.Impression, + target_type: TargetType.CustomizeNewTab, + extra: JSON.stringify({ + feature_name: 'newtab_customizer', + }), + }); + }, [isOpen, logEvent]); + + useEffect(() => { + if (!isOpen) { + return undefined; + } + const onKey = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return; + } + event.preventDefault(); + handleClose('esc'); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + // handleClose is recreated each render; only re-bind on isOpen changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + if (!shouldRender) { + return null; + } + + return ( + <> + {/* Collapsed rail: always visible when panel is closed */} + {!isOpen && ( + + )} + + {/* Expanded panel */} + + + ); +}; diff --git a/packages/shared/src/features/customizeNewTab/components/SidebarSection.tsx b/packages/shared/src/features/customizeNewTab/components/SidebarSection.tsx new file mode 100644 index 00000000000..087d07a5856 --- /dev/null +++ b/packages/shared/src/features/customizeNewTab/components/SidebarSection.tsx @@ -0,0 +1,39 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../../components/typography/Typography'; + +interface SidebarSectionProps { + title: string; + description?: string; + children: ReactNode; +} + +export const SidebarSection = ({ + title, + description, + children, +}: SidebarSectionProps): ReactElement => { + return ( +
+
+ + {title} + + {description && ( + + {description} + + )} +
+
{children}
+
+ ); +}; diff --git a/packages/shared/src/features/customizeNewTab/components/SidebarSwitch.tsx b/packages/shared/src/features/customizeNewTab/components/SidebarSwitch.tsx new file mode 100644 index 00000000000..c00503eecd9 --- /dev/null +++ b/packages/shared/src/features/customizeNewTab/components/SidebarSwitch.tsx @@ -0,0 +1,57 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { Switch } from '../../../components/fields/Switch'; + +interface SidebarSwitchProps { + name: string; + label: ReactNode; + description?: ReactNode; + checked: boolean; + onToggle: () => void; + className?: string; +} + +export const SidebarSwitch = ({ + name, + label, + description, + checked, + onToggle, + className, +}: SidebarSwitchProps): ReactElement => { + return ( +
+
+ + {label} + + {description && ( + + {description} + + )} +
+ +
+ ); +}; diff --git a/packages/shared/src/features/customizeNewTab/sections/AppearanceSection.tsx b/packages/shared/src/features/customizeNewTab/sections/AppearanceSection.tsx new file mode 100644 index 00000000000..c06f00e5493 --- /dev/null +++ b/packages/shared/src/features/customizeNewTab/sections/AppearanceSection.tsx @@ -0,0 +1,123 @@ +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import { ThemeSection } from '../../../components/ProfileMenu/sections/ThemeSection'; +import { useSettingsContext } from '../../../contexts/SettingsContext'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { + Button, + ButtonGroup, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetId, TargetType } from '../../../lib/log'; +import type { Spaciness } from '../../../graphql/settings'; +import { SidebarSection } from '../components/SidebarSection'; + +const spacinessOptions: { value: Spaciness; label: string }[] = [ + { value: 'eco', label: 'Eco' }, + { value: 'roomy', label: 'Roomy' }, + { value: 'cozy', label: 'Cozy' }, +]; + +interface SegmentedControlRowProps { + label: string; +} + +const SegmentedRow = ({ + label, + children, +}: SegmentedControlRowProps & { children: ReactElement }): ReactElement => ( +
+ + {label} + + {children} +
+); + +export const AppearanceSection = (): ReactElement => { + const { logEvent } = useLogContext(); + const { insaneMode, toggleInsaneMode, spaciness, setSpaciness } = + useSettingsContext(); + + const onLayoutToggle = useCallback( + async (isList: boolean) => { + logEvent({ + event_name: LogEvent.ChangeSettings, + target_type: TargetType.Layout, + target_id: isList ? TargetId.List : TargetId.Cards, + extra: JSON.stringify({ source: TargetType.CustomizeNewTab }), + }); + return toggleInsaneMode(isList); + }, + [logEvent, toggleInsaneMode], + ); + + const onSpacinessChange = useCallback( + (value: Spaciness) => { + logEvent({ + event_name: LogEvent.ChangeSettings, + target_type: TargetType.CustomizeNewTab, + target_id: `spaciness_${value}`, + }); + setSpaciness(value); + }, + [logEvent, setSpaciness], + ); + + return ( + + + + + + + + + + + + + {spacinessOptions.map(({ value, label }) => { + const isActive = value === spaciness; + return ( + + ); + })} + + + + ); +}; diff --git a/packages/shared/src/features/customizeNewTab/sections/ShortcutsSection.tsx b/packages/shared/src/features/customizeNewTab/sections/ShortcutsSection.tsx new file mode 100644 index 00000000000..848a93eafc9 --- /dev/null +++ b/packages/shared/src/features/customizeNewTab/sections/ShortcutsSection.tsx @@ -0,0 +1,84 @@ +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import { useSettingsContext } from '../../../contexts/SettingsContext'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../../lib/log'; +import { useLazyModal } from '../../../hooks/useLazyModal'; +import { LazyModal } from '../../../components/modals/common/types'; +import { useShortcuts } from '../../shortcuts/contexts/ShortcutsProvider'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { SidebarSection } from '../components/SidebarSection'; +import { SidebarSwitch } from '../components/SidebarSwitch'; + +export const ShortcutsSection = (): ReactElement => { + const { logEvent } = useLogContext(); + const { openModal } = useLazyModal(); + const { showTopSites, toggleShowTopSites, customLinks } = + useSettingsContext(); + const { hasCheckedPermission, setShowPermissionsModal } = useShortcuts(); + const hasCustomLinks = (customLinks?.length ?? 0) > 0; + + const onToggle = useCallback(() => { + const nextValue = !showTopSites; + + logEvent({ + event_name: LogEvent.ChangeSettings, + target_type: TargetType.CustomizeNewTab, + target_id: 'show_top_sites', + extra: JSON.stringify({ enabled: nextValue }), + }); + + // Turning ON without prior permission / custom links: route through the + // existing permissions modal so users can pick browser top sites vs custom. + if (nextValue && !hasCheckedPermission && !hasCustomLinks) { + setShowPermissionsModal(true); + return; + } + + toggleShowTopSites(); + }, [ + hasCheckedPermission, + hasCustomLinks, + logEvent, + setShowPermissionsModal, + showTopSites, + toggleShowTopSites, + ]); + + const onEditShortcuts = useCallback(() => { + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.CustomizeNewTab, + target_id: 'edit_shortcuts', + }); + openModal({ type: LazyModal.CustomLinks }); + }, [logEvent, openModal]); + + return ( + + + + + ); +}; diff --git a/packages/shared/src/features/customizeNewTab/sections/WidgetsSection.tsx b/packages/shared/src/features/customizeNewTab/sections/WidgetsSection.tsx new file mode 100644 index 00000000000..16250bf77b4 --- /dev/null +++ b/packages/shared/src/features/customizeNewTab/sections/WidgetsSection.tsx @@ -0,0 +1,138 @@ +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import { useSettingsContext } from '../../../contexts/SettingsContext'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../../lib/log'; +import { useDndContext } from '../../../contexts/DndContext'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; +import { questsFeature } from '../../../lib/featureManagement'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../components/buttons/Button'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import { SidebarSection } from '../components/SidebarSection'; +import { SidebarSwitch } from '../components/SidebarSwitch'; + +interface LoggedToggleArgs { + targetId: string; + next: boolean; + toggle: () => Promise | void; +} + +export const WidgetsSection = (): ReactElement => { + const { logEvent } = useLogContext(); + const { + optOutReadingStreak, + toggleOptOutReadingStreak, + optOutLevelSystem, + toggleOptOutLevelSystem, + optOutQuestSystem, + toggleOptOutQuestSystem, + } = useSettingsContext(); + const { setShowDnd } = useDndContext(); + const { value: isQuestsEnabled } = useConditionalFeature({ + feature: questsFeature, + }); + + const logToggle = useCallback( + ({ targetId, next, toggle }: LoggedToggleArgs) => { + logEvent({ + event_name: LogEvent.ChangeSettings, + target_type: TargetType.CustomizeNewTab, + target_id: targetId, + extra: JSON.stringify({ enabled: next }), + }); + toggle(); + }, + [logEvent], + ); + + const onOpenDnd = useCallback(() => { + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.CustomizeNewTab, + target_id: 'dnd', + }); + setShowDnd(true); + }, [logEvent, setShowDnd]); + + return ( + + + logToggle({ + targetId: 'streak', + next: optOutReadingStreak, + toggle: toggleOptOutReadingStreak, + }) + } + /> + + {isQuestsEnabled && ( + <> + + logToggle({ + targetId: 'levels', + next: optOutLevelSystem, + toggle: toggleOptOutLevelSystem, + }) + } + /> + + logToggle({ + targetId: 'quests', + next: optOutQuestSystem, + toggle: toggleOptOutQuestSystem, + }) + } + /> + + )} + +
+
+ + Do Not Disturb + + + Hide the feed temporarily and stay focused. + +
+ +
+
+ ); +}; diff --git a/packages/shared/src/features/customizeNewTab/useCustomizeNewTab.ts b/packages/shared/src/features/customizeNewTab/useCustomizeNewTab.ts new file mode 100644 index 00000000000..3fd2956281e --- /dev/null +++ b/packages/shared/src/features/customizeNewTab/useCustomizeNewTab.ts @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useActions } from '../../hooks/useActions'; +import { useConditionalFeature } from '../../hooks/useConditionalFeature'; +import { useOnboardingActions } from '../../hooks/auth/useOnboardingActions'; +import { ActionType } from '../../graphql/actions'; +import { featureNewtabCustomizer } from '../../lib/featureManagement'; + +export interface UseCustomizeNewTab { + shouldRender: boolean; + isOpen: boolean; + open: () => void; + close: (via: 'x' | 'esc' | 'done') => void; + isFlagLoading: boolean; +} + +export const useCustomizeNewTab = (): UseCustomizeNewTab => { + const { isOnboardingComplete, isOnboardingActionsReady } = + useOnboardingActions(); + const { checkHasCompleted, completeAction, isActionsFetched } = useActions(); + const hasDismissed = checkHasCompleted(ActionType.DismissedNewTabCustomizer); + + const shouldEvaluate = isActionsFetched && isOnboardingComplete; + const { value: isFlagEnabled, isLoading: isFlagLoading } = + useConditionalFeature({ + feature: featureNewtabCustomizer, + shouldEvaluate, + }); + + const shouldRender = + isOnboardingActionsReady && isOnboardingComplete && !!isFlagEnabled; + + const [isOpen, setIsOpen] = useState(false); + const [hasSyncedInitialOpen, setHasSyncedInitialOpen] = useState(false); + + // Auto-open once for users who haven't dismissed yet. We run this only after + // both actions and flag have resolved so we don't flash the panel. + useEffect(() => { + if (hasSyncedInitialOpen) { + return; + } + if (!shouldRender || isFlagLoading) { + return; + } + setHasSyncedInitialOpen(true); + if (!hasDismissed) { + setIsOpen(true); + } + }, [hasSyncedInitialOpen, shouldRender, isFlagLoading, hasDismissed]); + + const open = useCallback(() => setIsOpen(true), []); + + // via is provided by the shell so it can log a dismiss event with source; + // the hook itself only needs to flip state and record the action once. + const close = useCallback( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (via: 'x' | 'esc' | 'done') => { + setIsOpen(false); + if (!hasDismissed) { + // Fire-and-forget: the action api dedupes on the server. + completeAction(ActionType.DismissedNewTabCustomizer); + } + }, + [completeAction, hasDismissed], + ); + + return { + shouldRender, + isOpen, + open, + close, + isFlagLoading, + }; +}; diff --git a/packages/shared/src/graphql/actions.ts b/packages/shared/src/graphql/actions.ts index 0ce5b75e3db..048e439f5b5 100644 --- a/packages/shared/src/graphql/actions.ts +++ b/packages/shared/src/graphql/actions.ts @@ -62,6 +62,7 @@ export enum ActionType { DismissBriefCard = 'dismiss_brief_card', DigestUpsell = 'digest_upsell', AskUpsellSearch = 'ask_upsell_search', + DismissedNewTabCustomizer = 'dismissed_new_tab_customizer', } export const cvActions = [ diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 08cb456228b..53b1afe11a2 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -158,3 +158,5 @@ export const featureShortcutsExtensionPromo = new Feature( 'shortcuts_extension_promo', false, ); + +export const featureNewtabCustomizer = new Feature('newtab_customizer', false); diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 8d418c39ce3..0854f2a7808 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -482,6 +482,7 @@ export enum TargetType { MarketingOptOut = 'marketing opt out', OnboardingComplete = 'onboarding complete', MobileAppDownload = 'mobile app download', + CustomizeNewTab = 'customize new tab', } export enum TargetId { diff --git a/packages/storybook/stories/features/customizeNewTab/CustomizeNewTabSidebar.stories.tsx b/packages/storybook/stories/features/customizeNewTab/CustomizeNewTabSidebar.stories.tsx new file mode 100644 index 00000000000..3dff922bc6e --- /dev/null +++ b/packages/storybook/stories/features/customizeNewTab/CustomizeNewTabSidebar.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React, { useState } from 'react'; +import { CustomizeNewTabSidebar } from '@dailydotdev/shared/src/features/customizeNewTab/CustomizeNewTabSidebar'; +import type { UseCustomizeNewTab } from '@dailydotdev/shared/src/features/customizeNewTab/useCustomizeNewTab'; +import { DndContextProvider } from '@dailydotdev/shared/src/contexts/DndContext'; +import { ShortcutsProvider } from '@dailydotdev/shared/src/features/shortcuts/contexts/ShortcutsProvider'; +import ExtensionProviders from '../../extension/_providers'; + +const StubbedSidebar = ({ defaultOpen = true }: { defaultOpen?: boolean }) => { + const [isOpen, setIsOpen] = useState(defaultOpen); + const customizer: UseCustomizeNewTab = { + shouldRender: true, + isOpen, + open: () => setIsOpen(true), + close: () => setIsOpen(false), + isFlagLoading: false, + }; + return ; +}; + +const meta: Meta = { + title: 'Features/CustomizeNewTab/Sidebar', + component: StubbedSidebar, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], + render: (args) => ( + + + +
+ +
+
+
+
+ ), +}; + +export default meta; + +type Story = StoryObj; + +export const Open: Story = { + args: { defaultOpen: true }, +}; + +export const Collapsed: Story = { + args: { defaultOpen: false }, +}; + +export const Light: Story = { + args: { defaultOpen: true }, + parameters: { + backgrounds: { default: 'light' }, + theme: 'light', + }, +}; From 0a90ada4af1675ab4bed1969a83c1f6f5cdd6efd Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Apr 2026 21:31:52 +0300 Subject: [PATCH 02/20] feat: only auto-open customizer for users <14 days old Made-with: Cursor --- .../customizeNewTab/useCustomizeNewTab.ts | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/features/customizeNewTab/useCustomizeNewTab.ts b/packages/shared/src/features/customizeNewTab/useCustomizeNewTab.ts index 3fd2956281e..a94ae26f627 100644 --- a/packages/shared/src/features/customizeNewTab/useCustomizeNewTab.ts +++ b/packages/shared/src/features/customizeNewTab/useCustomizeNewTab.ts @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useActions } from '../../hooks/useActions'; +import { useAuthContext } from '../../contexts/AuthContext'; import { useConditionalFeature } from '../../hooks/useConditionalFeature'; import { useOnboardingActions } from '../../hooks/auth/useOnboardingActions'; import { ActionType } from '../../graphql/actions'; @@ -13,7 +14,13 @@ export interface UseCustomizeNewTab { isFlagLoading: boolean; } +// Users whose account was created within this window are considered "new" +// and get the panel opened automatically on first visit. Everyone else can +// still reach it via the collapsed rail. +export const NEW_USER_WINDOW_DAYS = 14; + export const useCustomizeNewTab = (): UseCustomizeNewTab => { + const { user } = useAuthContext(); const { isOnboardingComplete, isOnboardingActionsReady } = useOnboardingActions(); const { checkHasCompleted, completeAction, isActionsFetched } = useActions(); @@ -29,11 +36,20 @@ export const useCustomizeNewTab = (): UseCustomizeNewTab => { const shouldRender = isOnboardingActionsReady && isOnboardingComplete && !!isFlagEnabled; + const isNewUser = useMemo(() => { + if (!user?.createdAt) { + return false; + } + const createdAt = new Date(user.createdAt).getTime(); + const windowMs = NEW_USER_WINDOW_DAYS * 24 * 60 * 60 * 1000; + return Date.now() - createdAt < windowMs; + }, [user?.createdAt]); + const [isOpen, setIsOpen] = useState(false); const [hasSyncedInitialOpen, setHasSyncedInitialOpen] = useState(false); - // Auto-open once for users who haven't dismissed yet. We run this only after - // both actions and flag have resolved so we don't flash the panel. + // Auto-open once on first visit for new users who haven't dismissed yet. + // Existing users will only see the collapsed rail until they open it. useEffect(() => { if (hasSyncedInitialOpen) { return; @@ -42,10 +58,16 @@ export const useCustomizeNewTab = (): UseCustomizeNewTab => { return; } setHasSyncedInitialOpen(true); - if (!hasDismissed) { + if (isNewUser && !hasDismissed) { setIsOpen(true); } - }, [hasSyncedInitialOpen, shouldRender, isFlagLoading, hasDismissed]); + }, [ + hasSyncedInitialOpen, + shouldRender, + isFlagLoading, + hasDismissed, + isNewUser, + ]); const open = useCallback(() => setIsOpen(true), []); From 006906b24018696624a74dc60f59711849e4c102 Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Apr 2026 21:36:00 +0300 Subject: [PATCH 03/20] feat: show floating Customize button for all users Swaps the hidden right-edge rail for a floating bottom-right "Customize" pill (Chrome-style) so every logged-in user who finished onboarding can open the sidebar manually. Removes the GrowthBook flag from the render gate so the entry point is always visible; the flag-driven kill switch can be wired back in later if needed. Also drops the laptop-only breakpoint on the panel so it opens on narrower widths too. Made-with: Cursor --- .../CustomizeNewTabSidebar.spec.tsx | 1 - .../CustomizeNewTabSidebar.tsx | 21 +++++------ .../customizeNewTab/useCustomizeNewTab.ts | 36 +++++-------------- .../CustomizeNewTabSidebar.stories.tsx | 1 - 4 files changed, 18 insertions(+), 41 deletions(-) diff --git a/packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.spec.tsx b/packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.spec.tsx index 4435fcb3a5f..b51eda33aa3 100644 --- a/packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.spec.tsx +++ b/packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.spec.tsx @@ -18,7 +18,6 @@ const renderSidebar = ( isOpen: true, open, close, - isFlagLoading: false, ...overrides, }; diff --git a/packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.tsx b/packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.tsx index 6659a8de604..61920fcf5d2 100644 --- a/packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.tsx +++ b/packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.tsx @@ -92,24 +92,21 @@ export const CustomizeNewTabSidebar = ({ return ( <> - {/* Collapsed rail: always visible when panel is closed */} + {/* Floating "Customize" pill: always visible when panel is closed. */} {!isOpen && ( - + Customize + )} {/* Expanded panel */} @@ -118,7 +115,7 @@ export const CustomizeNewTabSidebar = ({ aria-label="Make this tab yours" aria-hidden={!isOpen} className={classNames( - 'fixed right-0 top-0 z-modal hidden h-screen flex-col border-l border-border-subtlest-tertiary bg-background-default shadow-2 transition-transform duration-200 ease-in-out laptop:flex', + 'fixed right-0 top-0 z-modal flex h-screen max-w-[100vw] flex-col border-l border-border-subtlest-tertiary bg-background-default shadow-2 transition-transform duration-200 ease-in-out', isOpen ? 'translate-x-0' : 'translate-x-full', )} style={{ width: CUSTOMIZE_NEW_TAB_PANEL_WIDTH_PX }} diff --git a/packages/shared/src/features/customizeNewTab/useCustomizeNewTab.ts b/packages/shared/src/features/customizeNewTab/useCustomizeNewTab.ts index a94ae26f627..6586b7daf9d 100644 --- a/packages/shared/src/features/customizeNewTab/useCustomizeNewTab.ts +++ b/packages/shared/src/features/customizeNewTab/useCustomizeNewTab.ts @@ -1,40 +1,32 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useActions } from '../../hooks/useActions'; import { useAuthContext } from '../../contexts/AuthContext'; -import { useConditionalFeature } from '../../hooks/useConditionalFeature'; import { useOnboardingActions } from '../../hooks/auth/useOnboardingActions'; import { ActionType } from '../../graphql/actions'; -import { featureNewtabCustomizer } from '../../lib/featureManagement'; export interface UseCustomizeNewTab { shouldRender: boolean; isOpen: boolean; open: () => void; close: (via: 'x' | 'esc' | 'done') => void; - isFlagLoading: boolean; } // Users whose account was created within this window are considered "new" // and get the panel opened automatically on first visit. Everyone else can -// still reach it via the collapsed rail. +// still reach it via the floating Customize button. export const NEW_USER_WINDOW_DAYS = 14; export const useCustomizeNewTab = (): UseCustomizeNewTab => { const { user } = useAuthContext(); const { isOnboardingComplete, isOnboardingActionsReady } = useOnboardingActions(); - const { checkHasCompleted, completeAction, isActionsFetched } = useActions(); + const { checkHasCompleted, completeAction } = useActions(); const hasDismissed = checkHasCompleted(ActionType.DismissedNewTabCustomizer); - const shouldEvaluate = isActionsFetched && isOnboardingComplete; - const { value: isFlagEnabled, isLoading: isFlagLoading } = - useConditionalFeature({ - feature: featureNewtabCustomizer, - shouldEvaluate, - }); - - const shouldRender = - isOnboardingActionsReady && isOnboardingComplete && !!isFlagEnabled; + // The button is visible to any logged-in user who has finished onboarding. + // Gating further (e.g. on a feature flag) would hide it from everyone by + // default, which isn't what we want. + const shouldRender = isOnboardingActionsReady && isOnboardingComplete; const isNewUser = useMemo(() => { if (!user?.createdAt) { @@ -49,25 +41,16 @@ export const useCustomizeNewTab = (): UseCustomizeNewTab => { const [hasSyncedInitialOpen, setHasSyncedInitialOpen] = useState(false); // Auto-open once on first visit for new users who haven't dismissed yet. - // Existing users will only see the collapsed rail until they open it. + // Existing users will only see the floating button until they open it. useEffect(() => { - if (hasSyncedInitialOpen) { - return; - } - if (!shouldRender || isFlagLoading) { + if (hasSyncedInitialOpen || !shouldRender) { return; } setHasSyncedInitialOpen(true); if (isNewUser && !hasDismissed) { setIsOpen(true); } - }, [ - hasSyncedInitialOpen, - shouldRender, - isFlagLoading, - hasDismissed, - isNewUser, - ]); + }, [hasSyncedInitialOpen, shouldRender, hasDismissed, isNewUser]); const open = useCallback(() => setIsOpen(true), []); @@ -90,6 +73,5 @@ export const useCustomizeNewTab = (): UseCustomizeNewTab => { isOpen, open, close, - isFlagLoading, }; }; diff --git a/packages/storybook/stories/features/customizeNewTab/CustomizeNewTabSidebar.stories.tsx b/packages/storybook/stories/features/customizeNewTab/CustomizeNewTabSidebar.stories.tsx index 3dff922bc6e..35a131dcd7d 100644 --- a/packages/storybook/stories/features/customizeNewTab/CustomizeNewTabSidebar.stories.tsx +++ b/packages/storybook/stories/features/customizeNewTab/CustomizeNewTabSidebar.stories.tsx @@ -13,7 +13,6 @@ const StubbedSidebar = ({ defaultOpen = true }: { defaultOpen?: boolean }) => { isOpen, open: () => setIsOpen(true), close: () => setIsOpen(false), - isFlagLoading: false, }; return ; }; From e4dae80bc353e72595e1163af40c3db56a375cbc Mon Sep 17 00:00:00 2001 From: Tsahi Matsliah Date: Thu, 23 Apr 2026 21:48:01 +0300 Subject: [PATCH 04/20] feat(newtab-customizer): sync header/feedback/feed with sidebar - Stack the Customize pill above the Feedback button (right-4, bottom-20) so they never overlap. - Global rightSidebarOffset atom; Header, FeedbackWidget and FeedLayoutProvider all react to it so the top bar shrinks, the Feedback button shifts, and the feed drops a column (same behaviour as the left sidebar). - Make the close affordance an explicit icon-only Tertiary Button in the panel header. - Drop the "Open full settings" footer link and the Density segmented control from Appearance. Made-with: Cursor --- .../components/feedback/FeedbackWidget.tsx | 8 +++- .../components/layout/MainLayoutHeader.tsx | 11 ++++- packages/shared/src/contexts/FeedContext.tsx | 45 +++++++++++++---- .../CustomizeNewTabSidebar.tsx | 48 +++++++++++-------- .../sections/AppearanceSection.tsx | 43 +---------------- .../store/rightSidebar.store.ts | 14 ++++++ 6 files changed, 96 insertions(+), 73 deletions(-) create mode 100644 packages/shared/src/features/customizeNewTab/store/rightSidebar.store.ts diff --git a/packages/shared/src/components/feedback/FeedbackWidget.tsx b/packages/shared/src/components/feedback/FeedbackWidget.tsx index a5ea9d49351..34c504ad80b 100644 --- a/packages/shared/src/components/feedback/FeedbackWidget.tsx +++ b/packages/shared/src/components/feedback/FeedbackWidget.tsx @@ -7,12 +7,14 @@ import { useSettingsContext } from '../../contexts/SettingsContext'; import { useViewSize, ViewSize } from '../../hooks/useViewSize'; import { useLazyModal } from '../../hooks/useLazyModal'; import { LazyModal } from '../modals/common/types'; +import { useRightSidebarOffset } from '../../features/customizeNewTab/store/rightSidebar.store'; export function FeedbackWidget(): ReactElement | null { const { user } = useAuthContext(); const { showFeedbackButton } = useSettingsContext(); const isMobile = useViewSize(ViewSize.MobileL); const { openModal } = useLazyModal(); + const rightSidebarOffset = useRightSidebarOffset(); // Only show for authenticated users on desktop when setting is enabled // Mobile feedback is handled by FooterPlusButton @@ -25,7 +27,11 @@ export function FeedbackWidget(): ReactElement | null { variant={ButtonVariant.Primary} size={ButtonSize.Medium} icon={} - className="fixed bottom-4 right-4 z-max shadow-2" + className="fixed bottom-4 z-max shadow-2" + style={{ + right: `calc(1rem + ${rightSidebarOffset}px)`, + transition: 'right 200ms ease-in-out', + }} onClick={() => openModal({ type: LazyModal.Feedback })} aria-label="Send feedback" > diff --git a/packages/shared/src/components/layout/MainLayoutHeader.tsx b/packages/shared/src/components/layout/MainLayoutHeader.tsx index d6088ed1f91..e643152a9a2 100644 --- a/packages/shared/src/components/layout/MainLayoutHeader.tsx +++ b/packages/shared/src/components/layout/MainLayoutHeader.tsx @@ -14,6 +14,7 @@ import { useFeedName } from '../../hooks/feed/useFeedName'; import FeedNav from '../feeds/FeedNav'; import { MobileExploreHeader } from '../header/MobileExploreHeader'; import useActiveNav from '../../hooks/useActiveNav'; +import { useRightSidebarOffset } from '../../features/customizeNewTab/store/rightSidebar.store'; export interface MainLayoutHeaderProps { hasBanner?: boolean; @@ -52,6 +53,7 @@ function MainLayoutHeader({ const isSearchPage = isSearch || isAnyExplore; const featureTheme = useFeatureTheme(); const scrollClassName = useScrollTopClassName({ enabled: !!featureTheme }); + const rightSidebarOffset = useRightSidebarOffset(); const { profile } = useActiveNav(feedName); const shouldUseLoadedSettings = loadedSettings && hasHydrated; const isMobileProfile = profile && !isLaptop; @@ -105,7 +107,14 @@ function MainLayoutHeader({ !isMobileSearchPage && isSearchPage && 'mb-16 laptop:mb-0', !isMobileSearchPage && scrollClassName, )} - style={featureTheme ? featureTheme.navbar : undefined} + style={{ + ...(featureTheme ? featureTheme.navbar : undefined), + right: rightSidebarOffset, + width: rightSidebarOffset + ? `calc(100% - ${rightSidebarOffset}px)` + : undefined, + transition: 'right 200ms ease-in-out, width 200ms ease-in-out', + }} > {isMobileSearchPage ? ( <> diff --git a/packages/shared/src/contexts/FeedContext.tsx b/packages/shared/src/contexts/FeedContext.tsx index a37ceda1432..2d7a5a0bcd4 100644 --- a/packages/shared/src/contexts/FeedContext.tsx +++ b/packages/shared/src/contexts/FeedContext.tsx @@ -4,6 +4,7 @@ import { desktop, laptop, laptopL, laptopXL, tablet } from '../styles/media'; import { useConditionalFeature, useMedia, usePlusSubscription } from '../hooks'; import { useSettingsContext } from './SettingsContext'; import useSidebarRendered from '../hooks/useSidebarRendered'; +import { useRightSidebarOffset } from '../features/customizeNewTab/store/rightSidebar.store'; import type { Spaciness } from '../graphql/settings'; import { featureFeedAdTemplate } from '../lib/featureManagement'; @@ -117,6 +118,7 @@ export function FeedLayoutProvider({ const { sidebarExpanded } = useSettingsContext(); const { sidebarRendered } = useSidebarRendered(); const { isPlus } = usePlusSubscription(); + const rightSidebarOffset = useRightSidebarOffset(); const feedAdTemplateFeature = useConditionalFeature({ feature: featureFeedAdTemplate, shouldEvaluate: !isPlus, @@ -127,6 +129,11 @@ export function FeedLayoutProvider({ const [debouncedSidebarExpanded, setDebouncedSidebarExpanded] = useState(sidebarExpanded); + // Same debounce applied to the right-side panel's offset so the feed does + // not re-layout in the middle of the slide-in animation. + const [debouncedRightOffset, setDebouncedRightOffset] = + useState(rightSidebarOffset); + useEffect(() => { const timer = setTimeout(() => { setDebouncedSidebarExpanded(sidebarExpanded); @@ -135,6 +142,14 @@ export function FeedLayoutProvider({ return () => clearTimeout(timer); }, [sidebarExpanded]); + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedRightOffset(rightSidebarOffset); + }, SIDEBAR_TRANSITION_DURATION); + + return () => clearTimeout(timer); + }, [rightSidebarOffset]); + const { feedSettings, defaultFeedSettings } = useMemo(() => { const enhancedFeedSettings = Object.entries(baseFeedSettings).reduce( (acc, [feedSettingsKey, feedSettingsValue]) => { @@ -162,27 +177,37 @@ export function FeedLayoutProvider({ }; }, [feedAdTemplateFeature.value]); - // Generate the breakpoints for the feed settings - // Uses debounced sidebar state to sync layout change with sidebar animation + // Generate the breakpoints for the feed settings. + // Uses debounced sidebar state to sync layout change with sidebar animation, + // and also shifts breakpoints right by any active right-side panel (e.g. the + // customize new tab sidebar) so the feed drops a column while it's open. const feedBreakpoints = useMemo(() => { const breakpoints = feedSettings.map((setting) => setting.breakpoint.replace('@media ', ''), ); - if (!sidebarRendered) { - return breakpoints; + let leftOffset = 0; + if (sidebarRendered) { + leftOffset = debouncedSidebarExpanded + ? sidebarOpenWidth + : sidebarRenderedWidth; } - if (debouncedSidebarExpanded) { - return breakpoints.map((breakpoint) => - replaceDigitsWithIncrement(breakpoint, sidebarOpenWidth), - ); + const totalOffset = leftOffset + debouncedRightOffset; + + if (totalOffset === 0) { + return breakpoints; } return breakpoints.map((breakpoint) => - replaceDigitsWithIncrement(breakpoint, sidebarRenderedWidth), + replaceDigitsWithIncrement(breakpoint, totalOffset), ); - }, [feedSettings, debouncedSidebarExpanded, sidebarRendered]); + }, [ + feedSettings, + debouncedSidebarExpanded, + sidebarRendered, + debouncedRightOffset, + ]); const currentSettings = useMedia( feedBreakpoints, diff --git a/packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.tsx b/packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.tsx index 61920fcf5d2..6409f61aab6 100644 --- a/packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.tsx +++ b/packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.tsx @@ -6,8 +6,7 @@ import { ButtonSize, ButtonVariant, } from '../../components/buttons/Button'; -import { MagicIcon, SettingsIcon } from '../../components/icons'; -import CloseButton from '../../components/CloseButton'; +import { MagicIcon, MiniCloseIcon } from '../../components/icons'; import { Typography, TypographyColor, @@ -15,14 +14,14 @@ import { } from '../../components/typography/Typography'; import { useLogContext } from '../../contexts/LogContext'; import { LogEvent, TargetType } from '../../lib/log'; -import { settingsUrl } from '../../lib/constants'; +import { useSettingsContext } from '../../contexts/SettingsContext'; import { AppearanceSection } from './sections/AppearanceSection'; import { ShortcutsSection } from './sections/ShortcutsSection'; import { WidgetsSection } from './sections/WidgetsSection'; +import { useSetRightSidebarOffset } from './store/rightSidebar.store'; import type { useCustomizeNewTab } from './useCustomizeNewTab'; export const CUSTOMIZE_NEW_TAB_PANEL_WIDTH_PX = 360; -export const CUSTOMIZE_NEW_TAB_RAIL_WIDTH_PX = 40; interface CustomizeNewTabSidebarProps { customizer: ReturnType; @@ -33,6 +32,8 @@ export const CustomizeNewTabSidebar = ({ }: CustomizeNewTabSidebarProps): ReactElement | null => { const { shouldRender, isOpen, open, close } = customizer; const { logEvent } = useLogContext(); + const { showFeedbackButton } = useSettingsContext(); + const setRightSidebarOffset = useSetRightSidebarOffset(); const panelId = useId(); const impressionLoggedRef = useRef(false); @@ -55,6 +56,15 @@ export const CustomizeNewTabSidebar = ({ open(); }; + // Expose the panel width as a global offset so the fixed header, feedback + // button and feed layout all shift/reshape in sync with the panel. + useEffect(() => { + setRightSidebarOffset( + shouldRender && isOpen ? CUSTOMIZE_NEW_TAB_PANEL_WIDTH_PX : 0, + ); + return () => setRightSidebarOffset(0); + }, [shouldRender, isOpen, setRightSidebarOffset]); + useEffect(() => { if (!isOpen || impressionLoggedRef.current) { return; @@ -90,6 +100,11 @@ export const CustomizeNewTabSidebar = ({ return null; } + // Stack the Customize pill above the Feedback pill (which sits at bottom-4) + // so they never occlude each other. When feedback is disabled we drop the + // gap so Customize lives at the normal bottom-4 position. + const customizeBottomClass = showFeedbackButton ? 'bottom-20' : 'bottom-4'; + return ( <> {/* Floating "Customize" pill: always visible when panel is closed. */} @@ -103,7 +118,10 @@ export const CustomizeNewTabSidebar = ({ size={ButtonSize.Medium} icon={} title="Customize new tab" - className="fixed bottom-6 right-6 z-modal shadow-2" + className={classNames( + 'fixed right-4 z-max shadow-2', + customizeBottomClass, + )} > Customize @@ -134,9 +152,13 @@ export const CustomizeNewTabSidebar = ({ Settings. - } + aria-label="Close customize sidebar" + title="Close" onClick={() => handleClose('x')} /> @@ -147,19 +169,7 @@ export const CustomizeNewTabSidebar = ({ -