diff --git a/client/src/components/Nav/AgentMarketplaceButton.tsx b/client/src/components/Nav/AgentMarketplaceButton.tsx
index a9416d7cba2..7729cfbc0e0 100644
--- a/client/src/components/Nav/AgentMarketplaceButton.tsx
+++ b/client/src/components/Nav/AgentMarketplaceButton.tsx
@@ -2,13 +2,22 @@ import { Link } from 'react-router-dom';
import { LayoutGrid } from 'lucide-react';
import { Button, TooltipAnchor } from '@librechat/client';
import { useLocalize, useShowMarketplace } from '~/hooks';
+import { cn } from '~/utils';
interface AgentMarketplaceButtonProps {
/** Which way the tooltip opens: the desktop rail is a left edge, the mobile
- * drawer header is a top edge. */
+ * drawer header is a top edge. Ignored by the `row` layout, which has no
+ * tooltip to place. */
side?: 'right' | 'bottom';
/** Mobile dismisses the drawer on navigation; the desktop rail stays put. */
onNavigate?: () => void;
+ /**
+ * `icon` is the rail's square, labelled by a tooltip. `row` is a full-width
+ * entry carrying its own label, for the mobile drawer: an icon in a header
+ * strip is the easiest thing there to miss, and the list it now heads is
+ * where someone already looks for somewhere to go.
+ */
+ layout?: 'icon' | 'row';
}
/** Agent Marketplace entry in the sidebar. Self-gated on marketplace
@@ -16,6 +25,7 @@ interface AgentMarketplaceButtonProps {
export default function AgentMarketplaceButton({
side = 'right',
onNavigate,
+ layout = 'icon',
}: AgentMarketplaceButtonProps) {
const localize = useLocalize();
const showAgentMarketplace = useShowMarketplace();
@@ -24,33 +34,46 @@ export default function AgentMarketplaceButton({
return null;
}
+ const isRow = layout === 'row';
+
+ /** Composed through the shared button so the focus ring, hover fill and theme
+ * timing come from the primitive rather than being restated: a hand-rolled
+ * version of this had no focus ring at all. `asChild` keeps it a router Link,
+ * so modifier- and middle-clicks still open the marketplace in a new tab, and
+ * those stay on the current page and so must not dismiss. */
+ const button = (
+
+ );
+
+ if (isRow) {
+ return button;
+ }
+
return (
-
- {
- if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
- return;
- }
- onNavigate?.();
- }}
- >
-
-
-
- }
- />
+
);
}
diff --git a/client/src/components/Nav/__tests__/AgentMarketplaceButton.spec.tsx b/client/src/components/Nav/__tests__/AgentMarketplaceButton.spec.tsx
new file mode 100644
index 00000000000..c1ea752bc70
--- /dev/null
+++ b/client/src/components/Nav/__tests__/AgentMarketplaceButton.spec.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import { render, screen, cleanup } from '@testing-library/react';
+
+let mockShowMarketplace = true;
+
+jest.mock('~/hooks', () => ({
+ useLocalize: () => (key: string) => key,
+ useShowMarketplace: () => mockShowMarketplace,
+}));
+
+jest.mock('@librechat/client', () => ({
+ Button: ({
+ children,
+ asChild: _asChild,
+ ...props
+ }: React.ComponentProps<'button'> & { asChild?: boolean }) => (
+
+ ),
+ TooltipAnchor: ({ render: trigger }: { render: React.ReactNode }) => trigger,
+}));
+
+import AgentMarketplaceButton from '../AgentMarketplaceButton';
+
+const renderButton = (layout?: 'icon' | 'row') =>
+ render(, { wrapper: MemoryRouter });
+
+describe('AgentMarketplaceButton', () => {
+ afterEach(() => {
+ mockShowMarketplace = true;
+ cleanup();
+ });
+
+ it('routes to the marketplace', () => {
+ renderButton();
+
+ expect(screen.getByTestId('nav-agents-marketplace-button')).toHaveAttribute('href', '/agents');
+ });
+
+ /** The drawer is the only sidebar surface on small screens, so this row is the
+ * one route to `/agents` there. */
+ it('shows its own label as a row, and leans on the tooltip as an icon', () => {
+ renderButton('row');
+ expect(screen.getByText('com_agents_marketplace')).toBeInTheDocument();
+ expect(screen.getByTestId('nav-agents-marketplace-button')).not.toHaveAttribute('aria-label');
+
+ cleanup();
+
+ renderButton('icon');
+ expect(screen.queryByText('com_agents_marketplace')).not.toBeInTheDocument();
+ expect(screen.getByTestId('nav-agents-marketplace-button')).toHaveAttribute(
+ 'aria-label',
+ 'com_agents_marketplace',
+ );
+ });
+
+ /** A deployment without marketplace access is left with no gap, in either layout. */
+ it('omits itself without marketplace access', () => {
+ mockShowMarketplace = false;
+
+ for (const layout of ['icon', 'row'] as const) {
+ const { container } = renderButton(layout);
+ expect(container).toBeEmptyDOMElement();
+ cleanup();
+ }
+ });
+});
diff --git a/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx b/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx
index 2adbef4e647..9c607964671 100644
--- a/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx
+++ b/client/src/components/UnifiedSidebar/UnifiedSidebar.tsx
@@ -19,6 +19,7 @@ import {
import { filesDialogTriggerAtom, showFilesDialogAtom } from '~/store/filesDialog';
import { ChatContext, ChatFormProvider, ActivePanelProvider } from '~/Providers';
import { MobileHeader, MobileBottomBar, MobileShortcutTargets } from './mobile';
+import AgentMarketplaceButton from '~/components/Nav/AgentMarketplaceButton';
import { MyFilesModal } from '~/components/Chat/Input/Files/MyFilesModal';
import useUnifiedSidebarLinks from '~/hooks/Nav/useUnifiedSidebarLinks';
import useSidebarToggle from '~/hooks/Nav/useSidebarToggle';
@@ -194,9 +195,13 @@ function UnifiedSidebar({ isSliding = false }: { isSliding?: boolean }) {
id={MOBILE_DRAWER_ID}
className={cn(
/** The close swipe reads horizontal touches here (the drawer holds no
- * horizontal scrollers), while pinch-zoom stays with the browser —
+ * horizontal scrollers), while pinch-zoom stays with the browser:
* this full-viewport surface must not disable zooming entirely. */
- 'bg-surface-primary-alt fixed inset-y-0 left-0 flex touch-pan-y touch-pinch-zoom flex-col',
+ 'bg-surface-primary-alt text-text-primary fixed inset-y-0 left-0 flex touch-pan-y touch-pinch-zoom flex-col',
+ /** In dark mode the scrim and the drawer are both near-black, and no
+ * scrim opacity separates them by 3:1, so the drawer draws its own
+ * edge. Light palettes get that separation from the scrim. */
+ 'dark:border-border-xheavy dark:border-r',
expanded ? 'translate-x-0' : '-translate-x-full',
)}
style={{
@@ -221,9 +226,17 @@ function UnifiedSidebar({ isSliding = false }: { isSliding?: boolean }) {
links={links}
expanded={expanded}
onClose={handleCollapse}
+ onNewChat={handleCollapse}
onLeaveInsights={handleLeaveInsights}
routeActiveId={isInsightsRoute ? 'insights' : undefined}
/>
+ {/* Above the panel rather than inside it: the marketplace is a
+ destination like the panels themselves, not a row of whichever
+ list happens to be showing, so it stays put while they change and
+ does not scroll away with the chats. */}
+
+
+
-
+
diff --git a/client/src/components/UnifiedSidebar/__tests__/drawerPaint.spec.tsx b/client/src/components/UnifiedSidebar/__tests__/drawerPaint.spec.tsx
index 4fd08f72cfc..e939e2d4524 100644
--- a/client/src/components/UnifiedSidebar/__tests__/drawerPaint.spec.tsx
+++ b/client/src/components/UnifiedSidebar/__tests__/drawerPaint.spec.tsx
@@ -42,6 +42,7 @@ jest.mock('~/hooks/Nav/useUnifiedSidebarLinks', () => ({
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useChatHelpers: () => ({}),
+ useShowMarketplace: () => false,
}));
jest.mock('~/Providers', () => ({
diff --git a/client/src/components/UnifiedSidebar/mobile/BottomBar.tsx b/client/src/components/UnifiedSidebar/mobile/BottomBar.tsx
index 4b451e3a016..ee3282c3f58 100644
--- a/client/src/components/UnifiedSidebar/mobile/BottomBar.tsx
+++ b/client/src/components/UnifiedSidebar/mobile/BottomBar.tsx
@@ -1,90 +1,42 @@
-import { memo, useCallback } from 'react';
+import { memo } from 'react';
import { useRecoilValue } from 'recoil';
-import { SquarePen } from 'lucide-react';
-import { Button } from '@librechat/client';
import type { NavLink } from '~/common';
import { useActivePanel, resolveActivePanel, DEFAULT_PANEL } from '~/Providers';
-import { useShortcutAriaKey } from '~/hooks/useKeyboardShortcuts';
import SearchBar from '~/components/Nav/SearchBar';
-import useNewChat from '~/hooks/Chat/useNewChat';
-import { useLocalize } from '~/hooks';
+import { cn } from '~/utils';
import store from '~/store';
/**
- * Search and new chat are the two most frequent actions in the drawer, and both
- * sat in the top corner — the hardest place to reach one-handed. A flex footer
- * rather than an overlay, so the virtualized list shrinks around it and can
- * never be occluded.
+ * Search, in reach of a thumb. A flex footer rather than an overlay, so the
+ * virtualized list shrinks around it and can never be occluded.
+ *
+ * New chat used to sit here too and is now in the header strip: repeated under
+ * every panel it was a second, larger copy of a destination that has nothing to
+ * do with prompts, memories or MCP settings.
*/
-function BottomBar({
- links,
- onNewChat,
-}: {
- links: NavLink[];
- onNewChat: (afterSlide?: () => void) => void;
-}) {
- const localize = useLocalize();
+function BottomBar({ links }: { links: NavLink[] }) {
const search = useRecoilValue(store.search);
- const switchToHistory = useRecoilValue(store.newChatSwitchToHistory);
- const { active, setActive } = useActivePanel();
-
- const { startNewChat } = useNewChat();
-
- /**
- * Close first, reset second — inverted from `useNewChat`'s own click
- * handler: `startNewChat` clears the message cache and resets the
- * conversation, and run synchronously that commit stalls the drawer
- * slide's first frame on large conversations. The reset (and the panel
- * switch-back) ride the close's `afterSlide` instead, landing mid-slide.
- * The modified-click guard mirrors `useNewChat.handleNewChatClick` so a
- * ctrl/middle click still opens `/c/new` in a new tab.
- */
- const handleNewChatClick = useCallback(
- (event: React.MouseEvent) => {
- if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
- return;
- }
- event.preventDefault();
- onNewChat(() => {
- if (switchToHistory) {
- setActive(DEFAULT_PANEL);
- }
- startNewChat();
- });
- },
- [onNewChat, switchToHistory, setActive, startNewChat],
- );
- /** The shortcut fires globally; assistive tech needs it discoverable here too. */
- const newChatAriaKey = useShortcutAriaKey('newChat');
+ const { active } = useActivePanel();
/** Searching messages only means anything from the conversation list. */
const showSearch = search.enabled === true && resolveActivePanel(active, links) === DEFAULT_PANEL;
+ /** Without search the footer shrinks to the bottom safe-area inset alone, so
+ * the panel content still stops above the home indicator. */
return (
{showSearch && (
)}
-
);
}
diff --git a/client/src/components/UnifiedSidebar/mobile/Header.tsx b/client/src/components/UnifiedSidebar/mobile/Header.tsx
index 0a19aebbb89..fd03602658e 100644
--- a/client/src/components/UnifiedSidebar/mobile/Header.tsx
+++ b/client/src/components/UnifiedSidebar/mobile/Header.tsx
@@ -1,11 +1,11 @@
import { memo, lazy, Suspense, useEffect, useRef } from 'react';
import { Button, Sidebar, Skeleton } from '@librechat/client';
import type { NavLink } from '~/common';
-import AgentMarketplaceButton from '~/components/Nav/AgentMarketplaceButton';
import { CLOSE_SIDEBAR_ID } from '~/components/Chat/Menus/OpenSidebar';
import { useShortcutAriaKey } from '~/hooks/useKeyboardShortcuts';
import { useLocalize } from '~/hooks';
import Switcher from './Switcher';
+import NewChat from './NewChat';
const AccountSettings = lazy(() => import('~/components/Nav/AccountSettings'));
@@ -21,12 +21,14 @@ function Header({
links,
expanded,
onClose,
+ onNewChat,
onLeaveInsights,
routeActiveId,
}: {
links: NavLink[];
expanded: boolean;
onClose: () => void;
+ onNewChat: (afterSlide?: () => void) => void;
onLeaveInsights?: () => void;
routeActiveId?: string;
}) {
@@ -75,7 +77,7 @@ function Header({
onNavigate={onClose}
routeActiveId={routeActiveId}
/>
-
+ }>
diff --git a/client/src/components/UnifiedSidebar/mobile/NewChat.tsx b/client/src/components/UnifiedSidebar/mobile/NewChat.tsx
new file mode 100644
index 00000000000..7c40dfc9455
--- /dev/null
+++ b/client/src/components/UnifiedSidebar/mobile/NewChat.tsx
@@ -0,0 +1,74 @@
+import { memo, useCallback } from 'react';
+import { useRecoilValue } from 'recoil';
+import { SquarePen } from 'lucide-react';
+import { Button, TooltipAnchor } from '@librechat/client';
+import { useShortcutAriaKey } from '~/hooks/useKeyboardShortcuts';
+import { useActivePanel, DEFAULT_PANEL } from '~/Providers';
+import useNewChat from '~/hooks/Chat/useNewChat';
+import { useLocalize } from '~/hooks';
+import store from '~/store';
+
+/**
+ * New chat, in the drawer's header strip beside the panel switcher.
+ *
+ * It sat in the footer, which put it under the thumb but also under every panel,
+ * repeating a destination that has nothing to do with prompts or memories. The
+ * header is where the drawer keeps the controls that mean the same thing
+ * whichever panel is showing.
+ */
+function NewChat({ onNewChat }: { onNewChat: (afterSlide?: () => void) => void }) {
+ const localize = useLocalize();
+ const switchToHistory = useRecoilValue(store.newChatSwitchToHistory);
+ const { setActive } = useActivePanel();
+ const { startNewChat } = useNewChat();
+
+ /**
+ * Close first, reset second, the inverse of `useNewChat`'s own click
+ * handler: `startNewChat` clears the message cache and resets the
+ * conversation, and run synchronously that commit stalls the drawer
+ * slide's first frame on large conversations. The reset (and the panel
+ * switch-back) ride the close's `afterSlide` instead, landing mid-slide.
+ * The modified-click guard mirrors `useNewChat.handleNewChatClick` so a
+ * ctrl/middle click still opens `/c/new` in a new tab.
+ */
+ const handleNewChatClick = useCallback(
+ (event: React.MouseEvent) => {
+ if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
+ return;
+ }
+ event.preventDefault();
+ onNewChat(() => {
+ if (switchToHistory) {
+ setActive(DEFAULT_PANEL);
+ }
+ startNewChat();
+ });
+ },
+ [onNewChat, switchToHistory, setActive, startNewChat],
+ );
+
+ /** The shortcut fires globally; assistive tech needs it discoverable here too. */
+ const newChatAriaKey = useShortcutAriaKey('newChat');
+
+ return (
+
+
+
+
+
+ }
+ />
+ );
+}
+
+export default memo(NewChat);
diff --git a/client/src/components/UnifiedSidebar/mobile/Scrim.tsx b/client/src/components/UnifiedSidebar/mobile/Scrim.tsx
index a11167cfed3..2fc3b59c503 100644
--- a/client/src/components/UnifiedSidebar/mobile/Scrim.tsx
+++ b/client/src/components/UnifiedSidebar/mobile/Scrim.tsx
@@ -39,10 +39,7 @@ export default function Scrim({
tabIndex={expanded ? 0 : -1}
aria-hidden={!expanded || undefined}
className={cn(
- 'absolute inset-0 rounded-none bg-surface-overlay/50',
- /** Inset: the shell is overflow-hidden, and the shared ring's offset
- * puts it 2px outside the box, which the shell would clip. */
- 'focus-visible:ring-inset focus-visible:ring-offset-0',
+ 'group absolute inset-0 rounded-none',
!expanded && !isSliding && 'pointer-events-none',
)}
style={{
@@ -53,6 +50,19 @@ export default function Scrim({
opacity: expanded ? 1 : 0,
transition: prefersReducedMotion ? undefined : `opacity ${TRANSITION_MS}ms ${EASING}`,
}}
- />
+ >
+ {/* The dialogs' theme-owned scrim role, painted on its own layer so the
+ button itself carries no color of its own. */}
+
+ {/* The focus indicator, on a layer above the fill: the button's own ring
+ paints with its background, under the fill. Inset, because the shell
+ is overflow-hidden and would clip a ring drawn outside the box. Two
+ tones, because the fill is mid-gray in a light palette and black in a
+ dark one: the surface band carries it in light, the ring in dark. */}
+
+
);
}
diff --git a/client/src/components/UnifiedSidebar/mobile/__tests__/BottomBar.spec.tsx b/client/src/components/UnifiedSidebar/mobile/__tests__/BottomBar.spec.tsx
index d2fe5e5c361..88e7091cf9c 100644
--- a/client/src/components/UnifiedSidebar/mobile/__tests__/BottomBar.spec.tsx
+++ b/client/src/components/UnifiedSidebar/mobile/__tests__/BottomBar.spec.tsx
@@ -1,33 +1,13 @@
import React from 'react';
import { RecoilRoot } from 'recoil';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { act, cleanup, render, screen, fireEvent } from '@testing-library/react';
+import { render, screen, cleanup } from '@testing-library/react';
import type { NavLink } from '~/common';
-jest.mock('~/hooks', () => ({
- useLocalize: () => (key: string) => key,
-}));
-
-jest.mock('~/hooks/useKeyboardShortcuts', () => ({
- useShortcutAriaKey: () => 'Meta+Shift+O',
-}));
-
-jest.mock('@librechat/client', () => ({
- Button: ({
- children,
- asChild: _asChild,
- ...props
- }: {
- children: React.ReactNode;
- asChild?: boolean;
- }) =>
{children}
,
- /** The real `useNewChat` pulls the file-deletion mutation, which reads the toast context. */
- useToastContext: () => ({ showToast: jest.fn() }),
-}));
+const activePanel = { active: 'conversations' };
jest.mock('~/Providers', () => ({
- useActivePanel: () => ({ active: 'conversations', setActive: jest.fn() }),
- resolveActivePanel: () => 'conversations',
+ useActivePanel: () => ({ active: activePanel.active, setActive: jest.fn() }),
+ resolveActivePanel: (active: string) => active,
DEFAULT_PANEL: 'conversations',
}));
@@ -36,75 +16,52 @@ jest.mock('~/components/Nav/SearchBar', () => ({
default: () => ,
}));
-/** The heavy reset itself is the unit under order-test; only the navigation
- * internals below useNewChat are stubbed. */
-const mockNewConversation = jest.fn();
-jest.mock('~/hooks/useNewConvo', () => ({
- __esModule: true,
- default: () => ({ newConversation: mockNewConversation }),
-}));
-
import BottomBar from '../BottomBar';
+import store from '~/store';
const links = [] as NavLink[];
-let queryClient: QueryClient;
-
-describe('mobile bottom bar — new chat ordering', () => {
- beforeEach(() => {
- queryClient = new QueryClient();
- jest.clearAllMocks();
- });
+const renderBar = (searchEnabled = true) =>
+ render(
+
+ set(store.search, (prev) => ({ ...prev, enabled: searchEnabled }))
+ }
+ >
+
+ ,
+ );
+
+describe('mobile bottom bar', () => {
afterEach(() => {
+ activePanel.active = 'conversations';
cleanup();
- queryClient.clear();
});
- /**
- * The drawer close must start BEFORE the conversation reset: run
- * synchronously, the reset's cache clearing and navigation flush in the
- * tap's task and stall the slide's first frame. The reset rides the
- * close's `afterSlide` callback instead.
- */
- it('closes the drawer first and defers the conversation reset to afterSlide', () => {
- const order: string[] = [];
- let afterSlide: (() => void) | undefined;
- const onNewChat = jest.fn((callback?: () => void) => {
- order.push('close');
- afterSlide = callback;
- });
- mockNewConversation.mockImplementation(() => {
- order.push('reset');
- });
+ /** New chat moved to the header strip: repeated under every panel it was a
+ * second, larger copy of a destination the panel has nothing to do with. */
+ it('carries search and nothing else', () => {
+ renderBar();
- render(
-
-
-
-
- ,
- );
- fireEvent.click(screen.getByTestId('nav-new-chat-fab'));
+ expect(screen.getByTestId('search-bar')).toBeInTheDocument();
+ expect(screen.queryByTestId('nav-new-chat-fab')).not.toBeInTheDocument();
+ });
- expect(order).toEqual(['close']);
- expect(afterSlide).toBeDefined();
+ /** Searching messages only means anything from the conversation list. What
+ * remains is an empty spacer for the bottom safe-area inset, so the panel
+ * above still stops short of the home indicator. */
+ it('stands down to the safe-area spacer on a panel that has nothing to search', () => {
+ activePanel.active = 'prompts';
+ const { container } = renderBar();
- act(() => afterSlide?.());
- expect(order).toEqual(['close', 'reset']);
+ expect(screen.queryByTestId('search-bar')).not.toBeInTheDocument();
+ expect(container.firstElementChild).toBeEmptyDOMElement();
});
- it('leaves modified clicks to the browser (new tab)', () => {
- const onNewChat = jest.fn();
- render(
-
-
-
-
- ,
- );
- fireEvent.click(screen.getByTestId('nav-new-chat-fab'), { ctrlKey: true });
+ it('stands down to the safe-area spacer where the deployment has search off', () => {
+ const { container } = renderBar(false);
- expect(onNewChat).not.toHaveBeenCalled();
- expect(mockNewConversation).not.toHaveBeenCalled();
+ expect(screen.queryByTestId('search-bar')).not.toBeInTheDocument();
+ expect(container.firstElementChild).toBeEmptyDOMElement();
});
});
diff --git a/client/src/components/UnifiedSidebar/mobile/__tests__/Header.spec.tsx b/client/src/components/UnifiedSidebar/mobile/__tests__/Header.spec.tsx
index 0a4cefb7e94..4f4f961ee50 100644
--- a/client/src/components/UnifiedSidebar/mobile/__tests__/Header.spec.tsx
+++ b/client/src/components/UnifiedSidebar/mobile/__tests__/Header.spec.tsx
@@ -3,11 +3,8 @@ import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import type { NavLink } from '~/common';
-let mockShowMarketplace = true;
-
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
- useShowMarketplace: () => mockShowMarketplace,
}));
jest.mock('~/hooks/useKeyboardShortcuts', () => ({
@@ -35,6 +32,11 @@ jest.mock('../Switcher', () => ({
default: () => ,
}));
+jest.mock('../NewChat', () => ({
+ __esModule: true,
+ default: () => ,
+}));
+
jest.mock('~/components/Nav/AccountSettings', () => ({
__esModule: true,
default: () => ,
@@ -50,7 +52,9 @@ const links = [] as NavLink[];
describe('mobile drawer header', () => {
it('claims the close identity while the drawer is open', () => {
- render(, { wrapper: MemoryRouter });
+ render(, {
+ wrapper: MemoryRouter,
+ });
const close = screen.getByTestId('close-sidebar-button');
expect(close).toHaveAttribute('id', 'close-sidebar-button');
@@ -63,7 +67,7 @@ describe('mobile drawer header', () => {
* would find one sitting off-viewport and act on it.
*/
it('gives up that identity once closed', () => {
- render(, {
+ render(, {
wrapper: MemoryRouter,
});
@@ -73,7 +77,9 @@ describe('mobile drawer header', () => {
/** The only close control while open, so its binding must be discoverable here. */
it('advertises the toggle shortcut on the close control', () => {
- render(, { wrapper: MemoryRouter });
+ render(, {
+ wrapper: MemoryRouter,
+ });
expect(screen.getByTestId('close-sidebar-button')).toHaveAttribute(
'aria-keyshortcuts',
@@ -82,7 +88,7 @@ describe('mobile drawer header', () => {
});
it('keeps the closed drawer out of the tab order', () => {
- render(, {
+ render(, {
wrapper: MemoryRouter,
});
@@ -96,18 +102,21 @@ describe('mobile drawer header', () => {
* outlasts it (the id does not exist until `expanded` commits).
*/
it('moves focus to the toggle when the drawer opens', () => {
- const { rerender } = render(, {
- wrapper: MemoryRouter,
- });
+ const { rerender } = render(
+ ,
+ {
+ wrapper: MemoryRouter,
+ },
+ );
expect(document.activeElement).toBe(document.body);
- rerender();
+ rerender();
expect(document.activeElement).toBe(screen.getByTestId('close-sidebar-button'));
});
it('never steals focus while closed', () => {
- render(, {
+ render(, {
wrapper: MemoryRouter,
});
@@ -115,14 +124,17 @@ describe('mobile drawer header', () => {
});
/**
- * The toggle mirrors the chat header's OpenSidebar — same icon, same shared
- * Button variant, same far-left slot — so the drawer reads as the one
+ * The toggle mirrors the chat header's OpenSidebar (same icon, same shared
+ * Button variant, same far-left slot), so the drawer reads as the one
* persistent control flipping state rather than a new X appearing elsewhere.
*/
it('leads the row with the shared header-action toggle', () => {
- const { container } = render(, {
- wrapper: MemoryRouter,
- });
+ const { container } = render(
+ ,
+ {
+ wrapper: MemoryRouter,
+ },
+ );
const toggle = screen.getByTestId('close-sidebar-button');
expect(container.firstElementChild?.firstElementChild).toBe(toggle);
@@ -130,24 +142,15 @@ describe('mobile drawer header', () => {
expect(toggle.querySelector('[data-testid="sidebar-icon"]')).not.toBeNull();
});
- it('keeps an Agent Marketplace entry reachable from the drawer', () => {
- /* The drawer is the only sidebar surface on small screens, so losing this
- * entry here leaves marketplace users with no sidebar route to `/agents`. */
- render(, {
- wrapper: MemoryRouter,
- });
-
- const marketplace = screen.getByTestId('nav-agents-marketplace-button');
- expect(marketplace).toHaveAttribute('href', '/agents');
- });
-
- it('omits the marketplace entry without marketplace access', () => {
- mockShowMarketplace = false;
- render(, {
+ /** New chat took the marketplace icon's slot. It belongs beside the panel
+ * switcher because it means the same thing whichever panel is showing, which
+ * is exactly why it no longer repeats under each panel's contents. */
+ it('carries new chat in the strip, and not the marketplace', () => {
+ render(, {
wrapper: MemoryRouter,
});
+ expect(screen.getByTestId('nav-new-chat-fab')).toBeInTheDocument();
expect(screen.queryByTestId('nav-agents-marketplace-button')).not.toBeInTheDocument();
- mockShowMarketplace = true;
});
});
diff --git a/client/src/components/UnifiedSidebar/mobile/__tests__/NewChat.spec.tsx b/client/src/components/UnifiedSidebar/mobile/__tests__/NewChat.spec.tsx
new file mode 100644
index 00000000000..4a6cbd5e083
--- /dev/null
+++ b/client/src/components/UnifiedSidebar/mobile/__tests__/NewChat.spec.tsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import { RecoilRoot } from 'recoil';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, cleanup, render, screen, fireEvent } from '@testing-library/react';
+jest.mock('~/hooks', () => ({
+ useLocalize: () => (key: string) => key,
+}));
+
+jest.mock('~/hooks/useKeyboardShortcuts', () => ({
+ useShortcutAriaKey: () => 'Meta+Shift+O',
+}));
+
+jest.mock('@librechat/client', () => ({
+ Button: ({
+ children,
+ asChild: _asChild,
+ ...props
+ }: {
+ children: React.ReactNode;
+ asChild?: boolean;
+ }) =>
{children}
,
+ TooltipAnchor: ({ render }: { render: React.ReactNode }) => render,
+ /** The real `useNewChat` pulls the file-deletion mutation, which reads the toast context. */
+ useToastContext: () => ({ showToast: jest.fn() }),
+}));
+
+jest.mock('~/Providers', () => ({
+ useActivePanel: () => ({ active: 'conversations', setActive: jest.fn() }),
+ resolveActivePanel: () => 'conversations',
+ DEFAULT_PANEL: 'conversations',
+}));
+
+/** The heavy reset itself is the unit under order-test; only the navigation
+ * internals below useNewChat are stubbed. */
+const mockNewConversation = jest.fn();
+jest.mock('~/hooks/useNewConvo', () => ({
+ __esModule: true,
+ default: () => ({ newConversation: mockNewConversation }),
+}));
+
+import NewChat from '../NewChat';
+
+let queryClient: QueryClient;
+
+describe('mobile header new chat', () => {
+ beforeEach(() => {
+ queryClient = new QueryClient();
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ cleanup();
+ queryClient.clear();
+ });
+
+ /**
+ * The drawer close must start BEFORE the conversation reset: run
+ * synchronously, the reset's cache clearing and navigation flush in the
+ * tap's task and stall the slide's first frame. The reset rides the
+ * close's `afterSlide` callback instead.
+ */
+ it('closes the drawer first and defers the conversation reset to afterSlide', () => {
+ const order: string[] = [];
+ let afterSlide: (() => void) | undefined;
+ const onNewChat = jest.fn((callback?: () => void) => {
+ order.push('close');
+ afterSlide = callback;
+ });
+ mockNewConversation.mockImplementation(() => {
+ order.push('reset');
+ });
+
+ render(
+
+
+
+
+ ,
+ );
+ fireEvent.click(screen.getByTestId('nav-new-chat-fab'));
+
+ expect(order).toEqual(['close']);
+ expect(afterSlide).toBeDefined();
+
+ act(() => afterSlide?.());
+ expect(order).toEqual(['close', 'reset']);
+ });
+
+ it('leaves modified clicks to the browser (new tab)', () => {
+ const onNewChat = jest.fn();
+ render(
+
+
+
+
+ ,
+ );
+ fireEvent.click(screen.getByTestId('nav-new-chat-fab'), { ctrlKey: true });
+
+ expect(onNewChat).not.toHaveBeenCalled();
+ expect(mockNewConversation).not.toHaveBeenCalled();
+ });
+});
diff --git a/client/src/components/UnifiedSidebar/mobile/__tests__/Scrim.spec.tsx b/client/src/components/UnifiedSidebar/mobile/__tests__/Scrim.spec.tsx
index 6e97567d8f7..8002e1ecbda 100644
--- a/client/src/components/UnifiedSidebar/mobile/__tests__/Scrim.spec.tsx
+++ b/client/src/components/UnifiedSidebar/mobile/__tests__/Scrim.spec.tsx
@@ -46,22 +46,6 @@ describe('mobile drawer Scrim', () => {
expect(scrim).toHaveClass('pointer-events-none');
});
- /**
- * The parent shell is overflow-hidden and the global :focus-visible outline
- * sits 2px outside the box, so an inset ring is the only indicator that
- * actually paints.
- */
- it('keeps the focus ring inside the overflow-hidden shell', () => {
- const { scrim } = setup({ expanded: true });
-
- expect(scrim).toHaveClass(
- 'focus-visible:outline-hidden',
- 'focus-visible:ring-2',
- 'focus-visible:ring-inset',
- 'focus-visible:ring-focus-control',
- );
- });
-
/**
* The state commits before the drawer and pane finish moving, so releasing
* the pointer target early lets a tap through to a control sliding by
diff --git a/e2e/specs/mock/scenarios/mobile-drawer-controls.spec.ts b/e2e/specs/mock/scenarios/mobile-drawer-controls.spec.ts
new file mode 100644
index 00000000000..f5e52d3fff1
--- /dev/null
+++ b/e2e/specs/mock/scenarios/mobile-drawer-controls.spec.ts
@@ -0,0 +1,388 @@
+import { expect, test } from '@playwright/test';
+import type { Page } from '@playwright/test';
+import { clickHouseTheme } from '../../../../packages/client/src/theme/themes/clickhouse';
+import { NEW_CHAT_PATH } from '../helpers';
+
+/**
+ * The mobile drawer keeps one place for each control: new chat sits in the header strip beside
+ * the panel switcher, the marketplace is a labelled row above the panel content, and the footer
+ * holds search alone. The drawer also has to read as a surface of its own over the conversation
+ * it covers, in every bundled palette: the scrim separates it in light, and in dark (where the
+ * scrim and the drawer are both near-black) the drawer draws its own edge.
+ *
+ * Every scenario loads at phone width rather than resizing into it: a desktop-to-phone resize
+ * leaves the drawer on the desktop toggle path (berry-13/LibreChat#205).
+ */
+
+const DRAWER = '#mobile-drawer';
+const SCRIM = '#mobile-drawer-scrim';
+/** The layer inside the scrim button that paints the theme's scrim role. */
+const SCRIM_FILL = `${SCRIM} > span:first-child`;
+/** WCAG 1.4.11: a boundary that carries meaning needs 3:1 against its surround. */
+const BOUNDARY_CONTRAST = 3;
+/** WCAG 1.4.3: body text needs 4.5:1 against its background. */
+const TEXT_CONTRAST = 4.5;
+
+type Mode = 'light' | 'dark';
+type Rgb = [number, number, number];
+
+test.use({ viewport: { width: 390, height: 844 } });
+
+/**
+ * `strip` turns on the setting that leaves a strip of conversation beside the drawer. Only then is
+ * there a scrim, and something for the drawer to stand apart from: by default it covers the
+ * whole width.
+ */
+async function openDrawer(
+ page: Page,
+ mode: Mode = 'light',
+ definition?: { name: string },
+ strip = false,
+) {
+ await page.addInitScript(
+ ([appearance, stored, withStrip]) => {
+ localStorage.setItem('mobileDrawerStrip', JSON.stringify(withStrip));
+ localStorage.setItem('color-theme', appearance as string);
+ localStorage.removeItem('theme-colors');
+ localStorage.removeItem('theme-name');
+ if (stored) {
+ localStorage.setItem('theme-definition', JSON.stringify(stored));
+ localStorage.setItem('theme-source', 'definition');
+ } else {
+ localStorage.removeItem('theme-definition');
+ localStorage.removeItem('theme-source');
+ }
+ },
+ [mode, definition ?? null, strip] as [string, unknown, boolean],
+ );
+ await page.goto(NEW_CHAT_PATH, { timeout: 15000 });
+ await expect(page.getByRole('textbox', { name: 'Message input' })).toBeVisible({
+ timeout: 30000,
+ });
+ const root = page.locator('html');
+ await expect(root).toHaveClass(mode === 'dark' ? /\bdark\b/ : /\blight\b/);
+ if (definition) {
+ await expect(root).toHaveAttribute('data-theme', definition.name);
+ }
+
+ const drawer = page.locator(DRAWER);
+ if (!(await drawer.getByTestId('close-sidebar-button').isVisible())) {
+ await page.getByTestId('header-open-sidebar-button').click();
+ }
+ await expect(drawer.getByTestId('close-sidebar-button')).toBeVisible();
+ await expect(drawer).not.toHaveAttribute('inert');
+ /** The slide has to land before anything is measured against the viewport. */
+ await expect.poll(async () => (await drawer.boundingBox())?.x ?? -1, { timeout: 5000 }).toBe(0);
+ return drawer;
+}
+
+function parseRgb(color: string): { rgb: Rgb; alpha: number } {
+ const parts = color.match(/[\d.]+/g)?.map(Number) ?? [];
+ return { rgb: [parts[0], parts[1], parts[2]], alpha: parts.length > 3 ? parts[3] : 1 };
+}
+
+function luminance([r, g, b]: Rgb) {
+ const channel = (value: number) => {
+ const c = value / 255;
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ };
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
+}
+
+function contrast(a: Rgb, b: Rgb) {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+function over(top: string, bottom: string): Rgb {
+ const { rgb, alpha } = parseRgb(top);
+ const base = parseRgb(bottom).rgb;
+ return rgb.map((value, i) => value * alpha + base[i] * (1 - alpha)) as Rgb;
+}
+
+/**
+ * The drawer against the conversation it covers, read from the computed styles: the drawer's
+ * own fill, the page under the scrim with the scrim composited on top, and the drawer's
+ * trailing edge if it draws one.
+ */
+async function readBoundary(page: Page) {
+ const styles = await page.evaluate(
+ ([drawerSelector, scrimSelector]) => {
+ const drawer = document.querySelector(drawerSelector) as HTMLElement;
+ const scrim = document.querySelector(scrimSelector) as HTMLElement;
+ const drawerStyle = getComputedStyle(drawer);
+ /** The scrim sits over the chat pane, whose surface is the body's. */
+ const pageFill = getComputedStyle(document.body).backgroundColor;
+ return {
+ drawer: drawerStyle.backgroundColor,
+ edgeWidth: parseFloat(drawerStyle.borderRightWidth),
+ edgeColor: drawerStyle.borderRightColor,
+ scrim: getComputedStyle(scrim).backgroundColor,
+ page: pageFill,
+ };
+ },
+ [DRAWER, SCRIM_FILL],
+ );
+ const drawer = parseRgb(styles.drawer).rgb;
+ const dimmed = over(styles.scrim, styles.page);
+ const fill = contrast(drawer, dimmed);
+ const edge =
+ styles.edgeWidth >= 1
+ ? Math.max(
+ contrast(over(styles.edgeColor, styles.drawer), drawer),
+ contrast(over(styles.edgeColor, styles.drawer), dimmed),
+ )
+ : 0;
+ return { fill, edge, scrim: styles.scrim };
+}
+
+test.describe('mobile drawer controls', () => {
+ test('new chat sits beside the panel switcher and starts a chat from the drawer @scenario:mobile-drawer-new-chat-in-header', async ({
+ page,
+ }) => {
+ const drawer = await openDrawer(page);
+ const newChat = drawer.getByRole('link', { name: 'New chat' });
+ await expect(newChat).toHaveCount(1);
+ await expect(newChat).toBeVisible();
+
+ const switcher = await drawer.getByTestId('panel-switcher-button').boundingBox();
+ const button = await newChat.boundingBox();
+ const close = await drawer.getByTestId('close-sidebar-button').boundingBox();
+ expect(switcher && button && close).toBeTruthy();
+ /** Same strip as the switcher and the close toggle: their vertical centers line up. */
+ const center = (box: { y: number; height: number }) => box.y + box.height / 2;
+ expect(Math.abs(center(button!) - center(switcher!))).toBeLessThanOrEqual(2);
+ expect(Math.abs(center(button!) - center(close!))).toBeLessThanOrEqual(2);
+ expect(button!.x).toBeGreaterThanOrEqual(switcher!.x + switcher!.width - 1);
+
+ await page.keyboard.press('Shift');
+ await newChat.focus();
+ await page.keyboard.press('Enter');
+ await expect(page).toHaveURL(/\/c\/new$/);
+ await expect(drawer).toHaveAttribute('inert', '');
+ });
+
+ test('the marketplace is a labelled row above the panels that stays while they change @scenario:mobile-drawer-marketplace-row', async ({
+ page,
+ }) => {
+ const drawer = await openDrawer(page);
+ const marketplace = drawer.getByRole('link', { name: 'Agent Marketplace' });
+ await expect(marketplace).toBeVisible();
+ await expect(marketplace).toHaveText('Agent Marketplace');
+
+ const nav = drawer.locator('#chat-history-nav');
+ const before = await marketplace.boundingBox();
+ const navBox = await nav.boundingBox();
+ expect(before && navBox).toBeTruthy();
+ expect(before!.y + before!.height).toBeLessThanOrEqual(navBox!.y + 1);
+ /** A full-width row, not an icon in the header strip. */
+ expect(before!.width).toBeGreaterThan(200);
+
+ await drawer.getByTestId('panel-switcher-button').click();
+ const panels = page.getByRole('menuitemcheckbox');
+ await expect(panels.first()).toBeVisible();
+ const choices = await panels.count();
+ expect(choices).toBeGreaterThan(1);
+ await panels.nth(1).click();
+ await expect(page.getByRole('menuitemcheckbox')).toHaveCount(0);
+
+ const after = await marketplace.boundingBox();
+ expect(after).toEqual(before);
+
+ await marketplace.click();
+ await expect(page).toHaveURL(/\/agents/);
+ await expect(drawer).toHaveAttribute('inert', '');
+ });
+
+ test('without the marketplace the panels start right under the header strip @scenario:mobile-drawer-no-marketplace-no-gap', async ({
+ page,
+ }) => {
+ /** The role this page reads, with the marketplace permission off; nothing shared changes. */
+ await page.route('**/api/roles/*', async (route) => {
+ const response = await route.fetch();
+ const role = await response.json();
+ role.permissions = {
+ ...role.permissions,
+ MARKETPLACE: { ...role.permissions?.MARKETPLACE, USE: false },
+ };
+ await route.fulfill({ response, json: role });
+ });
+ const drawer = await openDrawer(page);
+ await expect(drawer.getByTestId('nav-agents-marketplace-button')).toHaveCount(0);
+
+ const header = await drawer.getByTestId('close-sidebar-button').locator('..').boundingBox();
+ const nav = await drawer.locator('#chat-history-nav').boundingBox();
+ expect(header && nav).toBeTruthy();
+ expect(Math.abs(nav!.y - (header!.y + header!.height))).toBeLessThanOrEqual(1);
+ });
+
+ test('the drawer footer holds only search and stands down where there is nothing to search @scenario:mobile-drawer-footer-search-only', async ({
+ page,
+ }) => {
+ const drawer = await openDrawer(page);
+ /** New chat left the footer for the header strip: one copy in the drawer, not two. */
+ await expect(drawer.getByRole('link', { name: 'New chat' })).toHaveCount(1);
+ await expect(drawer.getByTestId('nav-new-chat-fab')).toHaveCount(1);
+
+ await drawer.getByTestId('panel-switcher-button').click();
+ const panels = page.getByRole('menuitemcheckbox');
+ await expect(panels.first()).toBeVisible();
+ await panels.nth(1).click();
+ await expect(page.getByRole('menuitemcheckbox')).toHaveCount(0);
+
+ /** No footer beyond the safe-area inset (zero here): the panel runs to the drawer's bottom. */
+ await expect(drawer.getByRole('searchbox')).toHaveCount(0);
+ const nav = await drawer.locator('#chat-history-nav').boundingBox();
+ const box = await drawer.boundingBox();
+ expect(nav && box).toBeTruthy();
+ expect(box!.y + box!.height - (nav!.y + nav!.height)).toBeLessThanOrEqual(1);
+ });
+
+ /** Each tag is written out whole: the runner finds a scenario by its literal tag. */
+ const DARK_TEXT_CASES: Array<{ title: string; definition?: { name: string } }> = [
+ {
+ title:
+ 'the drawer header and marketplace row read in the default dark theme @scenario:mobile-drawer-text-readable-dark-default',
+ },
+ {
+ title:
+ 'the drawer header and marketplace row read in the ClickHouse dark theme @scenario:mobile-drawer-text-readable-dark-clickhouse',
+ definition: clickHouseTheme,
+ },
+ ];
+
+ for (const { title, definition } of DARK_TEXT_CASES) {
+ test(title, async ({ page }) => {
+ const drawer = await openDrawer(page, 'dark', definition);
+ const colors = await drawer.evaluate((node) => {
+ const label = Array.from(
+ node.querySelectorAll('a[data-testid="nav-agents-marketplace-button"] span'),
+ )[0];
+ return {
+ surface: getComputedStyle(node).backgroundColor,
+ drawerText: getComputedStyle(node).color,
+ label: label ? getComputedStyle(label).color : null,
+ };
+ });
+ expect(colors.label).not.toBeNull();
+ const surface = parseRgb(colors.surface).rgb;
+ expect(contrast(parseRgb(colors.drawerText).rgb, surface)).toBeGreaterThanOrEqual(
+ TEXT_CONTRAST,
+ );
+ expect(contrast(parseRgb(colors.label!).rgb, surface)).toBeGreaterThanOrEqual(TEXT_CONTRAST);
+ });
+ }
+
+ /** Each tag is written out whole: the runner finds a scenario by its literal tag. */
+ const BOUNDARY_CASES: Array<{ title: string; mode: Mode; definition?: { name: string } }> = [
+ {
+ title:
+ 'the drawer stands apart from the dimmed chat in the default light theme @scenario:mobile-drawer-boundary-default-light',
+ mode: 'light',
+ },
+ {
+ title:
+ 'the drawer stands apart from the dimmed chat in the default dark theme @scenario:mobile-drawer-boundary-default-dark',
+ mode: 'dark',
+ },
+ {
+ title:
+ 'the drawer stands apart from the dimmed chat in the ClickHouse light theme @scenario:mobile-drawer-boundary-clickhouse-light',
+ mode: 'light',
+ definition: clickHouseTheme,
+ },
+ {
+ title:
+ 'the drawer stands apart from the dimmed chat in the ClickHouse dark theme @scenario:mobile-drawer-boundary-clickhouse-dark',
+ mode: 'dark',
+ definition: clickHouseTheme,
+ },
+ ];
+
+ for (const { title, mode, definition } of BOUNDARY_CASES) {
+ test(title, async ({ page }) => {
+ await openDrawer(page, mode, definition, true);
+ await expect(page.locator(SCRIM)).toBeVisible();
+ const { fill, edge } = await readBoundary(page);
+ /** Either the fill against the scrim or the drawer's own edge has to carry it. */
+ expect(Math.max(fill, edge)).toBeGreaterThanOrEqual(BOUNDARY_CONTRAST);
+ if (mode === 'light') {
+ /** The scrim does the work in light: no extra line on a surface that already reads. */
+ expect(fill).toBeGreaterThanOrEqual(BOUNDARY_CONTRAST);
+ expect(edge).toBe(0);
+ }
+ });
+ }
+
+ /** Each tag is written out whole: the runner finds a scenario by its literal tag. */
+ const FOCUS_CASES: Array<{ title: string; mode: Mode; definition?: { name: string } }> = [
+ {
+ title:
+ 'the scrim follows the theme scrim role and shows its focus in the default light theme @scenario:mobile-drawer-scrim-focus-default-light',
+ mode: 'light',
+ },
+ {
+ title:
+ 'the scrim follows the theme scrim role and shows its focus in the default dark theme @scenario:mobile-drawer-scrim-focus-default-dark',
+ mode: 'dark',
+ },
+ {
+ title:
+ 'the scrim follows the theme scrim role and shows its focus in the ClickHouse light theme @scenario:mobile-drawer-scrim-focus-clickhouse-light',
+ mode: 'light',
+ definition: clickHouseTheme,
+ },
+ {
+ title:
+ 'the scrim follows the theme scrim role and shows its focus in the ClickHouse dark theme @scenario:mobile-drawer-scrim-focus-clickhouse-dark',
+ mode: 'dark',
+ definition: clickHouseTheme,
+ },
+ ];
+
+ for (const { title, mode, definition } of FOCUS_CASES) {
+ test(title, async ({ page }) => {
+ await openDrawer(page, mode, definition, true);
+ const scrim = page.locator(SCRIM);
+ /** The dialogs' theme-owned role: surface-overlay at the theme's scrim opacity. */
+ const role = await page.evaluate(() => {
+ const probe = document.createElement('div');
+ probe.className = 'bg-scrim';
+ document.body.append(probe);
+ const color = getComputedStyle(probe).backgroundColor;
+ probe.remove();
+ return color;
+ });
+ await expect(page.locator(SCRIM_FILL)).toHaveCSS('background-color', role);
+
+ /** Keyboard modality, so `:focus-visible` matches as it does for a Tab. */
+ await page.keyboard.press('Shift');
+ await scrim.focus();
+ await expect(scrim).toBeFocused();
+ /**
+ * The shell is overflow-hidden, so only an inset indicator shows, and it has to paint above
+ * the fill. Hit-testing follows paint order, so the topmost layer just inside the scrim's
+ * edge must be the one drawing the inset indicator, and one of its tones has to stand out
+ * from the fill.
+ */
+ const top = await scrim.evaluate((node) => {
+ const box = node.getBoundingClientRect();
+ const hit = document.elementsFromPoint(box.right - 1, box.top + box.height / 2)[0];
+ const fill = node.querySelector(':scope > span:first-child');
+ return {
+ inside: node.contains(hit),
+ shadow: hit ? getComputedStyle(hit).boxShadow : '',
+ fill: fill ? getComputedStyle(fill).backgroundColor : '',
+ page: getComputedStyle(document.body).backgroundColor,
+ };
+ });
+ expect(top.inside).toBe(true);
+ expect(top.shadow).toContain('inset');
+ const tones = top.shadow.match(/rgba?\([^)]*\)/g) ?? [];
+ expect(tones.length).toBeGreaterThan(0);
+ const dimmed = over(top.fill, top.page);
+ const best = Math.max(...tones.map((tone) => contrast(parseRgb(tone).rgb, dimmed)));
+ expect(best).toBeGreaterThanOrEqual(BOUNDARY_CONTRAST);
+ });
+ }
+});
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index dfcb80aa3a5..dfb30853e2d 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -2096,9 +2096,6 @@
"client/src/components/UnifiedSidebar/mobile/BottomBar.tsx": {
"shadcn/no-inline-styles": {
"count": 1
- },
- "shadcn/no-restyle": {
- "count": 4
}
},
"client/src/components/UnifiedSidebar/mobile/Scrim.tsx": {
@@ -2106,7 +2103,7 @@
"count": 2
},
"shadcn/no-restyle": {
- "count": 4
+ "count": 1
}
},
"client/src/components/Variables/Editor.tsx": {