From 3b9baf2487a903724e526f4c085039ef446efc7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=85=AC?= Date: Mon, 14 Sep 2026 19:15:54 +0800 Subject: [PATCH 1/6] feat(web-shell): slide the active pill between tabs The tab list used to recolor each trigger in place, so switching tabs (e.g. Tasks/Channels in the sidebar) read as a flat cross-fade. Render a single pill in the list that measures the active trigger and glides to it, arming transitions only after first paint and honoring reduced motion. --- .../components/ui/react18-ref-compat.test.tsx | 2 + .../client/components/ui/tabs.test.tsx | 105 +++++++++++++++ .../web-shell/client/components/ui/tabs.tsx | 124 +++++++++++++++--- .../e2e/web-shell.tabs-indicator.spec.ts | 109 +++++++++++++++ 4 files changed, 324 insertions(+), 16 deletions(-) create mode 100644 packages/web-shell/client/components/ui/tabs.test.tsx create mode 100644 packages/web-shell/client/e2e/web-shell.tabs-indicator.spec.ts diff --git a/packages/web-shell/client/components/ui/react18-ref-compat.test.tsx b/packages/web-shell/client/components/ui/react18-ref-compat.test.tsx index 82f76271ca2..3bf528a1baa 100644 --- a/packages/web-shell/client/components/ui/react18-ref-compat.test.tsx +++ b/packages/web-shell/client/components/ui/react18-ref-compat.test.tsx @@ -23,6 +23,7 @@ import { TableHeader, TableRow, } from './table'; +import { TabsList } from './tabs'; import { Tooltip, TooltipContent, @@ -59,6 +60,7 @@ describe('React 18 ref compatibility', () => { ['TableHead', TableHead], ['TableCell', TableCell], ['TableCaption', TableCaption], + ['TabsList', TabsList], ['TooltipTrigger', TooltipTrigger], ['TooltipContent', TooltipContent], ])('%s forwards refs', (_name, Component) => { diff --git a/packages/web-shell/client/components/ui/tabs.test.tsx b/packages/web-shell/client/components/ui/tabs.test.tsx new file mode 100644 index 00000000000..474f23b0f7a --- /dev/null +++ b/packages/web-shell/client/components/ui/tabs.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment jsdom +import * as React from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { Tabs, TabsList, TabsTrigger } from './tabs'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const mounted: Array<{ container: HTMLElement; root: Root }> = []; + +function renderTabs(ui: React.ReactElement): { + container: HTMLElement; + root: Root; +} { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ container, root }); + act(() => root.render(ui)); + return { container, root }; +} + +afterEach(() => { + while (mounted.length > 0) { + const { container, root } = mounted.pop()!; + act(() => root.unmount()); + container.remove(); + } +}); + +describe('TabsList sliding indicator', () => { + it('renders the indicator over the active trigger in the default variant', () => { + const { container } = renderTabs( + + + Tasks + Channels + + , + ); + + const indicator = container.querySelector( + '[data-slot="tabs-list-indicator"]', + ); + expect(indicator).not.toBeNull(); + expect((indicator as HTMLElement).style.opacity).toBe('1'); + expect( + container.querySelector('[data-slot="tabs-trigger"][data-state="active"]') + ?.textContent, + ).toBe('Tasks'); + }); + + it('hides the indicator when no trigger is active', () => { + const { container } = renderTabs( + + + Tasks + Channels + + , + ); + + const indicator = container.querySelector( + '[data-slot="tabs-list-indicator"]', + ); + expect(indicator).not.toBeNull(); + expect((indicator as HTMLElement).style.opacity).toBe('0'); + }); + + it('does not render the indicator in the line variant', () => { + const { container } = renderTabs( + + + Tasks + Channels + + , + ); + + expect( + container.querySelector('[data-slot="tabs-list-indicator"]'), + ).toBeNull(); + }); + + it('forwards a ref to the list element while rendering the indicator', () => { + const ref = React.createRef(); + const { container } = renderTabs( + + + Tasks + Channels + + , + ); + + expect(ref.current).toBe( + container.querySelector('[data-slot="tabs-list"]'), + ); + expect( + container.querySelector('[data-slot="tabs-list-indicator"]'), + ).not.toBeNull(); + }); +}); diff --git a/packages/web-shell/client/components/ui/tabs.tsx b/packages/web-shell/client/components/ui/tabs.tsx index 6a75c2bfecc..ac443703f0a 100644 --- a/packages/web-shell/client/components/ui/tabs.tsx +++ b/packages/web-shell/client/components/ui/tabs.tsx @@ -1,4 +1,10 @@ -import type * as React from 'react'; +import { forwardRef, useLayoutEffect, useRef, useState } from 'react'; +import type { + ComponentProps, + ComponentPropsWithoutRef, + CSSProperties, + RefObject, +} from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; import { Tabs as TabsPrimitive } from 'radix-ui'; @@ -8,7 +14,7 @@ function Tabs({ className, orientation = 'horizontal', ...props -}: React.ComponentProps) { +}: ComponentProps) { return ( & - VariantProps) { +// Measures the active trigger and positions the sliding pill behind it. +// MutationObserver (not the Tabs value) drives re-measurement so uncontrolled +// roots and keyboard navigation are covered; ResizeObserver keeps the pill +// glued to the trigger across container resizes. +function useSlidingIndicator( + listRef: RefObject, + enabled: boolean, +) { + const [style, setStyle] = useState({ opacity: 0 }); + const [ready, setReady] = useState(false); + + useLayoutEffect(() => { + const list = listRef.current; + if (!list || !enabled) { + return; + } + + const measure = () => { + const active = list.querySelector( + '[data-slot="tabs-trigger"][data-state="active"]', + ); + if (!active) { + setStyle((previous) => ({ ...previous, opacity: 0 })); + return; + } + setStyle({ + left: active.offsetLeft, + top: active.offsetTop, + width: active.offsetWidth, + height: active.offsetHeight, + opacity: 1, + }); + }; + + measure(); + // Transitions arm after the first paint so the initial position snaps + // instead of sliding in from the origin. + const frame = requestAnimationFrame(() => setReady(true)); + + const mutationObserver = new MutationObserver(measure); + mutationObserver.observe(list, { + attributes: true, + attributeFilter: ['data-state'], + childList: true, + subtree: true, + }); + const resizeObserver = new ResizeObserver(measure); + resizeObserver.observe(list); + + return () => { + cancelAnimationFrame(frame); + mutationObserver.disconnect(); + resizeObserver.disconnect(); + }; + }, [listRef, enabled]); + + return { style, ready }; +} + +type TabsListProps = ComponentPropsWithoutRef & + VariantProps; + +const TabsList = forwardRef(function TabsList( + { className, variant = 'default', children, ...props }, + forwardedRef, +) { + const listRef = useRef(null); + const { style, ready } = useSlidingIndicator(listRef, variant === 'default'); + + const setRefs = (node: HTMLDivElement | null) => { + listRef.current = node; + if (typeof forwardedRef === 'function') { + forwardedRef(node); + } else if (forwardedRef) { + forwardedRef.current = node; + } + }; + return ( + > + {variant === 'default' && ( + + )} + {children} + ); -} +}); function TabsTrigger({ className, ...props -}: React.ComponentProps) { +}: ComponentProps) { return ( ) { +}: ComponentProps) { return ( { + const box = el.getBoundingClientRect(); + return { left: box.left, width: box.width }; + }); + return rect; +} + +test.describe('session source switch sliding indicator', () => { + test('pill overlays the active trigger and slides on switch', async ({ + page, + }, testInfo) => { + await openSidebarWithSourceSwitch( + page, + String(testInfo.project.use.baseURL), + ); + + const tasksTab = page.getByRole('tab', { name: 'Tasks' }); + const channelsTab = page.getByRole('tab', { name: 'Channels' }); + const indicator = page.locator('[data-slot="tabs-list-indicator"]'); + await expect(indicator).toBeVisible(); + + const expectOverlay = async (tab: Locator) => { + const [tabBox, pillBox] = await Promise.all([ + horizontalBox(tab), + horizontalBox(indicator), + ]); + expect(pillBox.left).toBeCloseTo(tabBox.left, 0); + expect(pillBox.width).toBeCloseTo(tabBox.width, 0); + }; + + await expectOverlay(tasksTab); + + // Record the pill's left edge through the switch to prove it slides + // (intermediate positions) instead of cross-fading in place. + const tracePromise = page.evaluate(async () => { + const pill = document.querySelector('[data-slot="tabs-list-indicator"]'); + if (!pill) { + return []; + } + const samples: number[] = []; + const start = performance.now(); + await new Promise((resolve) => { + const tick = () => { + samples.push(pill.getBoundingClientRect().left); + if (performance.now() - start < 400) { + requestAnimationFrame(tick); + } else { + resolve(); + } + }; + requestAnimationFrame(tick); + }); + return samples; + }); + await channelsTab.click(); + const trace = await tracePromise; + + await expectOverlay(channelsTab); + + const startLeft = (await horizontalBox(tasksTab)).left; + const endLeft = (await horizontalBox(channelsTab)).left; + const intermediate = trace.filter( + (left) => + left > startLeft + 1 && left < endLeft - 1 && Number.isFinite(left), + ); + expect(intermediate.length).toBeGreaterThan(0); + }); + + test('no transition under prefers-reduced-motion', async ({ + page, + }, testInfo) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await openSidebarWithSourceSwitch( + page, + String(testInfo.project.use.baseURL), + ); + + const indicator = page.locator('[data-slot="tabs-list-indicator"]'); + await expect(indicator).toBeVisible(); + await expect(indicator).toHaveCSS('transition-property', 'none'); + }); +}); From de9f9a251c4af8177acfca3e01ee9b1f19c2324d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=85=AC?= Date: Mon, 14 Sep 2026 20:24:13 +0800 Subject: [PATCH 2/6] fix(web-shell): point the cursor at tabs The tab trigger is a plain button with no cursor rule, so it kept the UA default arrow; give it the pointer the rest of the sidebar uses. --- packages/web-shell/client/components/ui/tabs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-shell/client/components/ui/tabs.tsx b/packages/web-shell/client/components/ui/tabs.tsx index ac443703f0a..512f9aef039 100644 --- a/packages/web-shell/client/components/ui/tabs.tsx +++ b/packages/web-shell/client/components/ui/tabs.tsx @@ -155,7 +155,7 @@ function TabsTrigger({ Date: Mon, 14 Sep 2026 20:34:44 +0800 Subject: [PATCH 3/6] fix(web-shell): keep tab triggers inside the list bounds Flex triggers defaulted to min-width:auto, so on narrow lists their content could not shrink, justify-center split the overflow to both sides, and the active trigger (and the pill mirroring it) poked past the list's padding edge; unequal labels also produced unequal tab widths despite flex-1. min-w-0 lets the triggers shrink to equal shares, which keeps the indicator inside the frame. --- packages/web-shell/client/components/ui/tabs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-shell/client/components/ui/tabs.tsx b/packages/web-shell/client/components/ui/tabs.tsx index 512f9aef039..18b7ee54e62 100644 --- a/packages/web-shell/client/components/ui/tabs.tsx +++ b/packages/web-shell/client/components/ui/tabs.tsx @@ -155,7 +155,7 @@ function TabsTrigger({ Date: Mon, 14 Sep 2026 20:40:54 +0800 Subject: [PATCH 4/6] fix(web-shell): clip overflowing tab labels at the trigger box Once triggers can shrink (min-w-0), a label wider than its slot overflows symmetrically from the centered content and paints over the sliding pill's edge. Clip content to the trigger box in the default variant; the line variant keeps visible overflow because its underline sits outside the box. --- packages/web-shell/client/components/ui/tabs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-shell/client/components/ui/tabs.tsx b/packages/web-shell/client/components/ui/tabs.tsx index 18b7ee54e62..7233b5c5faf 100644 --- a/packages/web-shell/client/components/ui/tabs.tsx +++ b/packages/web-shell/client/components/ui/tabs.tsx @@ -155,7 +155,7 @@ function TabsTrigger({ Date: Mon, 14 Sep 2026 21:24:09 +0800 Subject: [PATCH 5/6] fix(web-shell): ellipsize sidebar tab labels instead of clipping them Equal halves can undercut a label in narrow sidebars or wider font environments, and the trigger's overflow guard then cut the text mid-glyph. Truncating spans turn that into a clean ellipsis while leaving the common case untouched. --- .../client/components/sidebar/WebShellSidebar.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index 5f98d2c83f0..6a790b4f020 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -5670,11 +5670,15 @@ export function WebShellSidebar({ > - {t('sidebar.sessionSource.tasks')} + + {t('sidebar.sessionSource.tasks')} + - {t('sidebar.sessionSource.channels')} + + {t('sidebar.sessionSource.channels')} + From c555bc455e1210c5cb7e83bedc83f268073c6eed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E5=85=AC?= Date: Tue, 15 Sep 2026 10:52:33 +0800 Subject: [PATCH 6/6] fix(web-shell): address review findings on the tab pill Keep the overflow policy of every other tab list intact: min-w-0 and the default-variant overflow guard move off the primitive; the sidebar keeps equal halves by opting its own triggers into min-w-0, which its truncating labels already support. Measure the pill with fractional rects so it lands exactly on half-pixel flex boundaries, skip the transition for resize-driven measurements so the pill tracks a sidebar drag 1:1, and arm transitions only once a measurable box exists so a list revealed from display:none never flies in from the origin. The pill now also dims with a disabled active trigger, TabsList stops advertising an asChild it can no longer honour, and the variant fallback resolves once. Tests cover the hide path for real and the resize-vs-switch animation split; the e2e spec joins the smoke gate. --- .../components/sidebar/WebShellSidebar.tsx | 4 +- .../client/components/ui/tabs.test.tsx | 125 +++++++++++++++--- .../web-shell/client/components/ui/tabs.tsx | 89 +++++++++---- .../e2e/web-shell.tabs-indicator.spec.ts | 43 +++++- 4 files changed, 213 insertions(+), 48 deletions(-) diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index 6a790b4f020..f97f5c84a8f 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -5668,13 +5668,13 @@ export function WebShellSidebar({ className="w-full" aria-label={t('sidebar.sessionSource')} > - + {t('sidebar.sessionSource.tasks')} - + {t('sidebar.sessionSource.channels')} diff --git a/packages/web-shell/client/components/ui/tabs.test.tsx b/packages/web-shell/client/components/ui/tabs.test.tsx index 474f23b0f7a..61a2bfbafa3 100644 --- a/packages/web-shell/client/components/ui/tabs.test.tsx +++ b/packages/web-shell/client/components/ui/tabs.test.tsx @@ -30,6 +30,15 @@ afterEach(() => { } }); +const INDICATOR = '[data-slot="tabs-list-indicator"]'; +const TRANSITION_CLASS = 'transition-['; + +async function flushFrame() { + await act(async () => { + await new Promise((resolve) => requestAnimationFrame(resolve)); + }); +} + describe('TabsList sliding indicator', () => { it('renders the indicator over the active trigger in the default variant', () => { const { container } = renderTabs( @@ -41,9 +50,7 @@ describe('TabsList sliding indicator', () => { , ); - const indicator = container.querySelector( - '[data-slot="tabs-list-indicator"]', - ); + const indicator = container.querySelector(INDICATOR); expect(indicator).not.toBeNull(); expect((indicator as HTMLElement).style.opacity).toBe('1'); expect( @@ -52,21 +59,30 @@ describe('TabsList sliding indicator', () => { ).toBe('Tasks'); }); - it('hides the indicator when no trigger is active', () => { - const { container } = renderTabs( - + it('hides the indicator when the active trigger disappears', async () => { + const { container, root } = renderTabs( + Tasks Channels , ); + const indicator = container.querySelector(INDICATOR) as HTMLElement; + expect(indicator.style.opacity).toBe('1'); - const indicator = container.querySelector( - '[data-slot="tabs-list-indicator"]', - ); - expect(indicator).not.toBeNull(); - expect((indicator as HTMLElement).style.opacity).toBe('0'); + await act(async () => { + root.render( + + + Tasks + Channels + + , + ); + }); + + expect(indicator.style.opacity).toBe('0'); }); it('does not render the indicator in the line variant', () => { @@ -79,9 +95,7 @@ describe('TabsList sliding indicator', () => { , ); - expect( - container.querySelector('[data-slot="tabs-list-indicator"]'), - ).toBeNull(); + expect(container.querySelector(INDICATOR)).toBeNull(); }); it('forwards a ref to the list element while rendering the indicator', () => { @@ -98,8 +112,85 @@ describe('TabsList sliding indicator', () => { expect(ref.current).toBe( container.querySelector('[data-slot="tabs-list"]'), ); - expect( - container.querySelector('[data-slot="tabs-list-indicator"]'), - ).not.toBeNull(); + expect(container.querySelector(INDICATOR)).not.toBeNull(); + }); + + it('animates mutation-driven moves but tracks resizes without a transition', async () => { + let resizeCallback: (() => void) | undefined; + const SharedResizeObserver = globalThis.ResizeObserver; + class CapturingResizeObserver { + constructor(callback: () => void) { + resizeCallback = callback; + } + observe() {} + unobserve() {} + disconnect() {} + } + Object.assign(globalThis, { + ResizeObserver: CapturingResizeObserver, + }); + + try { + const { container, root } = renderTabs( + + + Tasks + Channels + + , + ); + const indicator = container.querySelector(INDICATOR) as HTMLElement; + const list = container.querySelector( + '[data-slot="tabs-list"]', + ) as HTMLElement; + + // jsdom reports zero boxes; give the list and triggers plausible + // geometry so the resize-driven measurement has something to read. + const stubRect = (el: HTMLElement, left: number, width: number) => { + Object.defineProperty(el, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + left, + top: 0, + width, + height: 25, + right: left + width, + bottom: 25, + x: left, + y: 0, + toJSON: () => ({}), + }), + }); + }; + stubRect(list, 0, 200); + for (const [i, trigger] of [ + ...list.querySelectorAll('[data-slot="tabs-trigger"]'), + ].entries()) { + stubRect(trigger, 3 + i * 80, 80); + } + + // Resize-driven write: arms the hook and snaps — no transition class. + act(() => resizeCallback!()); + expect(indicator.className).not.toContain(TRANSITION_CLASS); + await flushFrame(); + expect(indicator.style.left).toBe('3px'); + expect(indicator.className).not.toContain(TRANSITION_CLASS); + + // Mutation-driven write (activation change): the transition arms. + await act(async () => { + root.render( + + + Tasks + Channels + + , + ); + }); + expect(indicator.style.left).toBe('83px'); + expect(indicator.className).toContain(TRANSITION_CLASS); + } finally { + Object.assign(globalThis, { ResizeObserver: SharedResizeObserver }); + } }); }); diff --git a/packages/web-shell/client/components/ui/tabs.tsx b/packages/web-shell/client/components/ui/tabs.tsx index 7233b5c5faf..32c6ee241ae 100644 --- a/packages/web-shell/client/components/ui/tabs.tsx +++ b/packages/web-shell/client/components/ui/tabs.tsx @@ -52,7 +52,10 @@ function useSlidingIndicator( listRef: RefObject, enabled: boolean, ) { - const [style, setStyle] = useState({ opacity: 0 }); + const [frame, setFrame] = useState<{ + style: CSSProperties; + animate: boolean; + }>({ style: { opacity: 0 }, animate: false }); const [ready, setReady] = useState(false); useLayoutEffect(() => { @@ -61,57 +64,91 @@ function useSlidingIndicator( return; } - const measure = () => { + // Transitions arm one frame after the first measurable box, so the + // initial position — and a reveal from display:none — snap instead of + // flying in from the origin. + let armed = false; + let armFrame: number | undefined; + const arm = () => { + if (armed) { + return; + } + armed = true; + armFrame = requestAnimationFrame(() => setReady(true)); + }; + + const measure = (animate: boolean) => { const active = list.querySelector( '[data-slot="tabs-trigger"][data-state="active"]', ); if (!active) { - setStyle((previous) => ({ ...previous, opacity: 0 })); + setFrame((previous) => ({ + animate: false, + style: { ...previous.style, opacity: 0 }, + })); return; } - setStyle({ - left: active.offsetLeft, - top: active.offsetTop, - width: active.offsetWidth, - height: active.offsetHeight, - opacity: 1, + const listRect = list.getBoundingClientRect(); + const rect = active.getBoundingClientRect(); + setFrame({ + animate, + style: { + left: rect.left - listRect.left + list.scrollLeft, + top: rect.top - listRect.top + list.scrollTop, + width: rect.width, + height: rect.height, + // The trigger fades when disabled; inline styles beat classes, so + // the pill's dimming has to live in the same inline write. + opacity: active.matches(':disabled') ? 0.5 : 1, + }, }); + if (rect.width > 0) { + arm(); + } }; - measure(); - // Transitions arm after the first paint so the initial position snaps - // instead of sliding in from the origin. - const frame = requestAnimationFrame(() => setReady(true)); + measure(false); - const mutationObserver = new MutationObserver(measure); + const mutationObserver = new MutationObserver(() => measure(true)); mutationObserver.observe(list, { attributes: true, - attributeFilter: ['data-state'], + attributeFilter: ['data-state', 'disabled'], childList: true, subtree: true, }); - const resizeObserver = new ResizeObserver(measure); + // Resize-driven moves skip the transition so the pill tracks a drag 1:1 + // instead of easing toward each intermediate target. + const resizeObserver = new ResizeObserver(() => measure(false)); resizeObserver.observe(list); return () => { - cancelAnimationFrame(frame); + if (armFrame !== undefined) { + cancelAnimationFrame(armFrame); + } mutationObserver.disconnect(); resizeObserver.disconnect(); }; }, [listRef, enabled]); - return { style, ready }; + return { style: frame.style, animated: ready && frame.animate }; } -type TabsListProps = ComponentPropsWithoutRef & +type TabsListProps = Omit< + ComponentPropsWithoutRef, + 'asChild' +> & VariantProps; const TabsList = forwardRef(function TabsList( - { className, variant = 'default', children, ...props }, + { className, variant, children, ...props }, forwardedRef, ) { + const resolvedVariant = variant ?? 'default'; const listRef = useRef(null); - const { style, ready } = useSlidingIndicator(listRef, variant === 'default'); + const { style, animated } = useSlidingIndicator( + listRef, + resolvedVariant === 'default', + ); const setRefs = (node: HTMLDivElement | null) => { listRef.current = node; @@ -126,17 +163,17 @@ const TabsList = forwardRef(function TabsList( - {variant === 'default' && ( + {resolvedVariant === 'default' && ( { - test('pill overlays the active trigger and slides on switch', async ({ + test('pill overlays the active trigger and slides on switch @smoke', async ({ page, }, testInfo) => { await openSidebarWithSourceSwitch( @@ -93,7 +93,7 @@ test.describe('session source switch sliding indicator', () => { expect(intermediate.length).toBeGreaterThan(0); }); - test('no transition under prefers-reduced-motion', async ({ + test('snaps without a transition under prefers-reduced-motion @smoke', async ({ page, }, testInfo) => { await page.emulateMedia({ reducedMotion: 'reduce' }); @@ -104,6 +104,43 @@ test.describe('session source switch sliding indicator', () => { const indicator = page.locator('[data-slot="tabs-list-indicator"]'); await expect(indicator).toBeVisible(); - await expect(indicator).toHaveCSS('transition-property', 'none'); + + const tasksLeft = ( + await horizontalBox(page.getByRole('tab', { name: 'Tasks' })) + ).left; + const channelsLeft = ( + await horizontalBox(page.getByRole('tab', { name: 'Channels' })) + ).left; + + const tracePromise = page.evaluate(async () => { + const pill = document.querySelector('[data-slot="tabs-list-indicator"]'); + if (!pill) { + return []; + } + const samples: number[] = []; + const start = performance.now(); + await new Promise((resolve) => { + const tick = () => { + samples.push(pill.getBoundingClientRect().left); + if (performance.now() - start < 400) { + requestAnimationFrame(tick); + } else { + resolve(); + } + }; + requestAnimationFrame(tick); + }); + return samples; + }); + await page.getByRole('tab', { name: 'Channels' }).click(); + const trace = await tracePromise; + + // The pill jumps: every sampled position is one of the two endpoints. + const endpoints = [tasksLeft, channelsLeft]; + const offEndpoint = trace.filter((left) => + endpoints.every((end) => Math.abs(left - end) > 1), + ); + expect(offEndpoint).toEqual([]); + expect(trace.some((left) => Math.abs(left - channelsLeft) <= 1)).toBe(true); }); });