From 8ea2d4d75face70e55a1397c30fe75641fe87bcc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 25 Sep 2026 22:37:48 -0700 Subject: [PATCH 1/3] fix(tabs): preserve readable labels in crowded tab strips --- apps/desktop/e2e/browser-chrome.spec.ts | 94 ++++++++++++++++--- apps/desktop/e2e/fixtures/browser-chrome.tsx | 1 + .../src/components/tab-strip/tab-strip.tsx | 76 +++++---------- 3 files changed, 105 insertions(+), 66 deletions(-) diff --git a/apps/desktop/e2e/browser-chrome.spec.ts b/apps/desktop/e2e/browser-chrome.spec.ts index a6917acf763..df68b5508b0 100644 --- a/apps/desktop/e2e/browser-chrome.spec.ts +++ b/apps/desktop/e2e/browser-chrome.spec.ts @@ -55,7 +55,7 @@ mountBrowserChromeFixture(useBrowserPanelOcclusion);`, response.end( path.endsWith('.js') ? bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text - : css.css + : `${css.css}\n${bundle.outputFiles.find((file) => file.path.endsWith('.css'))?.text ?? ''}` ) return } @@ -75,7 +75,7 @@ mountBrowserChromeFixture(useBrowserPanelOcclusion);`, response.end( path === '/page' ? '

Browser fixture

A live page behind the application chrome.

' - : '
' + : '
' ) }) await new Promise((resolve) => server?.listen(0, resolve)) @@ -116,31 +116,97 @@ mountBrowserChromeFixture(useBrowserPanelOcclusion);`, return { width: bounds.width, right: bounds.right } }) ) - expect(geometry.every((tab) => tab.width >= 64 && tab.width < 160)).toBe(true) + expect(geometry.every((tab) => tab.width < 160)).toBe(true) expect(geometry.at(-1)?.right).toBeLessThan(1070) await page.screenshot({ path: testInfo.outputPath('tabs.png') }) }) - await test.step('Short labels keep their compact intrinsic width', async () => { - await page.locator('#short-tabs').click() - const widths = await page - .locator('[data-tab-strip-item]') - .evaluateAll((tabs) => tabs.map((tab) => tab.getBoundingClientRect().width)) - expect(widths.every((width) => width >= 64 && width < 96)).toBe(true) - await page.locator('#eight-tabs').click() - }) - await test.step('Crowded tabs preserve controls and scroll', async () => { + await test.step('Crowded tabs keep readable labels when selected, hovered, and focused', async () => { await page.locator('#many-tabs').click() await expect(page.locator('[data-tab-strip-item]')).toHaveCount(18) const overflow = await page .locator('[data-tab-strip-item]') .first() .evaluate((tab) => ({ - width: tab.getBoundingClientRect().width, scrollWidth: tab.parentElement?.scrollWidth ?? 0, clientWidth: tab.parentElement?.clientWidth ?? 0, })) - expect(overflow.width).toBeGreaterThanOrEqual(64) expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth) + const active = page.getByRole('tab', { selected: true }) + const label = active.locator('[data-overflow-text]') + await expect + .poll(() => label.evaluate((element) => element.getBoundingClientRect().width)) + .toBeGreaterThanOrEqual(48) + const neighbor = page.getByRole('tab').nth(2) + const beforeHover = await neighbor.evaluate((element) => ({ + left: element.offsetLeft, + width: element.offsetWidth, + })) + await neighbor.hover() + await expect + .poll(() => + neighbor + .locator('[data-overflow-text]') + .evaluate((element) => element.getBoundingClientRect().width) + ) + .toBeGreaterThanOrEqual(48) + expect( + await neighbor.evaluate((element) => ({ + left: element.offsetLeft, + width: element.offsetWidth, + })) + ).toEqual(beforeHover) + await neighbor.click() + await expect(neighbor).toHaveAttribute('aria-selected', 'true') + await page.keyboard.press('End') + const last = page.getByRole('tab').last() + await expect(last).toBeFocused() + await expect(last).toHaveAttribute('aria-selected', 'true') + await expect(last).toBeInViewport({ ratio: 1 }) + await expect + .poll(() => label.evaluate((element) => element.getBoundingClientRect().width)) + .toBeGreaterThanOrEqual(48) + await page.mouse.move(200, 180) + await page.screenshot({ + path: testInfo.outputPath('crowded-tabs.png'), + animations: 'disabled', + }) + await page.evaluate(() => document.documentElement.classList.remove('dark')) + await page.screenshot({ + path: testInfo.outputPath('crowded-tabs-light.png'), + animations: 'disabled', + }) + await page.evaluate(() => document.documentElement.classList.add('dark')) + await page.keyboard.press('Home') + await expect(page.getByRole('tab').first()).toBeInViewport({ ratio: 1 }) + await page.locator('#eight-tabs').click() + }) + await test.step('Touch tabs leave room for both attention and close controls', async () => { + const session = await page.context().newCDPSession(page) + try { + await session.send('Emulation.setTouchEmulationEnabled', { enabled: true }) + expect(await page.evaluate(() => matchMedia('(any-pointer: coarse)').matches)).toBe(true) + await page.locator('#many-tabs').click() + const attention = page.getByRole('tab').nth(1) + await expect + .poll(() => + attention + .locator('[data-overflow-text]') + .evaluate((element) => element.getBoundingClientRect().width) + ) + .toBeGreaterThanOrEqual(48) + await page.screenshot({ path: testInfo.outputPath('crowded-tabs-touch.png') }) + } finally { + await session.send('Emulation.setTouchEmulationEnabled', { enabled: false }) + await session.detach() + } + await page.locator('#eight-tabs').click() + }) + await test.step('Short labels stay below the maximum tab width', async () => { + await page.locator('#short-tabs').click() + const widths = await page + .locator('[data-tab-strip-item]') + .evaluateAll((tabs) => tabs.map((tab) => tab.getBoundingClientRect().width)) + expect(widths.every((width) => width < 120)).toBe(true) await page.locator('#eight-tabs').click() }) await test.step('An open menu recovers when no native page was available for its initial capture', async () => { diff --git a/apps/desktop/e2e/fixtures/browser-chrome.tsx b/apps/desktop/e2e/fixtures/browser-chrome.tsx index 1e1ae4e5e53..580a81f16f9 100644 --- a/apps/desktop/e2e/fixtures/browser-chrome.tsx +++ b/apps/desktop/e2e/fixtures/browser-chrome.tsx @@ -112,6 +112,7 @@ function BrowserChromeFixture({ useOcclusion }: BrowserChromeFixtureProps) { title: shortTitles ? 'A' : `Example resource ${index + 1} with a descriptive title`, icon: , active: selected === `tab-${index}`, + attention: index === 1, }))} variant='floating' onSelect={setSelected} diff --git a/packages/emcn/src/components/tab-strip/tab-strip.tsx b/packages/emcn/src/components/tab-strip/tab-strip.tsx index 438b7b8d7bf..44f0def36b2 100644 --- a/packages/emcn/src/components/tab-strip/tab-strip.tsx +++ b/packages/emcn/src/components/tab-strip/tab-strip.tsx @@ -14,7 +14,14 @@ import { useRef, useState, } from 'react' -import { OverflowText, RowActions, rowActionsGroupClass } from '@sim/emcn' +import { + OverflowText, + RowActions, + rowActionsGroupClass, + SCROLL_FADE_BAND_PX, + scrollFadeAttributes, + scrollFadeXClass, +} from '@sim/emcn' import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' import { Plus, X } from '../../icons' import { cn } from '../../lib/cn' @@ -24,36 +31,6 @@ import { TabStripAction } from './tab-strip-action' const DRAG_EDGE_ZONE = 40 const DRAG_SCROLL_SPEED = 8 -/** - * Width of the scroll-edge fades, and so the margin a tab has to clear to be - * genuinely visible. Keep in step with the `w-4` on the gradients below: a tab - * revealed flush against the container edge lands under its gradient and reads - * as half-faded, which is indistinguishable from "there is more to scroll". - */ -const EDGE_FADE_PX = 24 - -/** - * Edge fades, as a mask rather than a tinted gradient laid over the tabs. - * - * Tabs paint their own fills, and an overlay tinted with the surface colour - * washes a pill's edge toward that colour instead of dissolving it — and it is - * only correct while whatever sits behind the strip is exactly that colour. A - * mask fades pill and label together to real transparency, over any background. - * This is how the command palette fades its results, and how every other - * horizontal fade in the app is drawn. - * - * The four combinations are spelled out because Tailwind scans for literal class - * strings; a template built at runtime would never be generated. Keep the 24px - * stops in step with {@link EDGE_FADE_PX}, which is how far `revealActiveTab` - * insets a tab so it lands clear of the fade rather than under it. - */ -const SCROLL_FADE = { - none: '', - start: - '[-webkit-mask-image:linear-gradient(to_right,transparent_0px,black_24px)] [mask-image:linear-gradient(to_right,transparent_0px,black_24px)]', - end: '[-webkit-mask-image:linear-gradient(to_right,black_calc(100%_-_24px),transparent_100%)] [mask-image:linear-gradient(to_right,black_calc(100%_-_24px),transparent_100%)]', - both: '[-webkit-mask-image:linear-gradient(to_right,transparent_0px,black_24px,black_calc(100%_-_24px),transparent_100%)] [mask-image:linear-gradient(to_right,transparent_0px,black_24px,black_calc(100%_-_24px),transparent_100%)]', -} as const const TAB_TRANSITION = { duration: 0.1, ease: [0.2, 0, 0, 1] as const } /** @@ -62,13 +39,14 @@ const TAB_TRANSITION = { duration: 0.1, ease: [0.2, 0, 0, 1] as const } * the basis and left every tab sized by its own title. * * Floating tabs start at their content width, capped at 200px, then shrink with - * the available space. Floating tabs stop at a 64px control footprint; attached - * tabs retain their 96px label minimum. Crowded rows then scroll, and clipped - * titles remain available through tooltips. + * the available space. Their 112px minimum leaves 50px for the title beside a + * 16px icon and visible close button, including OverflowText's fade. Keep the + * same minimum in every interaction state so revealing actions never shifts + * tabs beneath the pointer. Crowded rows then scroll. */ const TAB_WIDTH: Record = { attached: 'w-[156px] min-w-[96px] shrink', - floating: 'min-w-[64px] max-w-[var(--tab-strip-max-tab-width,200px)] shrink', + floating: 'min-w-28 max-w-[var(--tab-strip-max-tab-width,200px)] shrink', } /** The resting shape of a tab that is not the active one. */ @@ -427,9 +405,11 @@ const Tab = forwardRef(function Tab( className={cn( 'group relative select-none', rowActionsGroupClass, - // `shrink` lets a crowded strip squeeze tabs to their floor before it - // starts scrolling. tab.pinned ? 'w-[34px] min-w-[34px] max-w-[34px] flex-none' : TAB_WIDTH[variant], + variant === 'floating' && + closeable && + tab.attention && + '[@media(any-pointer:coarse)]:min-w-36 [@media(hover:none)]:min-w-36', dragging && 'opacity-30' )} data-tab-strip-item={tab.id} @@ -584,16 +564,15 @@ export function TabStrip({ const nodeRect = node.getBoundingClientRect() const tabLeft = tabRect.left - nodeRect.left + node.scrollLeft const tabRight = tabLeft + tabRect.width - // Inset by the fade on both sides so the tab comes to rest clear of the - // gradient rather than beneath it. - const viewLeft = node.scrollLeft + EDGE_FADE_PX - const viewRight = node.scrollLeft + node.clientWidth - EDGE_FADE_PX + /** Keep the active tab clear of the canonical scroll fade. */ + const viewLeft = node.scrollLeft + SCROLL_FADE_BAND_PX + const viewRight = node.scrollLeft + node.clientWidth - SCROLL_FADE_BAND_PX const maxScrollLeft = Math.max(0, node.scrollWidth - node.clientWidth) const target = tabLeft < viewLeft - ? tabLeft - EDGE_FADE_PX + ? tabLeft - SCROLL_FADE_BAND_PX : tabRight > viewRight - ? tabRight - node.clientWidth + EDGE_FADE_PX + ? tabRight - node.clientWidth + SCROLL_FADE_BAND_PX : null if (target === null) return // The clamp is what lets the first and last tabs sit flush: there is no @@ -926,18 +905,11 @@ export function TabStrip({
From d23e93d3d97ae105cd5ce9b908d2ce02fed25112 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 25 Sep 2026 22:42:31 -0700 Subject: [PATCH 2/3] fix(tabs): type layout checks and verify touch selection stability --- apps/desktop/e2e/browser-chrome.spec.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/desktop/e2e/browser-chrome.spec.ts b/apps/desktop/e2e/browser-chrome.spec.ts index df68b5508b0..2dc996c3dfa 100644 --- a/apps/desktop/e2e/browser-chrome.spec.ts +++ b/apps/desktop/e2e/browser-chrome.spec.ts @@ -137,7 +137,7 @@ mountBrowserChromeFixture(useBrowserPanelOcclusion);`, .poll(() => label.evaluate((element) => element.getBoundingClientRect().width)) .toBeGreaterThanOrEqual(48) const neighbor = page.getByRole('tab').nth(2) - const beforeHover = await neighbor.evaluate((element) => ({ + const beforeHover = await neighbor.evaluate((element: HTMLElement) => ({ left: element.offsetLeft, width: element.offsetWidth, })) @@ -150,7 +150,7 @@ mountBrowserChromeFixture(useBrowserPanelOcclusion);`, ) .toBeGreaterThanOrEqual(48) expect( - await neighbor.evaluate((element) => ({ + await neighbor.evaluate((element: HTMLElement) => ({ left: element.offsetLeft, width: element.offsetWidth, })) @@ -195,6 +195,14 @@ mountBrowserChromeFixture(useBrowserPanelOcclusion);`, ) .toBeGreaterThanOrEqual(48) await page.screenshot({ path: testInfo.outputPath('crowded-tabs-touch.png') }) + const beforeSelection = await attention.evaluate( + (element: HTMLElement) => element.offsetWidth + ) + await attention.click() + await expect(attention).toHaveAttribute('aria-selected', 'true') + expect(await attention.evaluate((element: HTMLElement) => element.offsetWidth)).toBe( + beforeSelection + ) } finally { await session.send('Emulation.setTouchEmulationEnabled', { enabled: false }) await session.detach() From 9f8d3e708513846b2b8b2f6ffc653b69257e0880 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 25 Sep 2026 22:51:09 -0700 Subject: [PATCH 3/3] fix(tabs): keep touch sizing stable across activity changes --- apps/desktop/e2e/browser-chrome.spec.ts | 45 +++++++++++++++++++ apps/desktop/e2e/fixtures/browser-chrome.tsx | 28 +++++++++--- .../src/components/tab-strip/tab-strip.tsx | 8 ++-- 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/apps/desktop/e2e/browser-chrome.spec.ts b/apps/desktop/e2e/browser-chrome.spec.ts index 2dc996c3dfa..c5857f80ea0 100644 --- a/apps/desktop/e2e/browser-chrome.spec.ts +++ b/apps/desktop/e2e/browser-chrome.spec.ts @@ -194,6 +194,30 @@ mountBrowserChromeFixture(useBrowserPanelOcclusion);`, .evaluate((element) => element.getBoundingClientRect().width) ) .toBeGreaterThanOrEqual(48) + const attentionItem = page.locator('[data-tab-strip-item="tab-1"]') + const indicator = attentionItem.locator('[data-row-action-indicator]') + const controls = attentionItem.locator('[data-row-action-controls]') + await expect(indicator).toBeVisible() + await expect(indicator).toHaveCSS('opacity', '1') + await expect(controls).toHaveCSS('opacity', '1') + await attentionItem.getByRole('button', { name: /^Close / }).click({ trial: true }) + expect( + await attentionItem.evaluate((element) => { + const title = element.querySelector('[data-overflow-text]')?.getBoundingClientRect() + const indicator = element + .querySelector('[data-row-action-indicator]') + ?.getBoundingClientRect() + const close = element.querySelector('[aria-label^="Close "]')?.getBoundingClientRect() + return ( + title && + indicator && + close && + title.right <= indicator.left && + indicator.right <= close.left && + close.right <= element.getBoundingClientRect().right + ) + }) + ).toBe(true) await page.screenshot({ path: testInfo.outputPath('crowded-tabs-touch.png') }) const beforeSelection = await attention.evaluate( (element: HTMLElement) => element.offsetWidth @@ -203,6 +227,27 @@ mountBrowserChromeFixture(useBrowserPanelOcclusion);`, expect(await attention.evaluate((element: HTMLElement) => element.offsetWidth)).toBe( beforeSelection ) + await page.locator('#toggle-activity').click() + expect(await attention.evaluate((element: HTMLElement) => element.offsetWidth)).toBe( + beforeSelection + ) + await page.locator('#toggle-activity').click() + await page.locator('#medium-tabs').click() + await page.getByRole('tab').first().click() + const intrinsicWidth = await attention.evaluate( + (element: HTMLElement) => element.offsetWidth + ) + expect(intrinsicWidth).toBeGreaterThan(144) + expect(intrinsicWidth).toBeLessThan(200) + await attention.click() + expect(await attention.evaluate((element: HTMLElement) => element.offsetWidth)).toBe( + intrinsicWidth + ) + await page.locator('#toggle-activity').click() + expect(await attention.evaluate((element: HTMLElement) => element.offsetWidth)).toBe( + intrinsicWidth + ) + await page.locator('#toggle-activity').click() } finally { await session.send('Emulation.setTouchEmulationEnabled', { enabled: false }) await session.detach() diff --git a/apps/desktop/e2e/fixtures/browser-chrome.tsx b/apps/desktop/e2e/fixtures/browser-chrome.tsx index 580a81f16f9..0fa62c97561 100644 --- a/apps/desktop/e2e/fixtures/browser-chrome.tsx +++ b/apps/desktop/e2e/fixtures/browser-chrome.tsx @@ -46,7 +46,8 @@ function BrowserChromeFixture({ useOcclusion }: BrowserChromeFixtureProps) { const [activeTabId, setActiveTabId] = useState(null) const [selected, setSelected] = useState('tab-0') const [tabCount, setTabCount] = useState(8) - const [shortTitles, setShortTitles] = useState(false) + const [titleLength, setTitleLength] = useState<'short' | 'medium' | 'long'>('long') + const [attention, setAttention] = useState(true) const [error, setError] = useState(null) const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop const { snapshot, snapshotLayer, onSnapshotError } = useOcclusion( @@ -93,7 +94,7 @@ function BrowserChromeFixture({ useOcclusion }: BrowserChromeFixtureProps) { id='eight-tabs' onClick={() => { setTabCount(8) - setShortTitles(false) + setTitleLength('long') }} > Eight tabs @@ -101,18 +102,35 @@ function BrowserChromeFixture({ useOcclusion }: BrowserChromeFixtureProps) { - + +
{error &&

{error}

} ({ id: `tab-${index}`, - title: shortTitles ? 'A' : `Example resource ${index + 1} with a descriptive title`, + title: + titleLength === 'short' + ? 'A' + : titleLength === 'medium' + ? 'Medium title' + : `Example resource ${index + 1} with a descriptive title`, icon: , active: selected === `tab-${index}`, - attention: index === 1, + attention: attention && index === 1, }))} variant='floating' onSelect={setSelected} diff --git a/packages/emcn/src/components/tab-strip/tab-strip.tsx b/packages/emcn/src/components/tab-strip/tab-strip.tsx index 44f0def36b2..baef5cd3202 100644 --- a/packages/emcn/src/components/tab-strip/tab-strip.tsx +++ b/packages/emcn/src/components/tab-strip/tab-strip.tsx @@ -42,7 +42,9 @@ const TAB_TRANSITION = { duration: 0.1, ease: [0.2, 0, 0, 1] as const } * the available space. Their 112px minimum leaves 50px for the title beside a * 16px icon and visible close button, including OverflowText's fade. Keep the * same minimum in every interaction state so revealing actions never shifts - * tabs beneath the pointer. Crowded rows then scroll. + * tabs beneath the pointer. Touch layouts reserve both action slots even when + * no activity indicator is present, so activity changes cannot resize tabs. + * Crowded rows then scroll. */ const TAB_WIDTH: Record = { attached: 'w-[156px] min-w-[96px] shrink', @@ -356,8 +358,7 @@ const Tab = forwardRef(function Tab( tab.pinned ? 'justify-center px-0' : 'justify-start gap-1.5 px-2', closeable && 'pr-8', closeable && - tab.attention && - !tab.active && + (variant === 'floating' || (tab.attention && !tab.active)) && '[@media(any-pointer:coarse)]:pr-[62px] [@media(hover:none)]:pr-[62px]', TAB_SHAPE[variant], tab.selected && !tab.active && TAB_SELECTED[variant], @@ -408,7 +409,6 @@ const Tab = forwardRef(function Tab( tab.pinned ? 'w-[34px] min-w-[34px] max-w-[34px] flex-none' : TAB_WIDTH[variant], variant === 'floating' && closeable && - tab.attention && '[@media(any-pointer:coarse)]:min-w-36 [@media(hover:none)]:min-w-36', dragging && 'opacity-30' )}