diff --git a/packages/extension/src/newtab/DndBanner.tsx b/packages/extension/src/newtab/DndBanner.tsx index 12135fc0b13..06d93e00e1d 100644 --- a/packages/extension/src/newtab/DndBanner.tsx +++ b/packages/extension/src/newtab/DndBanner.tsx @@ -7,11 +7,16 @@ import { } from '@dailydotdev/shared/src/components/buttons/Button'; import { MiniCloseIcon as XIcon } from '@dailydotdev/shared/src/components/icons'; import { useDndContext } from '@dailydotdev/shared/src/contexts/DndContext'; +import { useFocusSchedule } from '@dailydotdev/shared/src/features/newTab/store/focusSchedule.store'; export default function DndBanner(): ReactElement { const { onDndSettings } = useDndContext(); + const { pauseFor } = useFocusSchedule(); - const turnOff = () => onDndSettings(null); + const turnOff = () => { + pauseFor(null); + onDndSettings(null); + }; return (
diff --git a/packages/extension/src/newtab/MainFeedPage.tsx b/packages/extension/src/newtab/MainFeedPage.tsx index fd29b9f71fa..36a728328dc 100644 --- a/packages/extension/src/newtab/MainFeedPage.tsx +++ b/packages/extension/src/newtab/MainFeedPage.tsx @@ -6,12 +6,13 @@ import React, { useMemo, useState, } from 'react'; +import classNames from 'classnames'; import MainLayout from '@dailydotdev/shared/src/components/MainLayout'; -import MainFeedLayout from '@dailydotdev/shared/src/components/MainFeedLayout'; import ScrollToTopButton from '@dailydotdev/shared/src/components/ScrollToTopButton'; import { getShouldRedirect } from '@dailydotdev/shared/src/components/utilities'; import dynamic from 'next/dynamic'; import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import { useSettingsContext } from '@dailydotdev/shared/src/contexts/SettingsContext'; import { SearchProviderEnum } from '@dailydotdev/shared/src/graphql/search'; import { LogEvent } from '@dailydotdev/shared/src/lib/log'; import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; @@ -19,6 +20,15 @@ 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 { + useCustomizerFirstSession, + useRightSidebarOffset, +} from '@dailydotdev/shared/src/features/customizeNewTab/store/rightSidebar.store'; import ShortcutLinks from './ShortcutLinks/ShortcutLinks'; import DndBanner from './DndBanner'; import { CompanionPopupButton } from '../companion/CompanionPopupButton'; @@ -31,6 +41,13 @@ const PostsSearch = dynamic( ), ); +const MainFeedLayout = dynamic( + () => + import( + /* webpackChunkName: "mainFeedLayout" */ '@dailydotdev/shared/src/components/MainFeedLayout' + ), +); + const DndModal = dynamic( () => import(/* webpackChunkName: "dndModal" */ './DndModal'), ); @@ -68,6 +85,7 @@ export default function MainFeedPage({ const { logEvent } = useLogContext(); const [isSearchOn, setIsSearchOn] = useState(false); const { user, loadingUser } = useContext(AuthContext); + const { optOutCompanion, showFeedbackButton } = useSettingsContext(); const [feedName, setFeedName] = useState(() => getInitialFeedName(initialPage), ); @@ -76,6 +94,23 @@ 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'; + // Same source the header & feedback pill read so any fixed control on the + // new tab can stay clear of the open panel without recomputing widths. + const rightSidebarOffset = useRightSidebarOffset(); + // Mirror `FeedbackWidget`'s short-circuit during first-session + // onboarding so the scroll-to-top wrapper drops to the bottom rail + // instead of leaving a gap where the (now hidden) Feedback pill would + // have been. + const isCustomizerFirstSession = useCustomizerFirstSession(); + const isFeedbackButtonRendered = + showFeedbackButton && !isCustomizerFirstSession; + const shortcutsSlot = shortcuts ?? ( + + ); useLayoutEffect(() => { if (!initialPage || !shouldInitializeCurrentPage) { @@ -135,55 +170,87 @@ 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 ?? ( - - ) - } + {/* Park the back-to-top icon just above the Feedback pill (when + visible) so they share the right rail without overlapping. With + no Feedback pill — either disabled in settings or hidden during + first-session onboarding — the icon drops to the corner instead + of floating over a gap. The inline `right` slides the wrapper + out from under the customizer panel when it opens, mirroring + `FeedbackWidget`. The transition is gated on + `hasSettledInitialOpen` for the same reason as the outer + wrapper above. */} +
+ - - setShowDnd(false)} /> - +
+ } + additionalButtons={ + !loadingUser && !optOutCompanion && + } + > + + { + logEvent({ + event_name: LogEvent.SubmitSearch, + extra: JSON.stringify({ + query, + provider: SearchProviderEnum.Posts, + ...extraFlags, + }), + }); + + setSearchQuery(query); + }} + onFocus={() => { + logEvent({ event_name: LogEvent.FocusSearch }); + }} + /> + } + shortcuts={shortcutsSlot} + /> + + setShowDnd(false)} /> + +
+ ); } diff --git a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx index 4b857c7f4f0..1997480b065 100644 --- a/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx +++ b/packages/extension/src/newtab/ShortcutLinks/ShortcutLinks.spec.tsx @@ -93,6 +93,8 @@ const defaultSettings: RemoteSettings = { optOutLevelSystem: false, optOutQuestSystem: false, optOutCompanion: true, + optOutCores: false, + optOutReputation: false, autoDismissNotifications: true, sortCommentsBy: SortCommentsBy.NewestFirst, showFeedbackButton: true, diff --git a/packages/extension/src/newtab/index.tsx b/packages/extension/src/newtab/index.tsx index 176eb6ed931..ffd50cec799 100644 --- a/packages/extension/src/newtab/index.tsx +++ b/packages/extension/src/newtab/index.tsx @@ -10,7 +10,19 @@ import { import { get as getCache } from 'idb-keyval'; import browser from 'webextension-polyfill'; import type { DndSettings } from '@dailydotdev/shared/src/contexts/DndContext'; +import { + FOCUS_SCHEDULE_STORAGE_KEY, + isFocusActiveAt, + readFocusSchedule, + type FocusSchedule, +} from '@dailydotdev/shared/src/features/newTab/store/focusSchedule.store'; +import { + NEW_TAB_MODE_STORAGE_KEY, + readNewTabMode, + type NewTabMode, +} from '@dailydotdev/shared/src/features/newTab/store/newTabMode.store'; import App from './App'; +import { getDefaultLink } from './dnd'; declare global { interface Window { @@ -28,7 +40,11 @@ window.addEventListener( }, ); -const root = createRoot(document.getElementById('__next')); +const container = document.getElementById('__next'); +if (!container) { + throw new Error('New tab root container is missing'); +} +const root = createRoot(container); const renderApp = (data?: BootCacheData) => { root.render(); @@ -36,10 +52,62 @@ const renderApp = (data?: BootCacheData) => { const redirectApp = async (url: string) => { const tab = await browser.tabs.getCurrent(); + if (!tab.id) { + throw new Error('Unable to redirect new tab without a tab id'); + } window.stop(); await browser.tabs.update(tab.id, { url }); }; +// Read & coerce the DnD setting. Older builds stored `expiration` as a Date +// (idb-keyval preserves it), but a stringified value can sneak in via manual +// edits or older code paths — be defensive so a malformed entry never traps +// the user on a blank page. +const isDndActive = (settings: DndSettings | null | undefined): boolean => { + if (!settings?.expiration) { + return false; + } + const expirationMs = new Date(settings.expiration).getTime(); + if (Number.isNaN(expirationMs)) { + return false; + } + return expirationMs > Date.now(); +}; + +// Read the user-set Focus configuration. `localStorage` is the canonical +// store written by `useNewTabMode` / `useFocusSchedule` — we read it first +// so a silent `mirrorToExtensionStorage` failure can't strand the redirect. +// `chrome.storage.local` is a fallback for the rare case where localStorage +// is unavailable (e.g. site data cleared but extension data preserved). +const resolveScheduledFocus = async (): Promise => { + try { + const localMode = readNewTabMode(); + const localSchedule = readFocusSchedule(); + if (localMode === 'focus') { + return isFocusActiveAt(localSchedule); + } + // localStorage said `discover`, but check the extension mirror in case + // the page just opened and `localStorage` was cleared by the user + // outside of our flow. + const stored = await browser.storage.local.get([ + FOCUS_SCHEDULE_STORAGE_KEY, + NEW_TAB_MODE_STORAGE_KEY, + ]); + const mirroredMode = stored[NEW_TAB_MODE_STORAGE_KEY] as + | NewTabMode + | undefined; + const mirroredSchedule = stored[FOCUS_SCHEDULE_STORAGE_KEY] as + | FocusSchedule + | undefined; + if (mirroredMode !== 'focus' || !mirroredSchedule) { + return false; + } + return isFocusActiveAt(mirroredSchedule); + } catch { + return false; + } +}; + (async () => { const data = getLocalBootData(); @@ -47,14 +115,28 @@ const redirectApp = async (url: string) => { applyTheme(themeModes[data.settings.theme]); } - const source = window.location.href.split('source=')[1]; + // Always render the app on any unexpected failure below — a blank new tab + // is the worst possible outcome, much worse than a missed redirect. + try { + const source = window.location.href.split('source=')[1]; + if (source) { + renderApp(data ?? undefined); + return; + } - if (source) { - return renderApp(data); - } + const dnd = await getCache('dnd').catch(() => null); + if (isDndActive(dnd) && dnd) { + await redirectApp(dnd.link); + return; + } - const dnd = await getCache('dnd'); - const isDnd = dnd?.expiration?.getTime() > new Date().getTime(); + if (await resolveScheduledFocus()) { + await redirectApp(getDefaultLink()); + return; + } - return isDnd ? redirectApp(dnd.link) : renderApp(data); + renderApp(data ?? undefined); + } catch { + renderApp(data ?? undefined); + } })(); diff --git a/packages/shared/__tests__/fixture/settings.ts b/packages/shared/__tests__/fixture/settings.ts index a9f26e60d8b..4be44bd31cc 100644 --- a/packages/shared/__tests__/fixture/settings.ts +++ b/packages/shared/__tests__/fixture/settings.ts @@ -18,6 +18,8 @@ export const createTestSettings = ( toggleShowTopSites: jest.fn(), autoDismissNotifications: true, optOutCompanion: true, + optOutCores: false, + optOutReputation: false, optOutReadingStreak: true, optOutLevelSystem: false, optOutQuestSystem: false, @@ -29,6 +31,8 @@ export const createTestSettings = ( toggleShowFeedbackButton: jest.fn(), toggleAutoDismissNotifications: jest.fn(), toggleOptOutCompanion: jest.fn(), + toggleOptOutCores: jest.fn(), + toggleOptOutReputation: jest.fn(), toggleOptOutReadingStreak: jest.fn(), toggleOptOutLevelSystem: jest.fn(), toggleOptOutQuestSystem: jest.fn(), diff --git a/packages/shared/__tests__/helpers/boot.tsx b/packages/shared/__tests__/helpers/boot.tsx index f9b19c169aa..824ef923e4c 100644 --- a/packages/shared/__tests__/helpers/boot.tsx +++ b/packages/shared/__tests__/helpers/boot.tsx @@ -41,6 +41,8 @@ export const settingsContext: SettingsContextData = { onToggleHeaderPlacement: jest.fn(), openNewTab: true, optOutCompanion: false, + optOutCores: false, + optOutReputation: false, optOutReadingStreak: true, optOutLevelSystem: false, optOutQuestSystem: false, @@ -59,6 +61,8 @@ export const settingsContext: SettingsContextData = { toggleInsaneMode: jest.fn(), toggleOpenNewTab: jest.fn(), toggleOptOutCompanion: jest.fn(), + toggleOptOutCores: jest.fn(), + toggleOptOutReputation: jest.fn(), toggleOptOutReadingStreak: jest.fn(), toggleOptOutLevelSystem: jest.fn(), toggleOptOutQuestSystem: jest.fn(), diff --git a/packages/shared/src/components/MainLayout.tsx b/packages/shared/src/components/MainLayout.tsx index 55592d5f7da..08e2c07e05a 100644 --- a/packages/shared/src/components/MainLayout.tsx +++ b/packages/shared/src/components/MainLayout.tsx @@ -34,6 +34,7 @@ import usePlusEntry from '../hooks/usePlusEntry'; import { SearchProvider } from '../contexts/search/SearchContext'; import { FeedbackWidget } from './feedback'; import { isExtension } from '../lib/func'; +import { useDndContext } from '../contexts/DndContext'; const GoBackHeaderMobile = dynamic( () => @@ -84,6 +85,11 @@ function MainLayoutComponent({ const { growthbook } = useGrowthBookContext(); const { sidebarRendered } = useSidebarRendered(); const { isAvailable: isBannerAvailable } = useBanner(); + // Treat the DnD/Take-a-break strip the same as a promotional banner so the + // fixed header and main padding shift down to make room for it. Without + // this the strip ends up hidden behind the header at the top of the page. + const { isActive: isDndActive } = useDndContext(); + const hasTopBanner = isBannerAvailable || isDndActive; const { sidebarExpanded, autoDismissNotifications } = useContext(SettingsContext); const [hasLoggedImpression, setHasLoggedImpression] = useState(false); @@ -193,7 +199,7 @@ function MainLayoutComponent({ )} {isAuthReady && showSidebar && ( diff --git a/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx b/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx index ff11b6f6f2b..1947875459f 100644 --- a/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx +++ b/packages/shared/src/components/ProfileMenu/ProfileMenu.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import dynamic from 'next/dynamic'; import { useRouter } from 'next/router'; import { useAuthContext } from '../../contexts/AuthContext'; @@ -11,6 +11,7 @@ import { ButtonSize } from '../buttons/Button'; import { checkIsExtension } from '../../lib/func'; import { LogoutReason } from '../../lib/user'; import { TargetId } from '../../lib/log'; +import { useRightSidebarOffset } from '../../features/customizeNewTab/store/rightSidebar.store'; import { ProfileMenuFooter } from './ProfileMenuFooter'; import { UpgradeToPlus } from '../UpgradeToPlus'; @@ -22,7 +23,6 @@ import { ResourceSection } from './sections/ResourceSection'; import { AccountSection } from './sections/AccountSection'; import { MainSection } from './sections/MainSection'; import { ThemeSection } from './sections/ThemeSection'; -import { FeedbackButtonSection } from './sections/FeedbackButtonSection'; import { ProfileCompletion } from '../../features/profile/components/ProfileWidgets/ProfileCompletion'; import { useProfileCompletionIndicator } from '../../hooks/profile/useProfileCompletionIndicator'; @@ -43,6 +43,17 @@ export default function ProfileMenu({ const { user, logout } = useAuthContext(); const { showIndicator: showProfileCompletion } = useProfileCompletionIndicator(); + // The customize sidebar is `right-0` and the menu is `right-4` — without + // shifting, the menu would render fully under the sidebar (z-modal beats + // z-popup). Slide the menu left by the live sidebar width so the dropdown + // remains visible and clickable while the panel is open. + const rightSidebarOffset = useRightSidebarOffset(); + const popupStyle = useMemo(() => { + if (!rightSidebarOffset) { + return undefined; + } + return { right: `${rightSidebarOffset + 16}px` }; + }, [rightSidebarOffset]); useEffect(() => { events.on('routeChangeStart', onClose); @@ -62,6 +73,7 @@ export default function ProfileMenu({ closeOutsideClick position={InteractivePopupPosition.ProfileMenu} className="flex max-h-[calc(100vh-4rem)] w-full max-w-80 flex-col gap-3 overflow-y-auto !rounded-10 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest p-3" + style={popupStyle} > {showProfileCompletion && } @@ -83,14 +95,21 @@ export default function ProfileMenu({ - - - {checkIsExtension() && } + {/* "Customize new tab" sits directly above "Settings" so the two + destination-style entries read as one grouped block — both are + single taps that route the user somewhere (sidebar / settings + page) rather than toggle state inline. The wrapping flex column + collapses them into a single child of the parent `nav`, so its + `gap-2` no longer inserts a stray 8px gutter between Customize + new tab and Settings. */} +
+ {checkIsExtension() && } + +
- diff --git a/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx b/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx index 2b333802903..c3e14a165e7 100644 --- a/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx +++ b/packages/shared/src/components/ProfileMenu/sections/ExtensionSection.tsx @@ -1,47 +1,59 @@ import React from 'react'; import type { ReactElement } from 'react'; -import { HorizontalSeparator } from '../../utilities'; import { ProfileSection } from '../ProfileSection'; -import { useDndContext } from '../../../contexts/DndContext'; -import { useSettingsContext } from '../../../contexts/SettingsContext'; -import { PauseIcon, PlayIcon, ShortcutsIcon, StoryIcon } from '../../icons'; -import { useLazyModal } from '../../../hooks/useLazyModal'; -import { LazyModal } from '../../modals/common/types'; +import { MagicIcon } from '../../icons'; import { checkIsExtension } from '../../../lib/func'; +import { useRequestCustomizerOpen } from '../../../features/customizeNewTab/store/customizerOpenRequest.store'; +import { useLogContext } from '../../../contexts/LogContext'; +import { LogEvent, TargetType } from '../../../lib/log'; -export const ExtensionSection = (): ReactElement | null => { - const { openModal } = useLazyModal(); - const { isActive: isDndActive, setShowDnd } = useDndContext(); - const { optOutCompanion, toggleOptOutCompanion } = useSettingsContext(); +interface ExtensionSectionProps { + /** + * Called after the user picks any item in this section so the parent + * `ProfileMenu` collapses the dropdown — without this the menu stays open + * over the customize sidebar that's about to appear. + */ + onClose?: () => void; +} + +export const ExtensionSection = ({ + onClose, +}: ExtensionSectionProps): ReactElement | null => { + const { logEvent } = useLogContext(); + const requestCustomizerOpen = useRequestCustomizerOpen(); if (!checkIsExtension()) { return null; } - return ( - <> - + // The extension dropdown previously surfaced three separate toggles + // (Shortcuts, Pause new tab, Companion widget). All of them now live + // inside the Customize sidebar, so this entry just routes the user there + // — same idea as a "Settings" link in a menu, one tap to the destination. + const onCustomize = () => { + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.CustomizeNewTab, + target_id: 'profile_dropdown', + }); + requestCustomizerOpen(); + onClose?.(); + }; - openModal({ type: LazyModal.CustomLinks }), - }, - { - title: `${isDndActive ? 'Resume' : 'Pause'} new tab`, - icon: isDndActive ? PlayIcon : PauseIcon, - onClick: () => setShowDnd?.(true), - }, - { - title: `${optOutCompanion ? 'Enable' : 'Disable'} companion widget`, - icon: () => , - onClick: () => toggleOptOutCompanion(), - }, - ]} - /> - + // Rendered immediately above `AccountSection` in the profile dropdown, so + // the parent owns the surrounding separators and this component just emits + // the row itself — keeps the visual grouping with "Settings" tight, with + // no double-separator between them. + return ( + ); }; diff --git a/packages/shared/src/components/ScrollToTopButton.tsx b/packages/shared/src/components/ScrollToTopButton.tsx index 4883e8a71ef..3c6fea6ad5b 100644 --- a/packages/shared/src/components/ScrollToTopButton.tsx +++ b/packages/shared/src/components/ScrollToTopButton.tsx @@ -12,12 +12,23 @@ const baseStyle: CSSProperties = { willChange: 'transform, opacity', }; -export default function ScrollToTopButton(): ReactElement { +interface ScrollToTopButtonProps { + className?: string; + compact?: boolean; +} + +export default function ScrollToTopButton({ + className, + compact = false, +}: ScrollToTopButtonProps): ReactElement { const [show, setShow] = useState(false); const isLaptop = useViewSize(ViewSize.Laptop); const isTablet = useViewSize(ViewSize.Tablet); const { showFeedbackButton } = useSettingsContext(); const size = (() => { + if (compact) { + return ButtonSize.Small; + } if (isLaptop) { return ButtonSize.XLarge; } @@ -57,6 +68,7 @@ export default function ScrollToTopButton(): ReactElement { showFeedbackButton ? '-top-26 tablet:-top-28 laptop:-top-32' : '-top-12 tablet:-top-18 laptop:-top-24', + className, )} variant={ButtonVariant.Primary} size={size} diff --git a/packages/shared/src/components/feedback/FeedbackWidget.tsx b/packages/shared/src/components/feedback/FeedbackWidget.tsx index a5ea9d49351..6debfb3e2b1 100644 --- a/packages/shared/src/components/feedback/FeedbackWidget.tsx +++ b/packages/shared/src/components/feedback/FeedbackWidget.tsx @@ -7,16 +7,32 @@ import { useSettingsContext } from '../../contexts/SettingsContext'; import { useViewSize, ViewSize } from '../../hooks/useViewSize'; import { useLazyModal } from '../../hooks/useLazyModal'; import { LazyModal } from '../modals/common/types'; +import { + useCustomizerFirstSession, + useRightSidebarOffset, + useRightSidebarSettled, +} 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(); + // Match the rest of the new-tab chrome: skip the slide transition on + // first paint so the customizer auto-open lands without any siblings + // animating in alongside it. Subsequent open/close still animate. + const isRightSidebarSettled = useRightSidebarSettled(); + // Hide while a brand-new user is in their auto-opened first-session + // new tab. Without this the corner has the customizer panel + a + // Feedback pill competing for attention, which dilutes the + // onboarding moment. From the second session onward the atom stays + // `false` and feedback shows by default. + const isCustomizerFirstSession = useCustomizerFirstSession(); // Only show for authenticated users on desktop when setting is enabled // Mobile feedback is handled by FooterPlusButton - if (!user || isMobile || !showFeedbackButton) { + if (!user || isMobile || !showFeedbackButton || isCustomizerFirstSession) { return null; } @@ -25,7 +41,13 @@ 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: isRightSidebarSettled + ? 'right 200ms ease-in-out' + : undefined, + }} 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..8d6eea73844 100644 --- a/packages/shared/src/components/layout/MainLayoutHeader.tsx +++ b/packages/shared/src/components/layout/MainLayoutHeader.tsx @@ -14,6 +14,10 @@ import { useFeedName } from '../../hooks/feed/useFeedName'; import FeedNav from '../feeds/FeedNav'; import { MobileExploreHeader } from '../header/MobileExploreHeader'; import useActiveNav from '../../hooks/useActiveNav'; +import { + useRightSidebarOffset, + useRightSidebarSettled, +} from '../../features/customizeNewTab/store/rightSidebar.store'; export interface MainLayoutHeaderProps { hasBanner?: boolean; @@ -52,6 +56,13 @@ function MainLayoutHeader({ const isSearchPage = isSearch || isAnyExplore; const featureTheme = useFeatureTheme(); const scrollClassName = useScrollTopClassName({ enabled: !!featureTheme }); + const rightSidebarOffset = useRightSidebarOffset(); + // While `false`, the customizer panel is still settling into its + // initial open/closed position on first paint — skip the right/width + // transition here so we don't visibly animate the header in alongside + // the panel slide. Flips to `true` one frame later, so any subsequent + // open/close still animates normally. + const isRightSidebarSettled = useRightSidebarSettled(); const { profile } = useActiveNav(feedName); const shouldUseLoadedSettings = loadedSettings && hasHydrated; const isMobileProfile = profile && !isLaptop; @@ -105,7 +116,16 @@ 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: isRightSidebarSettled + ? 'right 200ms ease-in-out, width 200ms ease-in-out' + : undefined, + }} > {isMobileSearchPage ? ( <> diff --git a/packages/shared/src/components/modals/CompanionPermissionModal.tsx b/packages/shared/src/components/modals/CompanionPermissionModal.tsx new file mode 100644 index 00000000000..df295d05a01 --- /dev/null +++ b/packages/shared/src/components/modals/CompanionPermissionModal.tsx @@ -0,0 +1,141 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { LazyModalCommonProps } from './common/Modal'; +import { Modal } from './common/Modal'; +import { ModalHeader } from './common/ModalHeader'; +import { ModalBody } from './common/ModalBody'; +import { ModalSize } from './common/types'; +import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; +import { ClickableText } from '../buttons/ClickableText'; +import { PlayIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { Typography, TypographyType } from '../typography/Typography'; +import { useExtensionContext } from '../../contexts/ExtensionContext'; +import { companionExplainerVideo } from '../../lib/constants'; + +export interface CompanionPermissionModalProps extends LazyModalCommonProps { + /** + * Fires after the user explicitly opts in via the Activate button. Use it + * to flip the underlying setting — we never flip it eagerly because the + * modal exists precisely to gate the permission request behind consent. + */ + onActivated?: () => void; +} + +/** + * Confirmation modal for enabling the Companion. Renders the same value + * proposition as the legacy in-tab tooltip (preview thumbnail + explainer + * link + "Activate companion" button) but uses our standard Modal shell so + * it works as a real consent step instead of a passive popover. + * + * Activation only flips the user's setting (via `onActivated`) when they + * click the primary button — closing the modal without confirming leaves + * the toggle in its current state. This is the legal & UX-safe shape for a + * permission request: nothing happens behind the user's back. + */ +export const CompanionPermissionModal = ({ + isOpen, + onRequestClose, + onActivated, +}: CompanionPermissionModalProps): ReactElement => { + const { requestContentScripts } = useExtensionContext(); + + const handleActivate = async (event: React.MouseEvent) => { + if (requestContentScripts) { + // The host shim either resolves with `granted` or — rarely, but it + // happens on locked-down profiles / managed extensions — throws. Either + // way we close the modal and leave the opt-out flag untouched so the + // next click on the toggle re-prompts cleanly. An unhandled rejection + // here would otherwise strand the user on a Modal that does nothing. + let granted = false; + try { + granted = await requestContentScripts({ + origin: 'companion permission modal', + }); + } catch { + onRequestClose(event); + return; + } + if (!granted) { + onRequestClose(event); + return; + } + } + onActivated?.(); + onRequestClose(event); + }; + + return ( + + + + + The companion floats on top of any article you open so you can upvote, + bookmark and discuss without leaving the page. We'll ask your + browser for one extra permission so it can show up there. + + + + Companion preview + + + + Watch the overview + + +
+ + +
+
+
+ ); +}; + +export default CompanionPermissionModal; diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx index c1a8ae6c6d5..50cb362f34f 100644 --- a/packages/shared/src/components/modals/common.tsx +++ b/packages/shared/src/components/modals/common.tsx @@ -455,6 +455,13 @@ const AchievementShowcaseModal = dynamic( ), ); +const CompanionPermissionModal = dynamic( + () => + import( + /* webpackChunkName: "companionPermissionModal" */ './CompanionPermissionModal' + ), +); + export const modals = { [LazyModal.SquadMember]: SquadMemberModal, [LazyModal.UpvotedPopup]: UpvotedPopupModal, @@ -529,6 +536,7 @@ export const modals = { [LazyModal.AchievementCompletion]: AchievementCompletionModal, [LazyModal.CompareAchievements]: CompareAchievementsModal, [LazyModal.AchievementShowcase]: AchievementShowcaseModal, + [LazyModal.CompanionPermission]: CompanionPermissionModal, }; type GetComponentProps = T extends diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts index f331f4ff9d7..8590c9c639d 100644 --- a/packages/shared/src/components/modals/common/types.ts +++ b/packages/shared/src/components/modals/common/types.ts @@ -98,6 +98,7 @@ export enum LazyModal { AchievementCompletion = 'achievementCompletion', CompareAchievements = 'compareAchievements', AchievementShowcase = 'achievementShowcase', + CompanionPermission = 'companionPermission', } export type ModalTabItem = { diff --git a/packages/shared/src/components/profile/ProfileButton.tsx b/packages/shared/src/components/profile/ProfileButton.tsx index a91f1e3fa65..b62b7b861c8 100644 --- a/packages/shared/src/components/profile/ProfileButton.tsx +++ b/packages/shared/src/components/profile/ProfileButton.tsx @@ -15,6 +15,7 @@ import { walletUrl } from '../../lib/constants'; import { largeNumberFormat } from '../../lib'; import { formatCurrency } from '../../lib/utils'; import { useHasAccessToCores } from '../../hooks/useCoresFeature'; +import { useSettingsContext } from '../../contexts/SettingsContext'; import Link from '../utilities/Link'; import { Tooltip } from '../tooltip/Tooltip'; import { QuestRewardType } from '../../graphql/quests'; @@ -39,6 +40,13 @@ export default function ProfileButton({ const { user, isAuthReady } = useAuthContext(); const { streak, isLoading, isStreaksEnabled } = useReadingStreak(); const hasCoresAccess = useHasAccessToCores(); + // Customize -> Widgets exposes a "Cores wallet" toggle that hides the pill + // (and only the pill) without revoking access; the menu entry under + // ProfileMenu still routes to /wallet. The "Reputation badge" toggle is + // the same idea for the rep number — hide the chrome, keep the value. + const { optOutCores, optOutReputation } = useSettingsContext(); + const showCoresPill = hasCoresAccess && !optOutCores; + const showReputationBadge = !optOutReputation; const [animatedCores, setAnimatedCores] = useState(null); const [animatedReputation, setAnimatedReputation] = useState( null, @@ -215,7 +223,7 @@ export default function ProfileButton({ className="pl-4" /> )} - {hasCoresAccess && ( + {showCoresPill && ( @@ -252,19 +260,21 @@ export default function ProfileButton({ )} onClick={wrapHandler(() => onUpdate(!isOpen))} > - - - + {showReputationBadge ? ( + + + + ) : null}
diff --git a/packages/shared/src/components/sidebar/SidebarDesktop.tsx b/packages/shared/src/components/sidebar/SidebarDesktop.tsx index e72d3814eca..c40440c1810 100644 --- a/packages/shared/src/components/sidebar/SidebarDesktop.tsx +++ b/packages/shared/src/components/sidebar/SidebarDesktop.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/router'; import { Nav, SidebarAside, SidebarScrollWrapper } from './common'; import { useSettingsContext } from '../../contexts/SettingsContext'; import { useBanner } from '../../hooks/useBanner'; +import { useDndContext } from '../../contexts/DndContext'; import { MainSection } from './sections/MainSection'; import { CustomFeedSection } from './sections/CustomFeedSection'; import { DiscoverSection } from './sections/DiscoverSection'; @@ -33,6 +34,10 @@ export const SidebarDesktop = ({ const router = useRouter(); const { sidebarExpanded } = useSettingsContext(); const { isAvailable: isBannerAvailable } = useBanner(); + const { isActive: isDndActive } = useDndContext(); + // The DnD/Take-a-break strip steals the same 32px banner slot above the + // header, so the sidebar needs to slide down to keep its top edge under it. + const hasTopBanner = isBannerAvailable || isDndActive; const activePage = activePageProp || router.asPath || router.pathname; const defaultRenderSectionProps = useMemo( @@ -49,7 +54,7 @@ export const SidebarDesktop = ({ data-testid="sidebar-aside" className={classNames( sidebarExpanded ? 'laptop:w-60' : 'laptop:w-11', - isBannerAvailable + hasTopBanner ? 'laptop:top-24 laptop:h-[calc(100vh-theme(space.24))]' : 'laptop:top-16 laptop:h-[calc(100vh-theme(space.16))]', featureTheme && 'bg-transparent', diff --git a/packages/shared/src/components/tooltips/InteractivePopup.tsx b/packages/shared/src/components/tooltips/InteractivePopup.tsx index b0a1cdb2b79..06405a517a9 100644 --- a/packages/shared/src/components/tooltips/InteractivePopup.tsx +++ b/packages/shared/src/components/tooltips/InteractivePopup.tsx @@ -40,6 +40,12 @@ export interface InteractivePopupProps extends DrawerOnMobileProps { onClose?: PopupCloseFunc; closeButton?: CloseButtonProps; disableOverlay?: boolean; + /** + * Inline style override forwarded to the popup container. Useful for + * dynamic positioning (e.g. shifting the menu past a right sidebar) + * without forking the position presets above. + */ + style?: React.CSSProperties; } const centerClassX = 'left-1/2 -translate-x-1/2'; @@ -80,6 +86,7 @@ function InteractivePopup({ isDrawerOnMobile, drawerProps, disableOverlay = false, + style, ...props }: InteractivePopupProps): ReactElement { const { @@ -139,6 +146,7 @@ function InteractivePopup({ !sidebarExpanded && 'laptop:left-16', )} + style={style} {...props} > {finalPosition !== InteractivePopupPosition.ProfileMenu && diff --git a/packages/shared/src/contexts/BootProvider.spec.tsx b/packages/shared/src/contexts/BootProvider.spec.tsx index 821266be25a..53a36b4d6f4 100644 --- a/packages/shared/src/contexts/BootProvider.spec.tsx +++ b/packages/shared/src/contexts/BootProvider.spec.tsx @@ -68,6 +68,8 @@ const defaultSettings: RemoteSettings = { optOutQuestSystem: false, autoDismissNotifications: true, optOutCompanion: false, + optOutCores: false, + optOutReputation: false, sortCommentsBy: SortCommentsBy.NewestFirst, showFeedbackButton: true, }; diff --git a/packages/shared/src/contexts/DndContext.tsx b/packages/shared/src/contexts/DndContext.tsx index b2c9bae20a9..cbaee92f858 100644 --- a/packages/shared/src/contexts/DndContext.tsx +++ b/packages/shared/src/contexts/DndContext.tsx @@ -8,20 +8,19 @@ export type DndSettings = { expiration: Date; link: string }; export interface DndContextData { setShowDnd: Dispatch; showDnd: boolean; - dndSettings: DndSettings; + dndSettings: DndSettings | null; isActive: boolean; - onDndSettings: (settings: DndSettings) => Promise; + onDndSettings: (settings: DndSettings | null) => Promise; } const DEFAULT_VALUE = { - showDnd: null, - setShowDnd: null, + showDnd: false, + setShowDnd: () => undefined, dndSettings: null, isActive: false, - onDndSettings: null, -}; + onDndSettings: async () => undefined, +} satisfies DndContextData; const DndContext = React.createContext(DEFAULT_VALUE); -const now = new Date(); interface DndContextProviderProps { children: ReactNode; @@ -32,7 +31,9 @@ export const DndContextProvider = ({ }: DndContextProviderProps): ReactElement => { const [showDnd, setShowDnd] = useState(false); const [dndSettings, setDndSettings] = - usePersistentContext('dnd'); + usePersistentContext('dnd'); + const handleDndSettings = (settings: DndSettings | null) => + setDndSettings(settings); if (!checkIsExtension()) { return ( @@ -48,8 +49,11 @@ export const DndContextProvider = ({ showDnd, setShowDnd, dndSettings, - isActive: dndSettings?.expiration?.getTime() > now.getTime(), - onDndSettings: setDndSettings, + isActive: Boolean( + dndSettings?.expiration && + dndSettings.expiration.getTime() > Date.now(), + ), + onDndSettings: handleDndSettings, }} > {children} diff --git a/packages/shared/src/contexts/FeedContext.tsx b/packages/shared/src/contexts/FeedContext.tsx index a37ceda1432..58e41c759fe 100644 --- a/packages/shared/src/contexts/FeedContext.tsx +++ b/packages/shared/src/contexts/FeedContext.tsx @@ -1,9 +1,13 @@ import type { ReactElement, PropsWithChildren } from 'react'; -import React, { useMemo, useState, useEffect } from 'react'; +import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react'; 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, + useRightSidebarSettled, +} from '../features/customizeNewTab/store/rightSidebar.store'; import type { Spaciness } from '../graphql/settings'; import { featureFeedAdTemplate } from '../lib/featureManagement'; @@ -117,6 +121,8 @@ export function FeedLayoutProvider({ const { sidebarExpanded } = useSettingsContext(); const { sidebarRendered } = useSidebarRendered(); const { isPlus } = usePlusSubscription(); + const rightSidebarOffset = useRightSidebarOffset(); + const isRightSidebarSettled = useRightSidebarSettled(); const feedAdTemplateFeature = useConditionalFeature({ feature: featureFeedAdTemplate, shouldEvaluate: !isPlus, @@ -127,6 +133,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 +146,28 @@ export function FeedLayoutProvider({ return () => clearTimeout(timer); }, [sidebarExpanded]); + // While the customizer is still settling into its initial paint state + // (no transitions running yet), keep the debounced offset in lockstep + // with the live offset so the feed renders with its final column count + // on the *first* paint instead of re-flowing once the debounce timer + // fires. `useLayoutEffect` here so the sync lands before paint. + useLayoutEffect(() => { + if (!isRightSidebarSettled) { + setDebouncedRightOffset(rightSidebarOffset); + } + }, [rightSidebarOffset, isRightSidebarSettled]); + + useEffect(() => { + if (!isRightSidebarSettled) { + return undefined; + } + const timer = setTimeout(() => { + setDebouncedRightOffset(rightSidebarOffset); + }, SIDEBAR_TRANSITION_DURATION); + + return () => clearTimeout(timer); + }, [rightSidebarOffset, isRightSidebarSettled]); + const { feedSettings, defaultFeedSettings } = useMemo(() => { const enhancedFeedSettings = Object.entries(baseFeedSettings).reduce( (acc, [feedSettingsKey, feedSettingsValue]) => { @@ -162,36 +195,46 @@ 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( + const mediaSettings = useMedia( feedBreakpoints, feedSettings, defaultFeedSettings, ); return ( - + {children} ); diff --git a/packages/shared/src/contexts/SettingsContext.tsx b/packages/shared/src/contexts/SettingsContext.tsx index 79b0f9d5edf..9eb2b7453c0 100644 --- a/packages/shared/src/contexts/SettingsContext.tsx +++ b/packages/shared/src/contexts/SettingsContext.tsx @@ -55,6 +55,8 @@ export interface SettingsContextData extends Omit { toggleOptOutLevelSystem: () => Promise; toggleOptOutQuestSystem: () => Promise; toggleOptOutCompanion: () => Promise; + toggleOptOutCores: () => Promise; + toggleOptOutReputation: () => Promise; toggleAutoDismissNotifications: () => Promise; toggleShowFeedbackButton: () => Promise; loadedSettings: boolean; @@ -119,7 +121,7 @@ export type SettingsContextProviderProps = { loadedSettings?: boolean; }; -const defaultSettings: RemoteSettings = { +export const defaultSettings: RemoteSettings = { spaciness: 'eco', openNewTab: true, insaneMode: false, @@ -130,7 +132,17 @@ const defaultSettings: RemoteSettings = { optOutReadingStreak: false, optOutLevelSystem: false, optOutQuestSystem: false, - optOutCompanion: false, + // Default-off: the companion needs broad host permissions, so we wait for + // the user to opt in via the explicit confirmation modal in the + // Customize -> Widgets sidebar before flipping this on. + optOutCompanion: true, + // Default-on: users with Cores access see the wallet pill in the header. + // The Customize -> Widgets toggle lets them hide it without losing access. + optOutCores: false, + // Default-on: the reputation badge is visible in the header pill. Users + // who don't want a number in their face during deep work can hide it via + // the Customize -> Widgets toggle without affecting earned reputation. + optOutReputation: false, autoDismissNotifications: true, sortCommentsBy: SortCommentsBy.OldestFirst, showFeedbackButton: true, @@ -272,6 +284,16 @@ export const SettingsContextProvider = ({ ...settings, optOutCompanion: !settings.optOutCompanion, }), + toggleOptOutCores: () => + setSettings({ + ...settings, + optOutCores: !settings.optOutCores, + }), + toggleOptOutReputation: () => + setSettings({ + ...settings, + optOutReputation: !settings.optOutReputation, + }), toggleAutoDismissNotifications: () => setSettings({ ...settings, 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..6fa68d31e72 --- /dev/null +++ b/packages/shared/src/features/customizeNewTab/CustomizeNewTabSidebar.spec.tsx @@ -0,0 +1,214 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { QueryClient } from '@tanstack/react-query'; +import { TestBootProvider } from '../../../__tests__/helpers/boot'; +import { defaultSettings } from '../../contexts/SettingsContext'; +import { SortCommentsBy } from '../../graphql/comments'; +import { CustomizeNewTabSidebar } from './CustomizeNewTabSidebar'; +import type { UseCustomizeNewTab } from './useCustomizeNewTab'; +import { DndContextProvider } from '../../contexts/DndContext'; +import { ShortcutsProvider } from '../shortcuts/contexts/ShortcutsProvider'; +import { + DEFAULT_FOCUS_SCHEDULE, + FOCUS_SCHEDULE_STORAGE_KEY, +} from '../newTab/store/focusSchedule.store'; + +const renderSidebar = ( + overrides: Partial = {}, + settings = {}, +) => { + const close = jest.fn(); + const open = jest.fn(); + const customizer: UseCustomizeNewTab = { + shouldRender: true, + isOpen: true, + isFirstSession: false, + hasSettledInitialOpen: true, + open, + close, + ...overrides, + }; + + const client = new QueryClient(); + const utils = render( + + + + + + + , + ); + + return { ...utils, open, close }; +}; + +describe('CustomizeNewTabSidebar', () => { + beforeEach(() => { + jest.useRealTimers(); + window.localStorage.clear(); + }); + + it('renders nothing when shouldRender is false', () => { + const { container } = renderSidebar({ shouldRender: false }); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders feed-shaping sections in the default Discover mode', () => { + renderSidebar(); + expect(screen.getAllByText('Customize').length).toBeGreaterThan(0); + expect(screen.getByText('Mode')).toBeInTheDocument(); + expect(screen.getByText('Appearance')).toBeInTheDocument(); + expect(screen.getByText('Shortcuts')).toBeInTheDocument(); + expect(screen.getByText('Widgets')).toBeInTheDocument(); + // Focus controls only matter in Focus mode and should be hidden so the + // panel reads as "make my feed mine". + expect(screen.queryByText(/Take a break/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Active hours/i)).not.toBeInTheDocument(); + }); + + it('swaps to Focus-only sections after switching to Focus mode', () => { + renderSidebar(); + act(() => { + fireEvent.click(screen.getByRole('radio', { name: /Focus/ })); + }); + // Take a break sits at the top of the Focus section now — it's the + // most common reason users open the panel after picking Focus. + expect(screen.getByText(/Take a break/i)).toBeInTheDocument(); + expect(screen.getByText(/Active hours/i)).toBeInTheDocument(); + // Feed-only knobs disappear because they're a no-op while Focus owns the + // surface — keeping them around just creates dead UI. + expect(screen.queryByText('Appearance')).not.toBeInTheDocument(); + expect(screen.queryByText('Shortcuts')).not.toBeInTheDocument(); + expect(screen.queryByText('Widgets')).not.toBeInTheDocument(); + }); + + it('exposes the Discover and Focus mode options in the mode picker', () => { + renderSidebar(); + expect(screen.getByRole('radio', { name: /Discover/ })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /Focus/ })).toBeInTheDocument(); + }); + + it('does not expose the removed Zen mode option', () => { + renderSidebar(); + expect( + screen.queryByRole('radio', { name: /Zen/ }), + ).not.toBeInTheDocument(); + }); + + it('does not render a footer Done button — settings are real-time', () => { + // Every toggle / picker writes through immediately, so there is no + // draft state to commit. A "Done" button would imply confirmation and + // sit redundantly next to the X. Reset moved into the header alongside + // the X; nothing else lives at the bottom of the panel. + renderSidebar(); + expect( + screen.queryByRole('button', { name: 'Done' }), + ).not.toBeInTheDocument(); + }); + + it('exposes Reset to defaults in the header', () => { + renderSidebar(); + expect( + screen.getByRole('button', { name: /reset to defaults/i }), + ).toBeInTheDocument(); + }); + + it('calls close when the X button is clicked', () => { + const { close } = renderSidebar(); + fireEvent.click(screen.getByRole('button', { name: /close/i })); + expect(close).toHaveBeenCalled(); + }); + + it('calls close when Escape is pressed', () => { + const { close } = renderSidebar(); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(close).toHaveBeenCalled(); + }); + + it('hides the panel without rendering any floating affordance when closed', () => { + // We deliberately removed the floating "Customize" pill — on a brand + // new tab the panel auto-opens, and returning users open it from the + // profile dropdown. So when the panel is closed there should be no + // visible call-to-action in the corner. + renderSidebar({ isOpen: false }); + expect(screen.queryByTitle('Customize new tab')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /Customize/ }), + ).not.toBeInTheDocument(); + // Multiple