From caa9d4708550a6e09e18da463f5e040798031ea4 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Tue, 29 Sep 2026 08:11:17 -0700 Subject: [PATCH 01/23] editor: restore connected tab border customizations Keep pill tabs on fill-owned borders while allowing connected tabs to use the standard full-width theme border indicators. Add component fixtures and computed-style coverage for active, unfocused, and selected border colors. Fixes #325250. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../editor/media/multieditortabscontrol.css | 2 +- .../parts/editor/multiEditorTabsControl.ts | 4 +- .../browser/media/connectedEditorTabs.css | 1 + .../contrib/modernUI/browser/media/tabs.css | 10 +-- .../browser/modernUI.contribution.test.ts | 51 ++++++++++++++++ .../editor/editorTabBar.fixture.ts | 61 ++++++++++++++++++- .../playwright/tests/editorTabBar.spec.ts | 39 ++++++++++++ 7 files changed, 154 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css index 72ad2119dd0399..2dc734f54f1c61 100644 --- a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css +++ b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css @@ -316,7 +316,7 @@ .monaco-workbench .part.editor > .content .editor-group-container > .title:not(.two-tab-bars) .tabs-container > .tab.active.tab-border-bottom > .tab-border-bottom-container, .monaco-workbench .part.editor > .content .editor-group-container > .title.two-tab-bars .tabs-and-actions-container:not(:first-child) .tabs-container > .tab.active.tab-border-bottom > .tab-border-bottom-container, .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.dirty-border-top > .tab-border-top-container { - display: block; + display: var(--editor-tab-border-indicator-display, block); position: absolute; left: 0; pointer-events: none; diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index d315c8f77e69e6..8bea3273432b5d 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -2798,7 +2798,7 @@ registerThemingParticipant((theme, collector) => { if (tabHoverBorder) { collector.addRule(` .monaco-workbench .part.editor > .content .editor-group-container.active > .title .tabs-container > .tab:hover > .tab-border-bottom-container { - display: block; + display: var(--editor-tab-border-indicator-display, block); position: absolute; left: 0; pointer-events: none; @@ -2815,7 +2815,7 @@ registerThemingParticipant((theme, collector) => { if (tabUnfocusedHoverBorder) { collector.addRule(` .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:hover > .tab-border-bottom-container { - display: block; + display: var(--editor-tab-border-indicator-display, block); position: absolute; left: 0; pointer-events: none; diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 27a566d256b49a..37d93e782ff337 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -16,6 +16,7 @@ .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container, .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .modern-ui-editor-tab-group { + --editor-tab-border-indicator-display: block; --modern-ui-connected-tab-border: var(--vscode-editorGroupHeader-tabsBorder, var(--vscode-tab-border, var(--modern-ui-connected-tab-surface))); --modern-ui-connected-well-border: transparent; } diff --git a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css index 3feb7745e5f302..521e0384de6f71 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css @@ -47,6 +47,7 @@ --modern-ui-editor-tab-action-active-hover-background: var(--vscode-modernEditorTab-activeHoverActionBackground); --modern-ui-editor-tab-action-unfocused-active-hover-background: var(--modern-ui-editor-tab-action-active-hover-background); --modern-ui-editor-tabs-border: transparent; + --editor-tab-border-indicator-display: none; } /* Keep connected labels readable without overriding explicit legacy color customizations. */ @@ -75,7 +76,6 @@ font-weight: var(--vscode-fontWeight-regular); box-shadow: none !important; padding: 0 var(--vscode-spacing-size80) 0 var(--vscode-spacing-size60) !important; - --tab-border-top-color: transparent !important; } .modern-ui-tabs .part.editor > .content .editor-group-container > .title.tabs:not(.show-file-icons) .tabs-container > .tab:not(.sticky-compact):not(.tab-actions-left) { @@ -276,10 +276,6 @@ border-block-width: var(--vscode-spacing-size20) var(--vscode-spacing-size60); } -.modern-ui-tabs .part.editor .tabs-container > .tab .tab-border-bottom-container { - display: none !important; -} - /* Tab fills retain the theme's transparency. The action overlay uses a separately flattened * version of the same color so labels beneath the close button cannot show through. */ .modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor .editor-group-container.active .tabs-container > .tab:not(.selected):not(.active) > .tab-fill, @@ -602,10 +598,6 @@ z-index: 11; } -.modern-ui-tabs .part.editor .tabs-container > .tab .tab-border-top-container { - display: none !important; -} - .modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.dirty.dirty-border-top > .tab-border-top-container { display: block !important; position: absolute; diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index e6dbb490b73447..30cbb42fed579c 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -2220,6 +2220,57 @@ suite('ModernUIContribution', () => { }); }); + test('uses legacy border customizations for connected tabs', () => { + const root = appendElement(document.body, 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'); + store.add(toDisposable(() => root.remove())); + const content = appendElement(appendElement(root, 'part editor'), 'content'); + const createGroup = (active: boolean) => { + const group = appendElement(content, `editor-group-container${active ? ' active' : ''}`); + const title = appendElement(group, 'title tabs'); + const tabs = appendElement(appendElement(title, 'tabs-and-actions-container'), 'tabs-container'); + const activeTab = appendElement(tabs, 'tab active tab-border-top tab-border-bottom'); + activeTab.style.setProperty('--tab-border-top-color', active ? '#123456' : '#234567'); + activeTab.style.setProperty('--tab-border-bottom-color', active ? '#345678' : '#456789'); + appendElement(activeTab, 'tab-fill'); + const activeTopBorder = appendElement(activeTab, 'tab-border-top-container'); + const activeBottomBorder = appendElement(activeTab, 'tab-border-bottom-container'); + const selectedTab = appendElement(tabs, 'tab selected tab-border-top'); + selectedTab.style.setProperty('--tab-border-top-color', '#56789a'); + appendElement(selectedTab, 'tab-fill'); + const selectedTopBorder = appendElement(selectedTab, 'tab-border-top-container'); + return { activeTopBorder, activeBottomBorder, selectedTopBorder }; + }; + const activeGroup = createGroup(true); + const unfocusedGroup = createGroup(false); + const targetWindow = getWindow(root); + const borderStyles = (group: ReturnType) => { + const activeTop = targetWindow.getComputedStyle(group.activeTopBorder); + const activeBottom = targetWindow.getComputedStyle(group.activeBottomBorder); + const selectedTop = targetWindow.getComputedStyle(group.selectedTopBorder); + return { + activeTop: [activeTop.display, activeTop.backgroundColor], + activeBottom: [activeBottom.display, activeBottom.backgroundColor], + selectedTop: [selectedTop.display, selectedTop.backgroundColor], + }; + }; + + assert.deepStrictEqual({ + activeGroup: borderStyles(activeGroup), + unfocusedGroup: borderStyles(unfocusedGroup), + }, { + activeGroup: { + activeTop: ['block', 'rgb(18, 52, 86)'], + activeBottom: ['block', 'rgb(52, 86, 120)'], + selectedTop: ['block', 'rgb(86, 120, 154)'], + }, + unfocusedGroup: { + activeTop: ['block', 'rgb(35, 69, 103)'], + activeBottom: ['block', 'rgb(69, 103, 137)'], + selectedTop: ['block', 'rgb(86, 120, 154)'], + }, + }); + }); + test('uses separate connected strip and border colors without changing the editor group header', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'; diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts index 77f2a597bf9577..bfa6e87d74d86f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -6,6 +6,7 @@ import { $, Dimension, getWindow, scheduleAtNextAnimationFrame } from '../../../../../base/browser/dom.js'; import { Action } from '../../../../../base/common/actions.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Color } from '../../../../../base/common/color.js'; import { Event } from '../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; @@ -23,7 +24,7 @@ import { ContextKeyService } from '../../../../../platform/contextkey/browser/co import { listErrorForeground, listWarningForeground } from '../../../../../platform/theme/common/colors/listColors.js'; import { isDark } from '../../../../../platform/theme/common/theme.js'; import { asCssVariableName } from '../../../../../platform/theme/common/colorUtils.js'; -import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { IColorTheme, IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { testWorkspace } from '../../../../../platform/workspace/test/common/testWorkspace.js'; @@ -46,6 +47,11 @@ import { MODERN_EDITOR_TAB_HOVER_FOREGROUND, MODERN_EDITOR_TAB_INACTIVE_BACKGROUND, MODERN_EDITOR_TAB_SELECTED_ACTION_BACKGROUND, + TAB_ACTIVE_BORDER, + TAB_ACTIVE_BORDER_TOP, + TAB_SELECTED_BORDER_TOP, + TAB_UNFOCUSED_ACTIVE_BORDER, + TAB_UNFOCUSED_ACTIVE_BORDER_TOP, } from '../../../../common/theme.js'; import { DEFAULT_EDITOR_PART_OPTIONS, IEditorGroupMenuIds, IEditorGroupsView, IEditorGroupView, IEditorPartsView } from '../../../../browser/parts/editor/editor.js'; import { BreadcrumbsService, IBreadcrumbsService } from '../../../../browser/parts/editor/breadcrumbs.js'; @@ -377,6 +383,25 @@ export interface IEditorTabBarFixtureOptions { readonly activeTabClipping?: 'left' | 'right' | 'left-shoulder' | 'right-shoulder'; } +function customizeTheme(theme: IColorTheme, customizations: Readonly> | undefined): IColorTheme { + if (!customizations) { + return theme; + } + + const colors = new Map(Object.entries(customizations).map(([colorId, value]) => [colorId, Color.fromHex(value)])); + return new Proxy(theme, { + get(target, property, receiver) { + if (property === 'getColor') { + return (colorId: string, useDefault?: boolean) => colors.get(colorId) ?? target.getColor(colorId, useDefault); + } + if (property === 'defines') { + return (colorId: string) => colors.has(colorId) || target.defines(colorId); + } + return Reflect.get(target, property, receiver); + } + }); +} + function createPartOptions(overrides?: Partial): IEditorPartOptions { return { ...DEFAULT_EDITOR_PART_OPTIONS, @@ -438,7 +463,7 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: // Feed the fixture's themes to the shared theme service so tab-bar theme lookups resolve. const themeService = instantiationService.get(IThemeService) as TestThemeService; - themeService.setTheme(theme); + themeService.setTheme(customizeTheme(theme, options.colorCustomizations)); themeService.setFileIconTheme(fileIconTheme); // Services the base workbench harness does not stub but the tab bar needs. @@ -777,6 +802,24 @@ function getModernEditorTabColorCustomizations(theme: ComponentFixtureContext['t }; } +function getLegacyEditorTabBorderCustomizations(): Readonly> { + return { + [TAB_ACTIVE_BORDER]: '#F43F5E', + [TAB_ACTIVE_BORDER_TOP]: '#22D3EE', + [TAB_UNFOCUSED_ACTIVE_BORDER]: '#FB923C', + [TAB_UNFOCUSED_ACTIVE_BORDER_TOP]: '#C084FC', + [TAB_SELECTED_BORDER_TOP]: '#A3E635', + }; +} + +function renderConnectedLegacyBorders(active: boolean): (ctx: ComponentFixtureContext) => void { + return render(true, { + active, + editors: multiSelectEditorSpecs(), + colorCustomizations: getLegacyEditorTabBorderCustomizations(), + }); +} + function renderThemeColors(options: Omit): (ctx: ComponentFixtureContext) => void { return ctx => { ctx.container.classList.add('modern-ui'); @@ -885,6 +928,20 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { ], }), }), + ConnectedLegacyBorders: defineThemedFixtureGroup({ + ActiveGroup: defineComponentFixture({ + render: renderConnectedLegacyBorders(true), + expectedVisualDescriptions: [ + 'The active connected tab shows the customized cyan top border and pink bottom border without breaking its connection to the editor. Other selected tabs show the customized lime selection border.', + ], + }), + InactiveGroup: defineComponentFixture({ + render: renderConnectedLegacyBorders(false), + expectedVisualDescriptions: [ + 'In an inactive editor group, the active connected tab shows the customized purple top border and orange bottom border. Other selected tabs retain the customized lime selection border.', + ], + }), + }), ConnectedSurface: defineThemedFixtureGroup({ MinimumIdentity: defineComponentFixture({ render: renderConnectedSurface(3, undefined, undefined, { diff --git a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts index 5da36891240179..0dda6cfab634f0 100644 --- a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts +++ b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts @@ -44,6 +44,45 @@ test('Dark Modern keeps legacy, connected, and pill tab surfaces distinct', asyn }); }); +for (const [group, expected] of [ + ['ActiveGroup', { + activeTop: { display: 'block', color: 'rgb(34, 211, 238)' }, + activeBottom: { display: 'block', color: 'rgb(244, 63, 94)' }, + selectedTop: { display: 'block', color: 'rgb(163, 230, 53)' }, + }], + ['InactiveGroup', { + activeTop: { display: 'block', color: 'rgb(192, 132, 252)' }, + activeBottom: { display: 'block', color: 'rgb(251, 146, 60)' }, + selectedTop: { display: 'block', color: 'rgb(163, 230, 53)' }, + }], +] as const) { + test(`connected tabs show legacy border customizations in ${group}`, async ({ page }) => { + await openFixture(page, `editor/editorTabBar/editorTabBar/ConnectedLegacyBorders/${group}/Dark`, '.tabs-container > .tab.active'); + + const colors = await page.locator('.tabs-container').evaluate(tabs => { + const active = tabs.querySelector('.tab.active'); + const selected = tabs.querySelector('.tab.selected:not(.active)'); + const activeTop = active?.querySelector('.tab-border-top-container'); + const activeBottom = active?.querySelector('.tab-border-bottom-container'); + const selectedTop = selected?.querySelector('.tab-border-top-container'); + if (!activeTop || !activeBottom || !selectedTop) { + throw new Error('Expected active and selected connected-tab border indicators'); + } + const style = (element: HTMLElement) => { + const computedStyle = getComputedStyle(element); + return { display: computedStyle.display, color: computedStyle.backgroundColor }; + }; + return { + activeTop: style(activeTop), + activeBottom: style(activeBottom), + selectedTop: style(selectedTop), + }; + }); + + expect(colors).toEqual(expected); + }); +} + for (const theme of ['DarkHighContrast', 'LightHighContrast']) { test(`connected tab actions respect disabled hover state in ${theme}`, async ({ page }) => { await openFixture(page, `editor/editorTabBar/editorTabBar/ConnectedSurface/SingleTab/${theme}`, '.tabs-container > .tab'); From f5dc14bee57dddd40f6070672bc2507bf4371d03 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Tue, 29 Sep 2026 08:25:09 -0700 Subject: [PATCH 02/23] editor: honor customized connected tab separators Give explicit tab.border customizations precedence over the connected tab header border default, and cover the connected side stroke in unit, fixture, and Playwright assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/modernUI/browser/media/connectedEditorTabs.css | 2 +- .../modernUI/test/browser/modernUI.contribution.test.ts | 3 +++ .../services/themes/browser/modernTabColorCustomizations.ts | 4 +++- .../componentFixtures/editor/editorTabBar.fixture.ts | 2 ++ .../componentFixtures/playwright/tests/editorTabBar.spec.ts | 6 +++++- 5 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 37d93e782ff337..b14362998d5066 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -17,7 +17,7 @@ .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container, .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .modern-ui-editor-tab-group { --editor-tab-border-indicator-display: block; - --modern-ui-connected-tab-border: var(--vscode-editorGroupHeader-tabsBorder, var(--vscode-tab-border, var(--modern-ui-connected-tab-surface))); + --modern-ui-connected-tab-border: var(--modern-ui-editor-tab-custom-border, var(--vscode-editorGroupHeader-tabsBorder, var(--vscode-tab-border, var(--modern-ui-connected-tab-surface)))); --modern-ui-connected-well-border: transparent; } diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index 30cbb42fed579c..db13e82b9f79c4 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -3676,6 +3676,7 @@ suite('ModernUIContribution', () => { const separatorProbe = appendElement(root, 'separator-probe'); separatorProbe.style.color = 'var(--modern-ui-editor-tab-border)'; separatorProbe.style.borderColor = 'var(--modern-ui-editor-tab-last-pinned-border)'; + separatorProbe.style.backgroundColor = 'var(--modern-ui-editor-tab-custom-border)'; const twoRowTitle = appendElement(activeGroup, 'title two-tab-bars'); const pinnedRow = appendElement(twoRowTitle, 'tabs-and-actions-container'); appendElement(twoRowTitle, 'tabs-and-actions-container'); @@ -3714,6 +3715,7 @@ suite('ModernUIContribution', () => { actionFadeBackgroundClip: activeTabActionFadeStyle.backgroundClip, actionFadeBorderBlockWidth: [activeTabActionFadeStyle.borderTopWidth, activeTabActionFadeStyle.borderBottomWidth], separatorColor: getWindow(separatorProbe).getComputedStyle(separatorProbe).color, + customConnectedBorder: getWindow(separatorProbe).getComputedStyle(separatorProbe).backgroundColor, lastPinnedBorder: getWindow(separatorProbe).getComputedStyle(separatorProbe).borderTopColor, pinnedRowUsesLastPinnedBorder: pinnedRowStyle.boxShadow.includes('rgb(136, 153, 170)'), }, { @@ -3746,6 +3748,7 @@ suite('ModernUIContribution', () => { actionFadeBackgroundClip: 'padding-box', actionFadeBorderBlockWidth: ['1px', '1px'], separatorColor: 'rgb(119, 136, 153)', + customConnectedBorder: 'rgb(119, 136, 153)', lastPinnedBorder: 'rgb(136, 153, 170)', pinnedRowUsesLastPinnedBorder: true, }); diff --git a/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts b/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts index 4d7dac208a7355..fb12628b7a10f5 100644 --- a/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts +++ b/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts @@ -69,6 +69,7 @@ registerThemingParticipant((theme, collector) => { const explicitUnfocusedHoverBackground = resolveExplicitTabColor(theme, MODERN_EDITOR_TAB_HOVER_BACKGROUND, [MODERN_EDITOR_TAB_HOVER_BACKGROUND, MODERN_TAB_HOVER_BACKGROUND], TAB_UNFOCUSED_HOVER_BACKGROUND, TAB_HOVER_BACKGROUND); const explicitActiveHoverBackground = resolveExplicitTabColor(theme, MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, [MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_BACKGROUND, MODERN_TAB_HOVER_BACKGROUND], TAB_HOVER_BACKGROUND); const explicitUnfocusedActiveHoverBackground = resolveExplicitTabColor(theme, MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, [MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_BACKGROUND, MODERN_TAB_HOVER_BACKGROUND], TAB_UNFOCUSED_HOVER_BACKGROUND, TAB_HOVER_BACKGROUND); + const tabBorder = resolveLegacyTabColor(theme, TAB_BORDER, []); addColorVariable(declarations, '--modern-ui-editor-tab-active-background', activeBackground); addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-active-background', unfocusedActiveBackground); @@ -84,7 +85,8 @@ registerThemingParticipant((theme, collector) => { addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-inactive-foreground', resolveLegacyTabColor(theme, TAB_UNFOCUSED_INACTIVE_FOREGROUND, [], TAB_INACTIVE_FOREGROUND, TAB_ACTIVE_FOREGROUND)); addColorVariable(declarations, '--modern-ui-editor-tab-hover-foreground', resolveLegacyTabColor(theme, TAB_HOVER_FOREGROUND, [MODERN_EDITOR_TAB_HOVER_FOREGROUND, MODERN_TAB_HOVER_FOREGROUND])); addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-hover-foreground', resolveLegacyTabColor(theme, TAB_UNFOCUSED_HOVER_FOREGROUND, [MODERN_EDITOR_TAB_HOVER_FOREGROUND, MODERN_TAB_HOVER_FOREGROUND], TAB_HOVER_FOREGROUND)); - addColorVariable(declarations, '--modern-ui-editor-tab-border', resolveLegacyTabColor(theme, TAB_BORDER, [])); + addColorVariable(declarations, '--modern-ui-editor-tab-border', tabBorder); + addColorVariable(declarations, '--modern-ui-editor-tab-custom-border', tabBorder); addColorVariable(declarations, '--modern-ui-editor-tab-last-pinned-border', resolveLegacyTabColor(theme, TAB_LAST_PINNED_BORDER, [])); addColorVariable(declarations, '--modern-ui-editor-tab-active-border', resolveLegacyTabColor(theme, TAB_ACTIVE_BORDER, [])); addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-active-border', resolveLegacyTabColor(theme, TAB_UNFOCUSED_ACTIVE_BORDER, [], TAB_ACTIVE_BORDER)); diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts index bfa6e87d74d86f..b0695e5f8aab88 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -49,6 +49,7 @@ import { MODERN_EDITOR_TAB_SELECTED_ACTION_BACKGROUND, TAB_ACTIVE_BORDER, TAB_ACTIVE_BORDER_TOP, + TAB_BORDER, TAB_SELECTED_BORDER_TOP, TAB_UNFOCUSED_ACTIVE_BORDER, TAB_UNFOCUSED_ACTIVE_BORDER_TOP, @@ -806,6 +807,7 @@ function getLegacyEditorTabBorderCustomizations(): Readonly('.tab.selected:not(.active)'); const activeTop = active?.querySelector('.tab-border-top-container'); const activeBottom = active?.querySelector('.tab-border-bottom-container'); + const activeFill = active?.querySelector('.tab-fill'); const selectedTop = selected?.querySelector('.tab-border-top-container'); - if (!activeTop || !activeBottom || !selectedTop) { + if (!activeTop || !activeBottom || !activeFill || !selectedTop) { throw new Error('Expected active and selected connected-tab border indicators'); } const style = (element: HTMLElement) => { @@ -75,6 +78,7 @@ for (const [group, expected] of [ return { activeTop: style(activeTop), activeBottom: style(activeBottom), + activeSide: getComputedStyle(activeFill).borderRightColor, selectedTop: style(selectedTop), }; }); From f0ca52e7f77710c93cb58d483437c88bd5515241 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Tue, 29 Sep 2026 08:54:20 -0700 Subject: [PATCH 03/23] editor: smooth connected active tab corners Paint active top border customizations on the rounded connected cap instead of layering a straight indicator over it. Preserve the transparent default top-row border and keep selected and modified indicator ownership unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modernUI/browser/media/connectedEditorTabs.css | 13 ++++++++++++- .../test/browser/modernUI.contribution.test.ts | 10 +++++----- .../playwright/tests/editorTabBar.spec.ts | 9 ++++++--- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index b14362998d5066..c7e282820787f9 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -206,6 +206,17 @@ box-shadow: none; } +/* The connected cap owns its rounded top stroke. Painting the standard straight + * indicator over it squares the corners and creates a notch where the cap meets + * adjacent surfaces. */ +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.tab-border-top > .tab-fill { + border-top-color: var(--tab-border-top-color); +} + +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.tab-border-top > .tab-border-top-container { + display: none; +} + /* The foreground mask below removes the cap's side stroke where each shoulder curves out. */ .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::before, .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::after, @@ -566,7 +577,7 @@ inset-inline: 0; } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-top-row:not(.connected-tab-upper-row) > .tab-fill { +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-top-row:not(.connected-tab-upper-row):not(.tab-border-top) > .tab-fill { border-top-color: transparent; } diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index db13e82b9f79c4..d748f9f3c88c44 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -2231,14 +2231,14 @@ suite('ModernUIContribution', () => { const activeTab = appendElement(tabs, 'tab active tab-border-top tab-border-bottom'); activeTab.style.setProperty('--tab-border-top-color', active ? '#123456' : '#234567'); activeTab.style.setProperty('--tab-border-bottom-color', active ? '#345678' : '#456789'); - appendElement(activeTab, 'tab-fill'); + const activeFill = appendElement(activeTab, 'tab-fill'); const activeTopBorder = appendElement(activeTab, 'tab-border-top-container'); const activeBottomBorder = appendElement(activeTab, 'tab-border-bottom-container'); const selectedTab = appendElement(tabs, 'tab selected tab-border-top'); selectedTab.style.setProperty('--tab-border-top-color', '#56789a'); appendElement(selectedTab, 'tab-fill'); const selectedTopBorder = appendElement(selectedTab, 'tab-border-top-container'); - return { activeTopBorder, activeBottomBorder, selectedTopBorder }; + return { activeFill, activeTopBorder, activeBottomBorder, selectedTopBorder }; }; const activeGroup = createGroup(true); const unfocusedGroup = createGroup(false); @@ -2248,7 +2248,7 @@ suite('ModernUIContribution', () => { const activeBottom = targetWindow.getComputedStyle(group.activeBottomBorder); const selectedTop = targetWindow.getComputedStyle(group.selectedTopBorder); return { - activeTop: [activeTop.display, activeTop.backgroundColor], + activeTop: [activeTop.display, targetWindow.getComputedStyle(group.activeFill).borderTopColor], activeBottom: [activeBottom.display, activeBottom.backgroundColor], selectedTop: [selectedTop.display, selectedTop.backgroundColor], }; @@ -2259,12 +2259,12 @@ suite('ModernUIContribution', () => { unfocusedGroup: borderStyles(unfocusedGroup), }, { activeGroup: { - activeTop: ['block', 'rgb(18, 52, 86)'], + activeTop: ['none', 'rgb(18, 52, 86)'], activeBottom: ['block', 'rgb(52, 86, 120)'], selectedTop: ['block', 'rgb(86, 120, 154)'], }, unfocusedGroup: { - activeTop: ['block', 'rgb(35, 69, 103)'], + activeTop: ['none', 'rgb(35, 69, 103)'], activeBottom: ['block', 'rgb(69, 103, 137)'], selectedTop: ['block', 'rgb(86, 120, 154)'], }, diff --git a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts index 0ec0c2927cadb7..5f974dedb87a1d 100644 --- a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts +++ b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts @@ -46,13 +46,13 @@ test('Dark Modern keeps legacy, connected, and pill tab surfaces distinct', asyn for (const [group, expected] of [ ['ActiveGroup', { - activeTop: { display: 'block', color: 'rgb(34, 211, 238)' }, + activeTop: { indicator: 'none', color: 'rgb(34, 211, 238)' }, activeBottom: { display: 'block', color: 'rgb(244, 63, 94)' }, activeSide: 'rgb(250, 204, 21)', selectedTop: { display: 'block', color: 'rgb(163, 230, 53)' }, }], ['InactiveGroup', { - activeTop: { display: 'block', color: 'rgb(192, 132, 252)' }, + activeTop: { indicator: 'none', color: 'rgb(192, 132, 252)' }, activeBottom: { display: 'block', color: 'rgb(251, 146, 60)' }, activeSide: 'rgb(250, 204, 21)', selectedTop: { display: 'block', color: 'rgb(163, 230, 53)' }, @@ -76,7 +76,10 @@ for (const [group, expected] of [ return { display: computedStyle.display, color: computedStyle.backgroundColor }; }; return { - activeTop: style(activeTop), + activeTop: { + indicator: getComputedStyle(activeTop).display, + color: getComputedStyle(activeFill).borderTopColor, + }, activeBottom: style(activeBottom), activeSide: getComputedStyle(activeFill).borderRightColor, selectedTop: style(selectedTop), From 476efee7423944e39509ca7feb46c52e1be87483 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Tue, 29 Sep 2026 09:09:27 -0700 Subject: [PATCH 04/23] editor: cover connected tab border ownership Add matched legacy, pill, and connected fixtures plus first/middle continuity fixtures. Reuse the production legacy-to-modern color collector in the fixture harness and preserve connected structural borders and unfocused colors during active multi-selection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/media/connectedEditorTabs.css | 11 ++- .../browser/modernTabColorCustomizations.ts | 92 ++++++++++--------- .../editor/editorTabBar.fixture.ts | 79 +++++++++++++++- .../playwright/tests/editorTabBar.spec.ts | 79 ++++++++++++++++ 4 files changed, 213 insertions(+), 48 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index c7e282820787f9..f9aeee6ae2ea36 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -182,7 +182,6 @@ /* Preserve the existing explicit selection and focus borders in high contrast. */ .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.selected:not(.active).tab-border-top > .tab-fill, -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.multi-selected.tab-border-top > .tab-fill, .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.selected:not(.active).tab-border-top:not(.close-action-off) > .tab-actions, .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.multi-selected.tab-border-top:not(.close-action-off) > .tab-actions { border-color: transparent; @@ -213,6 +212,16 @@ border-top-color: var(--tab-border-top-color); } +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.multi-selected.tab-border-top > .tab-fill { + border-color: var(--modern-ui-connected-tab-border); + border-top-color: var(--tab-border-top-color); + border-bottom-color: transparent; +} + +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container:not(.active) > .title.tabs .tabs-container > .tab.active.multi-selected.tab-border-top > .tab-fill { + border-top-color: var(--vscode-tab-unfocusedActiveBorderTop, var(--tab-border-top-color)); +} + .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.tab-border-top > .tab-border-top-container { display: none; } diff --git a/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts b/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts index fb12628b7a10f5..0d1652dfe227c7 100644 --- a/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts +++ b/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts @@ -33,9 +33,9 @@ function resolveExplicitTabColor(theme: ColorThemeData, modernColorId: ColorIden return resolveLegacyTabColor(theme, legacyColorId, modernColorIds, ...relatedLegacyColorIds); } -function addColorVariable(declarations: string[], name: string, color: Color | undefined): void { +function addColorVariable(collector: (name: string, color: Color) => void, name: string, color: Color | undefined): void { if (color) { - declarations.push(`${name}: ${color};`); + collector(name, color); } } @@ -43,12 +43,7 @@ function flattenTabBackground(color: Color, editorBackgroundColor: Color | undef return editorBackgroundColor ? color.makeOpaque(editorBackgroundColor) : color; } -registerThemingParticipant((theme, collector) => { - if (!(theme instanceof ColorThemeData)) { - return; - } - - const declarations: string[] = []; +export function collectModernTabColorCustomizations(theme: ColorThemeData, collector: (name: string, color: Color) => void): void { const activeBackground = resolveLegacyTabColor(theme, TAB_ACTIVE_BACKGROUND, [MODERN_EDITOR_TAB_ACTIVE_BACKGROUND, MODERN_TAB_ACTIVE_BACKGROUND]); const unfocusedActiveBackground = resolveLegacyTabColor(theme, TAB_UNFOCUSED_ACTIVE_BACKGROUND, [MODERN_EDITOR_TAB_ACTIVE_BACKGROUND, MODERN_TAB_ACTIVE_BACKGROUND], TAB_ACTIVE_BACKGROUND); const inactiveBackground = resolveLegacyTabColor(theme, TAB_INACTIVE_BACKGROUND, [MODERN_EDITOR_TAB_INACTIVE_BACKGROUND]); @@ -71,57 +66,64 @@ registerThemingParticipant((theme, collector) => { const explicitUnfocusedActiveHoverBackground = resolveExplicitTabColor(theme, MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, [MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_BACKGROUND, MODERN_TAB_HOVER_BACKGROUND], TAB_UNFOCUSED_HOVER_BACKGROUND, TAB_HOVER_BACKGROUND); const tabBorder = resolveLegacyTabColor(theme, TAB_BORDER, []); - addColorVariable(declarations, '--modern-ui-editor-tab-active-background', activeBackground); - addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-active-background', unfocusedActiveBackground); - addColorVariable(declarations, '--modern-ui-editor-tab-inactive-background', inactiveBackground); - addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-inactive-background', unfocusedInactiveBackground); - addColorVariable(declarations, '--modern-ui-editor-tab-hover-background', hoverBackground); - addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-hover-background', unfocusedHoverBackground); - addColorVariable(declarations, '--modern-ui-editor-tab-active-hover-background', activeHoverBackground); - addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-active-hover-background', unfocusedActiveHoverBackground); - addColorVariable(declarations, '--modern-ui-editor-tab-active-foreground', resolveLegacyTabColor(theme, TAB_ACTIVE_FOREGROUND, [MODERN_EDITOR_TAB_ACTIVE_FOREGROUND, MODERN_TAB_ACTIVE_FOREGROUND])); - addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-active-foreground', resolveLegacyTabColor(theme, TAB_UNFOCUSED_ACTIVE_FOREGROUND, [MODERN_EDITOR_TAB_ACTIVE_FOREGROUND, MODERN_TAB_ACTIVE_FOREGROUND], TAB_ACTIVE_FOREGROUND)); - addColorVariable(declarations, '--modern-ui-editor-tab-inactive-foreground', resolveLegacyTabColor(theme, TAB_INACTIVE_FOREGROUND, [], TAB_ACTIVE_FOREGROUND)); - addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-inactive-foreground', resolveLegacyTabColor(theme, TAB_UNFOCUSED_INACTIVE_FOREGROUND, [], TAB_INACTIVE_FOREGROUND, TAB_ACTIVE_FOREGROUND)); - addColorVariable(declarations, '--modern-ui-editor-tab-hover-foreground', resolveLegacyTabColor(theme, TAB_HOVER_FOREGROUND, [MODERN_EDITOR_TAB_HOVER_FOREGROUND, MODERN_TAB_HOVER_FOREGROUND])); - addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-hover-foreground', resolveLegacyTabColor(theme, TAB_UNFOCUSED_HOVER_FOREGROUND, [MODERN_EDITOR_TAB_HOVER_FOREGROUND, MODERN_TAB_HOVER_FOREGROUND], TAB_HOVER_FOREGROUND)); - addColorVariable(declarations, '--modern-ui-editor-tab-border', tabBorder); - addColorVariable(declarations, '--modern-ui-editor-tab-custom-border', tabBorder); - addColorVariable(declarations, '--modern-ui-editor-tab-last-pinned-border', resolveLegacyTabColor(theme, TAB_LAST_PINNED_BORDER, [])); - addColorVariable(declarations, '--modern-ui-editor-tab-active-border', resolveLegacyTabColor(theme, TAB_ACTIVE_BORDER, [])); - addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-active-border', resolveLegacyTabColor(theme, TAB_UNFOCUSED_ACTIVE_BORDER, [], TAB_ACTIVE_BORDER)); - addColorVariable(declarations, '--modern-ui-editor-tab-active-border-top', resolveLegacyTabColor(theme, TAB_ACTIVE_BORDER_TOP, [])); - addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-active-border-top', resolveLegacyTabColor(theme, TAB_UNFOCUSED_ACTIVE_BORDER_TOP, [], TAB_ACTIVE_BORDER_TOP)); - addColorVariable(declarations, '--modern-ui-editor-tab-hover-border', resolveLegacyTabColor(theme, TAB_HOVER_BORDER, [])); - addColorVariable(declarations, '--modern-ui-editor-tab-unfocused-hover-border', resolveLegacyTabColor(theme, TAB_UNFOCUSED_HOVER_BORDER, [], TAB_HOVER_BORDER)); - addColorVariable(declarations, '--modern-ui-editor-tab-custom-active-background', explicitActiveBackground); - addColorVariable(declarations, '--modern-ui-editor-tab-custom-unfocused-active-background', explicitUnfocusedActiveBackground); - addColorVariable(declarations, '--modern-ui-editor-tab-custom-inactive-background', explicitInactiveBackground); - addColorVariable(declarations, '--modern-ui-editor-tab-custom-unfocused-inactive-background', explicitUnfocusedInactiveBackground); - addColorVariable(declarations, '--modern-ui-editor-tab-custom-hover-background', explicitHoverBackground); - addColorVariable(declarations, '--modern-ui-editor-tab-custom-unfocused-hover-background', explicitUnfocusedHoverBackground); - addColorVariable(declarations, '--modern-ui-editor-tab-custom-active-hover-background', explicitActiveHoverBackground); - addColorVariable(declarations, '--modern-ui-editor-tab-custom-unfocused-active-hover-background', explicitUnfocusedActiveHoverBackground); + addColorVariable(collector, '--modern-ui-editor-tab-active-background', activeBackground); + addColorVariable(collector, '--modern-ui-editor-tab-unfocused-active-background', unfocusedActiveBackground); + addColorVariable(collector, '--modern-ui-editor-tab-inactive-background', inactiveBackground); + addColorVariable(collector, '--modern-ui-editor-tab-unfocused-inactive-background', unfocusedInactiveBackground); + addColorVariable(collector, '--modern-ui-editor-tab-hover-background', hoverBackground); + addColorVariable(collector, '--modern-ui-editor-tab-unfocused-hover-background', unfocusedHoverBackground); + addColorVariable(collector, '--modern-ui-editor-tab-active-hover-background', activeHoverBackground); + addColorVariable(collector, '--modern-ui-editor-tab-unfocused-active-hover-background', unfocusedActiveHoverBackground); + addColorVariable(collector, '--modern-ui-editor-tab-active-foreground', resolveLegacyTabColor(theme, TAB_ACTIVE_FOREGROUND, [MODERN_EDITOR_TAB_ACTIVE_FOREGROUND, MODERN_TAB_ACTIVE_FOREGROUND])); + addColorVariable(collector, '--modern-ui-editor-tab-unfocused-active-foreground', resolveLegacyTabColor(theme, TAB_UNFOCUSED_ACTIVE_FOREGROUND, [MODERN_EDITOR_TAB_ACTIVE_FOREGROUND, MODERN_TAB_ACTIVE_FOREGROUND], TAB_ACTIVE_FOREGROUND)); + addColorVariable(collector, '--modern-ui-editor-tab-inactive-foreground', resolveLegacyTabColor(theme, TAB_INACTIVE_FOREGROUND, [], TAB_ACTIVE_FOREGROUND)); + addColorVariable(collector, '--modern-ui-editor-tab-unfocused-inactive-foreground', resolveLegacyTabColor(theme, TAB_UNFOCUSED_INACTIVE_FOREGROUND, [], TAB_INACTIVE_FOREGROUND, TAB_ACTIVE_FOREGROUND)); + addColorVariable(collector, '--modern-ui-editor-tab-hover-foreground', resolveLegacyTabColor(theme, TAB_HOVER_FOREGROUND, [MODERN_EDITOR_TAB_HOVER_FOREGROUND, MODERN_TAB_HOVER_FOREGROUND])); + addColorVariable(collector, '--modern-ui-editor-tab-unfocused-hover-foreground', resolveLegacyTabColor(theme, TAB_UNFOCUSED_HOVER_FOREGROUND, [MODERN_EDITOR_TAB_HOVER_FOREGROUND, MODERN_TAB_HOVER_FOREGROUND], TAB_HOVER_FOREGROUND)); + addColorVariable(collector, '--modern-ui-editor-tab-border', tabBorder); + addColorVariable(collector, '--modern-ui-editor-tab-custom-border', tabBorder); + addColorVariable(collector, '--modern-ui-editor-tab-last-pinned-border', resolveLegacyTabColor(theme, TAB_LAST_PINNED_BORDER, [])); + addColorVariable(collector, '--modern-ui-editor-tab-active-border', resolveLegacyTabColor(theme, TAB_ACTIVE_BORDER, [])); + addColorVariable(collector, '--modern-ui-editor-tab-unfocused-active-border', resolveLegacyTabColor(theme, TAB_UNFOCUSED_ACTIVE_BORDER, [], TAB_ACTIVE_BORDER)); + addColorVariable(collector, '--modern-ui-editor-tab-active-border-top', resolveLegacyTabColor(theme, TAB_ACTIVE_BORDER_TOP, [])); + addColorVariable(collector, '--modern-ui-editor-tab-unfocused-active-border-top', resolveLegacyTabColor(theme, TAB_UNFOCUSED_ACTIVE_BORDER_TOP, [], TAB_ACTIVE_BORDER_TOP)); + addColorVariable(collector, '--modern-ui-editor-tab-hover-border', resolveLegacyTabColor(theme, TAB_HOVER_BORDER, [])); + addColorVariable(collector, '--modern-ui-editor-tab-unfocused-hover-border', resolveLegacyTabColor(theme, TAB_UNFOCUSED_HOVER_BORDER, [], TAB_HOVER_BORDER)); + addColorVariable(collector, '--modern-ui-editor-tab-custom-active-background', explicitActiveBackground); + addColorVariable(collector, '--modern-ui-editor-tab-custom-unfocused-active-background', explicitUnfocusedActiveBackground); + addColorVariable(collector, '--modern-ui-editor-tab-custom-inactive-background', explicitInactiveBackground); + addColorVariable(collector, '--modern-ui-editor-tab-custom-unfocused-inactive-background', explicitUnfocusedInactiveBackground); + addColorVariable(collector, '--modern-ui-editor-tab-custom-hover-background', explicitHoverBackground); + addColorVariable(collector, '--modern-ui-editor-tab-custom-unfocused-hover-background', explicitUnfocusedHoverBackground); + addColorVariable(collector, '--modern-ui-editor-tab-custom-active-hover-background', explicitActiveHoverBackground); + addColorVariable(collector, '--modern-ui-editor-tab-custom-unfocused-active-hover-background', explicitUnfocusedActiveHoverBackground); if (activeBackground && !hasModernActiveActionBackground) { - addColorVariable(declarations, '--modern-ui-editor-tab-action-active-background', flattenTabBackground(activeBackground, editorBackgroundColor)); + addColorVariable(collector, '--modern-ui-editor-tab-action-active-background', flattenTabBackground(activeBackground, editorBackgroundColor)); } if (unfocusedActiveBackground && !hasModernActiveActionBackground) { - addColorVariable(declarations, '--modern-ui-editor-tab-action-unfocused-active-background', flattenTabBackground(unfocusedActiveBackground, editorBackgroundColor)); + addColorVariable(collector, '--modern-ui-editor-tab-action-unfocused-active-background', flattenTabBackground(unfocusedActiveBackground, editorBackgroundColor)); } if (hoverBackground && !hasModernHoverActionBackground) { - addColorVariable(declarations, '--modern-ui-editor-tab-action-hover-background', flattenTabBackground(hoverBackground, editorBackgroundColor)); + addColorVariable(collector, '--modern-ui-editor-tab-action-hover-background', flattenTabBackground(hoverBackground, editorBackgroundColor)); } if (unfocusedHoverBackground && !hasModernHoverActionBackground) { - addColorVariable(declarations, '--modern-ui-editor-tab-action-unfocused-hover-background', flattenTabBackground(unfocusedHoverBackground, editorBackgroundColor)); + addColorVariable(collector, '--modern-ui-editor-tab-action-unfocused-hover-background', flattenTabBackground(unfocusedHoverBackground, editorBackgroundColor)); } if (activeHoverBackground && !hasModernActiveHoverActionBackground) { - addColorVariable(declarations, '--modern-ui-editor-tab-action-active-hover-background', flattenTabBackground(activeHoverBackground, editorBackgroundColor)); + addColorVariable(collector, '--modern-ui-editor-tab-action-active-hover-background', flattenTabBackground(activeHoverBackground, editorBackgroundColor)); } if (unfocusedActiveHoverBackground && !hasModernActiveHoverActionBackground) { - addColorVariable(declarations, '--modern-ui-editor-tab-action-unfocused-active-hover-background', flattenTabBackground(unfocusedActiveHoverBackground, editorBackgroundColor)); + addColorVariable(collector, '--modern-ui-editor-tab-action-unfocused-active-hover-background', flattenTabBackground(unfocusedActiveHoverBackground, editorBackgroundColor)); } +} +registerThemingParticipant((theme, collector) => { + if (!(theme instanceof ColorThemeData)) { + return; + } + const declarations: string[] = []; + collectModernTabColorCustomizations(theme, (name, color) => declarations.push(`${name}: ${color};`)); if (declarations.length > 0) { // The doubled `.monaco-workbench` raises specificity above the defaults in tabs.css so these overrides win regardless of style injection order. collector.addRule(`.modern-ui-tabs.monaco-workbench.monaco-workbench { ${declarations.join('\n')} }`); diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts index b0695e5f8aab88..9ae73b7f271d5f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -37,6 +37,7 @@ import { EditorGroupModel } from '../../../../common/editor/editorGroupModel.js' import { EDITOR_GROUP_HEADER_NO_TABS_BACKGROUND, EDITOR_GROUP_HEADER_TABS_BACKGROUND, + EDITOR_BORDER, MODERN_EDITOR_TAB_ACTIVE_ACTION_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_FOREGROUND, @@ -59,6 +60,7 @@ import { BreadcrumbsService, IBreadcrumbsService } from '../../../../browser/par import { EditorTitleControl } from '../../../../browser/parts/editor/editorTitleControl.js'; import { IDecorationData, IDecorationsProvider, IDecorationsService } from '../../../../services/decorations/common/decorations.js'; import { DecorationsService } from '../../../../services/decorations/browser/decorationsService.js'; +import { collectModernTabColorCustomizations } from '../../../../services/themes/browser/modernTabColorCustomizations.js'; import { INotebookDocumentService, NotebookDocumentWorkbenchService } from '../../../../services/notebook/common/notebookDocumentService.js'; import { IOutlineService } from '../../../../services/outline/browser/outline.js'; import { LayoutSettings, ModernUIEditorTabStyle } from '../../../../services/layout/browser/layoutService.js'; @@ -67,6 +69,7 @@ import { workbenchInstantiationService } from '../../workbenchTestServices.js'; import { ComponentFixtureAdditionalTheme, ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; import '../../../../contrib/modernUI/browser/media/tabs.css'; import '../../../../contrib/modernUI/browser/connectedEditorTabs.js'; +import '../../../../contrib/modernUI/browser/media/editorBorder.css'; import './editorTabBar.fixture.css'; // ============================================================================ @@ -382,6 +385,7 @@ export interface IEditorTabBarFixtureOptions { readonly focusedTabAction?: number; readonly editorContents?: string; readonly activeTabClipping?: 'left' | 'right' | 'left-shoulder' | 'right-shoulder'; + readonly editorFrame?: boolean; } function customizeTheme(theme: IColorTheme, customizations: Readonly> | undefined): IColorTheme { @@ -398,11 +402,42 @@ function customizeTheme(theme: IColorTheme, customizations: Readonly colors.has(colorId) || target.defines(colorId); } + if (property === 'getColorCustomization') { + return (colorId: string) => colors.get(colorId); + } return Reflect.get(target, property, receiver); } }); } +function renderBorderOwnership(modernUI: boolean, editorTabStyle?: ModernUIEditorTabStyle): (ctx: ComponentFixtureContext) => void { + return render(modernUI, { + editorTabStyle, + editors: [ + { resource: file('/project/alpha.ts'), pinned: true }, + { resource: file('/project/beta.ts'), pinned: true, active: true }, + { resource: file('/project/gamma.ts'), pinned: true }, + ], + colorCustomizations: getLegacyEditorTabBorderCustomizations(), + }); +} + +function renderConnectedBorderContinuity(activeTabIndex: number): (ctx: ComponentFixtureContext) => void { + return render(true, { + editorFrame: true, + editors: [ + { resource: file('/project/alpha.ts'), pinned: true, active: activeTabIndex === 0 }, + { resource: file('/project/beta.ts'), pinned: true, active: activeTabIndex === 1 }, + { resource: file('/project/gamma.ts'), pinned: true, active: activeTabIndex === 2 }, + ], + colorCustomizations: { + [EDITOR_BORDER]: '#22D3EE', + [TAB_ACTIVE_BORDER_TOP]: '#22D3EE', + [TAB_BORDER]: '#22D3EE', + }, + }); +} + function createPartOptions(overrides?: Partial): IEditorPartOptions { return { ...DEFAULT_EDITOR_PART_OPTIONS, @@ -464,8 +499,12 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: // Feed the fixture's themes to the shared theme service so tab-bar theme lookups resolve. const themeService = instantiationService.get(IThemeService) as TestThemeService; - themeService.setTheme(customizeTheme(theme, options.colorCustomizations)); + const fixtureTheme = customizeTheme(theme, options.colorCustomizations); + themeService.setTheme(fixtureTheme); themeService.setFileIconTheme(fileIconTheme); + if (options.colorCustomizations) { + collectModernTabColorCustomizations(fixtureTheme as ColorThemeData, (name, color) => container.style.setProperty(name, color.toString())); + } // Services the base workbench harness does not stub but the tab bar needs. instantiationService.stub(ITreeViewsDnDService, new TreeViewsDnDService()); @@ -565,7 +604,14 @@ export function renderEditorTabBarFixture(ctx: ComponentFixtureContext, options: content.appendChild(groupContainer); groupContainer.appendChild(titleContainer); groupContainer.appendChild(editorContainer); - container.appendChild(editorPart); + if (options.editorFrame) { + container.classList.add('floating-panels'); + const grid = $('.monaco-grid-view'); + grid.appendChild(editorPart); + container.appendChild(grid); + } else { + container.appendChild(editorPart); + } if (options.editorContents !== undefined && model.activeEditor instanceof FixtureEditorInput) { editorContainer.style.height = '240px'; @@ -944,6 +990,35 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { ], }), }), + BorderOwnership: defineThemedFixtureGroup({ + Legacy: defineComponentFixture({ + render: renderBorderOwnership(false), + themes: ['dark'], + expectedVisualDescriptions: ['Legacy tabs retain their standard full-width top and bottom border indicators.'], + }), + Pill: defineComponentFixture({ + render: renderBorderOwnership(true, ModernUIEditorTabStyle.Pill), + themes: ['dark'], + expectedVisualDescriptions: ['Pill tabs hide the standard indicators and paint the customized cyan top and pink bottom borders on the inset rounded fill.'], + }), + Connected: defineComponentFixture({ + render: renderBorderOwnership(true, ModernUIEditorTabStyle.Connected), + themes: ['dark'], + expectedVisualDescriptions: ['Connected tabs paint the customized cyan top border on the rounded cap, retain the pink bottom indicator, and use the yellow structural side stroke.'], + }), + }), + ConnectedBorderContinuity: defineThemedFixtureGroup({ + FirstActive: defineComponentFixture({ + render: renderConnectedBorderContinuity(0), + themes: ['dark'], + expectedVisualDescriptions: ['The first active connected tab joins the cyan outer editor frame, cap, strip separator, and document frame as one continuous stroke.'], + }), + MiddleActive: defineComponentFixture({ + render: renderConnectedBorderContinuity(1), + themes: ['dark'], + expectedVisualDescriptions: ['The middle active connected tab has smooth rounded cyan top corners shared with adjacent surfaces, without a straight overlay or corner notch.'], + }), + }), ConnectedSurface: defineThemedFixtureGroup({ MinimumIdentity: defineComponentFixture({ render: renderConnectedSurface(3, undefined, undefined, { diff --git a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts index 5f974dedb87a1d..8205e2d2be28c8 100644 --- a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts +++ b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts @@ -90,6 +90,85 @@ for (const [group, expected] of [ }); } +for (const [style, expected] of [ + ['Legacy', { + topIndicator: { display: 'block', color: 'rgb(34, 211, 238)' }, + bottomIndicator: { display: 'block', color: 'rgb(244, 63, 94)' }, + }], + ['Pill', { + topIndicator: { display: 'none' }, + bottomIndicator: { display: 'none' }, + fillTop: 'rgb(34, 211, 238)', + fillBottom: 'rgb(244, 63, 94)', + }], + ['Connected', { + topIndicator: { display: 'none' }, + bottomIndicator: { display: 'block', color: 'rgb(244, 63, 94)' }, + fillTop: 'rgb(34, 211, 238)', + fillBottom: 'rgba(0, 0, 0, 0)', + fillSide: 'rgb(250, 204, 21)', + }], +] as const) { + test(`${style} tabs retain their border ownership`, async ({ page }) => { + await openFixture(page, `editor/editorTabBar/editorTabBar/BorderOwnership/${style}/Dark`, '.tabs-container > .tab.active'); + const ownership = await page.locator('.tabs-container > .tab.active').evaluate(active => { + const top = active.querySelector('.tab-border-top-container'); + const bottom = active.querySelector('.tab-border-bottom-container'); + const fill = active.querySelector('.tab-fill'); + if (!top || !bottom || !fill) { + throw new Error('Expected active tab border elements'); + } + const fillStyle = getComputedStyle(fill); + const indicatorStyle = (element: HTMLElement) => { + const style = getComputedStyle(element); + return { display: style.display, color: style.backgroundColor }; + }; + return { + topIndicator: indicatorStyle(top), + bottomIndicator: indicatorStyle(bottom), + fillTop: fillStyle.borderTopColor, + fillBottom: fillStyle.borderBottomColor, + fillSide: fillStyle.borderRightColor, + }; + }); + expect(ownership).toMatchObject(expected); + }); +} + +for (const fixture of ['FirstActive', 'MiddleActive']) { + test(`connected border continuity stays aligned for ${fixture}`, async ({ page }) => { + await openFixture(page, `editor/editorTabBar/editorTabBar/ConnectedBorderContinuity/${fixture}/Dark`, '.tabs-container > .tab.active'); + const state = await page.locator('.part.editor').evaluate(editor => { + const group = editor.querySelector('.editor-group-container.active'); + const active = group?.querySelector('.tab.active'); + const fill = active?.querySelector('.tab-fill'); + const indicator = active?.querySelector('.tab-border-top-container'); + const strip = group?.querySelector('.tabs-and-actions-container'); + if (!group || !active || !fill || !indicator || !strip) { + throw new Error('Expected connected editor frame and active tab'); + } + const editorRect = editor.getBoundingClientRect(); + const fillRect = fill.getBoundingClientRect(); + return { + editorBorder: getComputedStyle(editor).borderTopColor, + capTop: getComputedStyle(fill).borderTopColor, + capSide: getComputedStyle(fill).borderRightColor, + separator: getComputedStyle(strip, '::after').backgroundColor, + indicator: getComputedStyle(indicator).display, + topAligned: Math.abs(editorRect.top - fillRect.top) <= 1, + }; + }); + expect(state).toEqual({ + editorBorder: 'rgb(34, 211, 238)', + capTop: 'rgb(34, 211, 238)', + capSide: 'rgb(34, 211, 238)', + separator: 'rgb(34, 211, 238)', + indicator: 'none', + topAligned: true, + }); + }); +} + for (const theme of ['DarkHighContrast', 'LightHighContrast']) { test(`connected tab actions respect disabled hover state in ${theme}`, async ({ page }) => { await openFixture(page, `editor/editorTabBar/editorTabBar/ConnectedSurface/SingleTab/${theme}`, '.tabs-container > .tab'); From 52532c1f685f6b43218fa8c972354ab22215c352 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Tue, 29 Sep 2026 11:36:50 -0700 Subject: [PATCH 05/23] editor: separate tab dividers from boundaries Add a customizable tab.divider element between tabs while reserving tab.border for tab boundaries. Modern pill and connected styles show short dividers only between inactive tabs; legacy keeps dividers at every shared boundary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/lib/stylelint/vscode-known-variables.json | 2 ++ .../editor/media/multieditortabscontrol.css | 17 +++++++++++++++++ .../parts/editor/multiEditorTabsControl.ts | 2 ++ src/vs/workbench/common/theme.ts | 9 ++++++++- .../contrib/modernUI/browser/media/tabs.css | 14 ++++++++++---- .../editor/editorTabBar.fixture.ts | 9 ++++++--- .../playwright/tests/editorTabBar.spec.ts | 8 ++++++++ 7 files changed, 53 insertions(+), 8 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index e7a7880112e646..93847c77fde282 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -833,6 +833,7 @@ "--vscode-tab-activeForeground", "--vscode-tab-activeModifiedBorder", "--vscode-tab-border", + "--vscode-tab-divider", "--vscode-tab-dragAndDropBorder", "--vscode-tab-hoverBackground", "--vscode-tab-hoverBorder", @@ -1079,6 +1080,7 @@ "--tab-sizing-current-width", "--tab-sizing-fixed-min-width", "--tab-sizing-fixed-max-width", + "--editor-tab-border-indicator-display", "--editor-group-tab-height", "--editor-group-tabs-height", "--testMessageDecorationFontFamily", diff --git a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css index 2dc734f54f1c61..869fb661dbe36f 100644 --- a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css +++ b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css @@ -127,6 +127,23 @@ display: none; } +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab > .tab-divider { + display: none; + position: absolute; + top: 50%; + left: 0; + z-index: 9; + width: var(--vscode-strokeThickness); + height: var(--vscode-spacing-size160); + background-color: var(--vscode-tab-divider, transparent); + pointer-events: none; + transform: translateY(-50%); +} + +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:not(.last-in-row) + .tab > .tab-divider { + display: block; +} + .monaco-workbench .part.editor > .content .editor-group-container > .title > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge { display: none; } diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index 8bea3273432b5d..447027bf75789f 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -998,6 +998,8 @@ export class MultiEditorTabsControl extends EditorTabsControl { tabContainer.appendChild(tabFillContainer); const tabConnectedEdgeContainer = $('.tab-connected-edge', { 'aria-hidden': true }); tabContainer.appendChild(tabConnectedEdgeContainer); + const tabDivider = $('.tab-divider', { 'aria-hidden': true }); + tabContainer.appendChild(tabDivider); // Tab Border Top const tabBorderTopContainer = $('.tab-border-top-container'); diff --git a/src/vs/workbench/common/theme.ts b/src/vs/workbench/common/theme.ts index 91b466700b3df5..a0c7241104c99e 100644 --- a/src/vs/workbench/common/theme.ts +++ b/src/vs/workbench/common/theme.ts @@ -105,7 +105,14 @@ export const TAB_BORDER = registerColor('tab.border', { light: '#F3F3F3', hcDark: contrastBorder, hcLight: contrastBorder, -}, localize('tabBorder', "Border to separate tabs from each other. Tabs are the containers for editors in the editor area. Multiple tabs can be opened in one editor group. There can be multiple editor groups.")); +}, localize('tabBorder', "Border around tabs. Tabs are the containers for editors in the editor area. Multiple tabs can be opened in one editor group. There can be multiple editor groups.")); + +export const TAB_DIVIDER = registerColor('tab.divider', { + dark: null, + light: null, + hcDark: contrastBorder, + hcLight: contrastBorder, +}, localize('tabDivider', "Color of dividers between editor tabs.")); export const TAB_LAST_PINNED_BORDER = registerColor('tab.lastPinnedBorder', { dark: treeIndentGuidesStroke, diff --git a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css index 521e0384de6f71..c1762836d813fb 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css @@ -108,14 +108,20 @@ z-index: 0; border-radius: var(--vscode-cornerRadius-small); box-sizing: border-box; - box-shadow: inset calc(-1 * var(--vscode-strokeThickness)) 0 var(--modern-ui-editor-tab-border); + border: var(--vscode-strokeThickness) solid var(--modern-ui-editor-tab-border); pointer-events: none; } .modern-ui-tabs.monaco-workbench .part.editor .tabs-container > .tab.sticky + .tab:not(.sticky) > .tab-fill { - box-shadow: - inset calc(-1 * var(--vscode-strokeThickness)) 0 var(--modern-ui-editor-tab-border), - inset var(--vscode-strokeThickness) 0 var(--modern-ui-editor-tab-last-pinned-border); + box-shadow: inset var(--vscode-strokeThickness) 0 var(--modern-ui-editor-tab-last-pinned-border); +} + +.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:not(.last-in-row) + .tab > .tab-divider { + display: none; +} + +.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:not(.active):not(.last-in-row) + .tab:not(.active) > .tab-divider { + display: block; } /* Reuses the last-pinned-border color for the horizontal divider between the pinned and normal tab rows. */ diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts index 9ae73b7f271d5f..0d9bb7ad20bc30 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -51,6 +51,7 @@ import { TAB_ACTIVE_BORDER, TAB_ACTIVE_BORDER_TOP, TAB_BORDER, + TAB_DIVIDER, TAB_SELECTED_BORDER_TOP, TAB_UNFOCUSED_ACTIVE_BORDER, TAB_UNFOCUSED_ACTIVE_BORDER_TOP, @@ -417,6 +418,7 @@ function renderBorderOwnership(modernUI: boolean, editorTabStyle?: ModernUIEdito { resource: file('/project/alpha.ts'), pinned: true }, { resource: file('/project/beta.ts'), pinned: true, active: true }, { resource: file('/project/gamma.ts'), pinned: true }, + { resource: file('/project/delta.ts'), pinned: true }, ], colorCustomizations: getLegacyEditorTabBorderCustomizations(), }); @@ -854,6 +856,7 @@ function getLegacyEditorTabBorderCustomizations(): Readonly { @@ -119,6 +123,9 @@ for (const [style, expected] of [ throw new Error('Expected active tab border elements'); } const fillStyle = getComputedStyle(fill); + const visibleDividers = [...active.parentElement!.querySelectorAll('.tab-divider')] + .filter(element => getComputedStyle(element).display !== 'none') + .map(element => getComputedStyle(element).backgroundColor); const indicatorStyle = (element: HTMLElement) => { const style = getComputedStyle(element); return { display: style.display, color: style.backgroundColor }; @@ -129,6 +136,7 @@ for (const [style, expected] of [ fillTop: fillStyle.borderTopColor, fillBottom: fillStyle.borderBottomColor, fillSide: fillStyle.borderRightColor, + visibleDividers, }; }); expect(ownership).toMatchObject(expected); From 59be6cc53e9c5eb05d768ae8442ace9ebeaadfb1 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Tue, 29 Sep 2026 11:41:39 -0700 Subject: [PATCH 06/23] editor: inherit tab divider color from border Default tab.divider to tab.border while preserving an explicit divider customization as an independent override. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/workbench/common/theme.ts | 7 +------ .../test/browser/modernUI.contribution.test.ts | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/vs/workbench/common/theme.ts b/src/vs/workbench/common/theme.ts index a0c7241104c99e..9634bef5f72739 100644 --- a/src/vs/workbench/common/theme.ts +++ b/src/vs/workbench/common/theme.ts @@ -107,12 +107,7 @@ export const TAB_BORDER = registerColor('tab.border', { hcLight: contrastBorder, }, localize('tabBorder', "Border around tabs. Tabs are the containers for editors in the editor area. Multiple tabs can be opened in one editor group. There can be multiple editor groups.")); -export const TAB_DIVIDER = registerColor('tab.divider', { - dark: null, - light: null, - hcDark: contrastBorder, - hcLight: contrastBorder, -}, localize('tabDivider', "Color of dividers between editor tabs.")); +export const TAB_DIVIDER = registerColor('tab.divider', TAB_BORDER, localize('tabDivider', "Color of dividers between editor tabs.")); export const TAB_LAST_PINNED_BORDER = registerColor('tab.lastPinnedBorder', { dark: treeIndentGuidesStroke, diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index d748f9f3c88c44..1233d0246bbbe5 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -27,7 +27,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { activeContrastBorder, editorBackground, Extensions as ColorRegistryExtensions, IColorRegistry, listHoverBackground, listHoverForeground, listInactiveSelectionBackground, listInactiveSelectionForeground, oneOf, opaque, transparent } from '../../../../../platform/theme/common/colorRegistry.js'; import { foreground } from '../../../../../platform/theme/common/colors/baseColors.js'; import { Extensions as ThemeServiceExtensions, IThemingRegistry } from '../../../../../platform/theme/common/themeService.js'; -import { EDITOR_BORDER, EDITOR_GROUP_HEADER_CONNECTED_TABS_BACKGROUND, EDITOR_GROUP_HEADER_TABS_BACKGROUND, MODERN_ACTIVITY_BAR_BACKGROUND, MODERN_ACTIVITY_BAR_BORDER, MODERN_ACTIVITY_BAR_INACTIVE_BACKGROUND, MODERN_ACTIVITY_BAR_ITEM_ACTIVE_BACKGROUND, MODERN_ACTIVITY_BAR_ITEM_ACTIVE_FOREGROUND, MODERN_ACTIVITY_BAR_ITEM_HOVER_BACKGROUND, MODERN_ACTIVITY_BAR_ITEM_HOVER_FOREGROUND, MODERN_EDITOR_TAB_ACTIVE_ACTION_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_FOREGROUND, MODERN_EDITOR_TAB_ACTIVE_HOVER_ACTION_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_ACTION_BACKGROUND, MODERN_EDITOR_TAB_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_FOREGROUND, MODERN_EDITOR_TAB_INACTIVE_BACKGROUND, MODERN_EDITOR_TAB_SELECTED_ACTION_BACKGROUND, MODERN_PANEL_BORDER, MODERN_SASH_GRIP_FOREGROUND, MODERN_TAB_ACTIVE_BACKGROUND, MODERN_TAB_ACTIVE_FOREGROUND, MODERN_TAB_HOVER_BACKGROUND, MODERN_TAB_HOVER_FOREGROUND, MODERN_UI_INACTIVE_SHELL_BACKGROUND, MODERN_UI_SHELL_BACKGROUND, PANEL_SECTION_BORDER, PANEL_SECTION_HEADER_BORDER, SIDE_BAR_SECTION_HEADER_BORDER, SURFACE_BORDER, TAB_ACTIVE_BACKGROUND, TAB_ACTIVE_BORDER, TAB_ACTIVE_BORDER_TOP, TAB_ACTIVE_FOREGROUND, TAB_BORDER, TAB_HOVER_BACKGROUND, TAB_HOVER_BORDER, TAB_HOVER_FOREGROUND, TAB_INACTIVE_BACKGROUND, TAB_INACTIVE_FOREGROUND, TAB_LAST_PINNED_BORDER, TAB_SELECTED_BACKGROUND, TAB_UNFOCUSED_ACTIVE_BACKGROUND, TAB_UNFOCUSED_HOVER_BACKGROUND, TAB_UNFOCUSED_INACTIVE_BACKGROUND, TITLE_BAR_ACTIVE_BACKGROUND, TITLE_BAR_INACTIVE_BACKGROUND } from '../../../../common/theme.js'; +import { EDITOR_BORDER, EDITOR_GROUP_HEADER_CONNECTED_TABS_BACKGROUND, EDITOR_GROUP_HEADER_TABS_BACKGROUND, MODERN_ACTIVITY_BAR_BACKGROUND, MODERN_ACTIVITY_BAR_BORDER, MODERN_ACTIVITY_BAR_INACTIVE_BACKGROUND, MODERN_ACTIVITY_BAR_ITEM_ACTIVE_BACKGROUND, MODERN_ACTIVITY_BAR_ITEM_ACTIVE_FOREGROUND, MODERN_ACTIVITY_BAR_ITEM_HOVER_BACKGROUND, MODERN_ACTIVITY_BAR_ITEM_HOVER_FOREGROUND, MODERN_EDITOR_TAB_ACTIVE_ACTION_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_FOREGROUND, MODERN_EDITOR_TAB_ACTIVE_HOVER_ACTION_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_ACTION_BACKGROUND, MODERN_EDITOR_TAB_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_FOREGROUND, MODERN_EDITOR_TAB_INACTIVE_BACKGROUND, MODERN_EDITOR_TAB_SELECTED_ACTION_BACKGROUND, MODERN_PANEL_BORDER, MODERN_SASH_GRIP_FOREGROUND, MODERN_TAB_ACTIVE_BACKGROUND, MODERN_TAB_ACTIVE_FOREGROUND, MODERN_TAB_HOVER_BACKGROUND, MODERN_TAB_HOVER_FOREGROUND, MODERN_UI_INACTIVE_SHELL_BACKGROUND, MODERN_UI_SHELL_BACKGROUND, PANEL_SECTION_BORDER, PANEL_SECTION_HEADER_BORDER, SIDE_BAR_SECTION_HEADER_BORDER, SURFACE_BORDER, TAB_ACTIVE_BACKGROUND, TAB_ACTIVE_BORDER, TAB_ACTIVE_BORDER_TOP, TAB_ACTIVE_FOREGROUND, TAB_BORDER, TAB_DIVIDER, TAB_HOVER_BACKGROUND, TAB_HOVER_BORDER, TAB_HOVER_FOREGROUND, TAB_INACTIVE_BACKGROUND, TAB_INACTIVE_FOREGROUND, TAB_LAST_PINNED_BORDER, TAB_SELECTED_BACKGROUND, TAB_UNFOCUSED_ACTIVE_BACKGROUND, TAB_UNFOCUSED_HOVER_BACKGROUND, TAB_UNFOCUSED_INACTIVE_BACKGROUND, TITLE_BAR_ACTIVE_BACKGROUND, TITLE_BAR_INACTIVE_BACKGROUND } from '../../../../common/theme.js'; import { TestEnvironmentService, TestLayoutService } from '../../../../test/browser/workbenchTestServices.js'; import { LayoutSettings, ModernUIDensity, ModernUIEditorTabStyle } from '../../../../services/layout/browser/layoutService.js'; import { PRESERVE_MERGED_WORKSPACE_NAME_CASE_CLASS, PRESERVE_WORKSPACE_NAME_CASE_CLASS, shouldPreserveWorkspaceNameCase } from '../../../files/browser/views/explorerView.js'; @@ -3591,6 +3591,21 @@ suite('ModernUIContribution', () => { }); }); + test('tab divider inherits tab border unless customized', () => { + const theme = ColorThemeData.createUnloadedTheme('vs-dark'); + theme.setCustomColors({ [TAB_BORDER]: '#123456' }); + const inherited = theme.getColor(TAB_DIVIDER)?.toString(); + theme.setCustomColors({ [TAB_BORDER]: '#123456', [TAB_DIVIDER]: '#654321' }); + + assert.deepStrictEqual({ + inherited, + customized: theme.getColor(TAB_DIVIDER)?.toString(), + }, { + inherited: '#123456', + customized: '#654321', + }); + }); + test('uses legacy color customizations for Modern UI editor tabs only', () => { const theme = ColorThemeData.createUnloadedTheme('vs-dark', { [editorBackground]: '#000000', From 8d1cd66e4afa218e152aace61cff48e7857f91be Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Tue, 29 Sep 2026 11:53:18 -0700 Subject: [PATCH 07/23] editor: refine pill tab dividers and accents Show dedicated dividers at every pill boundary while keeping connected dividers inactive-only. Keep tab.border as the rounded pill outline and inset active top/bottom accents so mixed colors do not protrude through the corners. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../lib/stylelint/vscode-known-variables.json | 3 ++ .../browser/media/connectedEditorTabs.css | 8 ++++ .../contrib/modernUI/browser/media/tabs.css | 40 +++++++++++++------ .../editor/editorTabBar.fixture.ts | 2 +- .../playwright/tests/editorTabBar.spec.ts | 10 +++-- 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 93847c77fde282..d284d2578f477c 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1122,8 +1122,11 @@ "--modern-ui-editor-tab-active-foreground", "--modern-ui-editor-tab-active-hover-background", "--modern-ui-editor-tab-border", + "--modern-ui-editor-tab-current-border-bottom", + "--modern-ui-editor-tab-current-border-top", "--modern-ui-editor-tab-custom-active-background", "--modern-ui-editor-tab-custom-active-hover-background", + "--modern-ui-editor-tab-custom-border", "--modern-ui-editor-tab-custom-hover-background", "--modern-ui-editor-tab-custom-inactive-background", "--modern-ui-editor-tab-custom-unfocused-active-background", diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index f9aeee6ae2ea36..81894b3de58a7a 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -66,6 +66,14 @@ background-color: var(--modern-ui-connected-tab-surface); } +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.last-in-row) + .tab > .tab-divider { + display: none; +} + +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.active):not(.last-in-row) + .tab:not(.active) > .tab-divider { + display: block; +} + .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.multi-selected:not(:focus) > .tab-fill { outline: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder); outline-offset: calc(-1 * (var(--vscode-spacing-size20) + var(--vscode-strokeThickness))); diff --git a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css index c1762836d813fb..7a85246461f8a3 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css @@ -116,14 +116,6 @@ box-shadow: inset var(--vscode-strokeThickness) 0 var(--modern-ui-editor-tab-last-pinned-border); } -.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:not(.last-in-row) + .tab > .tab-divider { - display: none; -} - -.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:not(.active):not(.last-in-row) + .tab:not(.active) > .tab-divider { - display: block; -} - /* Reuses the last-pinned-border color for the horizontal divider between the pinned and normal tab rows. */ .modern-ui-tabs.monaco-workbench .part.editor .title.two-tab-bars > .tabs-and-actions-container:first-child { box-shadow: inset 0 calc(-1 * var(--vscode-strokeThickness)) var(--modern-ui-editor-tab-last-pinned-border); @@ -312,16 +304,40 @@ .modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor .editor-group-container.active .tabs-container > .tab.active > .tab-fill, .modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .modern-ui-editor-tab-group.modern-ui-editor-tab-group-active .modern-ui-editor-tab.active > .modern-ui-editor-tab-fill { + --modern-ui-editor-tab-current-border-top: var(--modern-ui-editor-tab-active-border-top); + --modern-ui-editor-tab-current-border-bottom: var(--modern-ui-editor-tab-active-border); background-color: var(--modern-ui-editor-tab-active-background); - border-top: var(--vscode-strokeThickness) solid var(--modern-ui-editor-tab-active-border-top); - border-bottom: var(--vscode-strokeThickness) solid var(--modern-ui-editor-tab-active-border); } .modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor .editor-group-container:not(.active) .tabs-container > .tab.active > .tab-fill, .modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .modern-ui-editor-tab-group:not(.modern-ui-editor-tab-group-active) .modern-ui-editor-tab.active > .modern-ui-editor-tab-fill { + --modern-ui-editor-tab-current-border-top: var(--modern-ui-editor-tab-unfocused-active-border-top); + --modern-ui-editor-tab-current-border-bottom: var(--modern-ui-editor-tab-unfocused-active-border); background-color: var(--modern-ui-editor-tab-unfocused-active-background); - border-top: var(--vscode-strokeThickness) solid var(--modern-ui-editor-tab-unfocused-active-border-top); - border-bottom: var(--vscode-strokeThickness) solid var(--modern-ui-editor-tab-unfocused-active-border); +} + +.modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs):not(:is(.hc-black, .hc-light)) .part.editor .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::before, +.modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs):not(:is(.hc-black, .hc-light)) .part.editor .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::after, +.modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs):not(:is(.hc-black, .hc-light)) .modern-ui-editor-tab.active > .modern-ui-editor-tab-fill::before, +.modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs):not(:is(.hc-black, .hc-light)) .modern-ui-editor-tab.active > .modern-ui-editor-tab-fill::after { + content: ''; + position: absolute; + right: var(--vscode-cornerRadius-small); + left: var(--vscode-cornerRadius-small); + height: var(--vscode-strokeThickness); + pointer-events: none; +} + +.modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs):not(:is(.hc-black, .hc-light)) .part.editor .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::before, +.modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs):not(:is(.hc-black, .hc-light)) .modern-ui-editor-tab.active > .modern-ui-editor-tab-fill::before { + top: calc(-1 * var(--vscode-strokeThickness)); + background-color: var(--modern-ui-editor-tab-current-border-top); +} + +.modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs):not(:is(.hc-black, .hc-light)) .part.editor .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::after, +.modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs):not(:is(.hc-black, .hc-light)) .modern-ui-editor-tab.active > .modern-ui-editor-tab-fill::after { + bottom: calc(-1 * var(--vscode-strokeThickness)); + background-color: var(--modern-ui-editor-tab-current-border-bottom); } .modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor .editor-group-container.active .tabs-container > .tab.active:hover > .tab-fill, diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts index 0d9bb7ad20bc30..f1e1017c73bd53 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -1002,7 +1002,7 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { Pill: defineComponentFixture({ render: renderBorderOwnership(true, ModernUIEditorTabStyle.Pill), themes: ['dark'], - expectedVisualDescriptions: ['Pill tabs hide the standard indicators and paint the customized yellow outline, cyan top, and pink bottom on the inset rounded fill. A short white divider appears only between adjacent inactive pills.'], + expectedVisualDescriptions: ['Pill tabs hide the standard indicators and paint the customized yellow outline around every inset rounded fill. Inset cyan and pink accents stop before the rounded corners, and a short white divider appears at every shared boundary, including beside the active pill.'], }), Connected: defineComponentFixture({ render: renderBorderOwnership(true, ModernUIEditorTabStyle.Connected), diff --git a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts index b1c44f82be95c7..3dd91d9c0eacdb 100644 --- a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts +++ b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts @@ -99,10 +99,12 @@ for (const [style, expected] of [ ['Pill', { topIndicator: { display: 'none' }, bottomIndicator: { display: 'none' }, - fillTop: 'rgb(34, 211, 238)', - fillBottom: 'rgb(244, 63, 94)', + fillTop: 'rgb(250, 204, 21)', + fillBottom: 'rgb(250, 204, 21)', fillSide: 'rgb(250, 204, 21)', - visibleDividers: ['rgb(255, 255, 255)'], + topAccent: 'rgb(34, 211, 238)', + bottomAccent: 'rgb(244, 63, 94)', + visibleDividers: ['rgb(255, 255, 255)', 'rgb(255, 255, 255)', 'rgb(255, 255, 255)'], }], ['Connected', { topIndicator: { display: 'none' }, @@ -136,6 +138,8 @@ for (const [style, expected] of [ fillTop: fillStyle.borderTopColor, fillBottom: fillStyle.borderBottomColor, fillSide: fillStyle.borderRightColor, + topAccent: getComputedStyle(fill, '::before').backgroundColor, + bottomAccent: getComputedStyle(fill, '::after').backgroundColor, visibleDividers, }; }); From 3a4c4c769f1d6f253a0d5f1e7f763eac4379f557 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Tue, 29 Sep 2026 11:56:01 -0700 Subject: [PATCH 08/23] editor: isolate tab border continuity fixtures Make the divider transparent in continuity fixtures so the shared tab and editor boundary is tested without an inherited divider repainting it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../componentFixtures/editor/editorTabBar.fixture.ts | 5 +++-- .../playwright/tests/editorTabBar.spec.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts index f1e1017c73bd53..fa258acecd551b 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -436,6 +436,7 @@ function renderConnectedBorderContinuity(activeTabIndex: number): (ctx: Componen [EDITOR_BORDER]: '#22D3EE', [TAB_ACTIVE_BORDER_TOP]: '#22D3EE', [TAB_BORDER]: '#22D3EE', + [TAB_DIVIDER]: '#00000000', }, }); } @@ -1014,12 +1015,12 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { FirstActive: defineComponentFixture({ render: renderConnectedBorderContinuity(0), themes: ['dark'], - expectedVisualDescriptions: ['The first active connected tab joins the cyan outer editor frame, cap, strip separator, and document frame as one continuous stroke.'], + expectedVisualDescriptions: ['The first active connected tab joins the cyan outer editor frame, cap, strip separator, and document frame as one continuous stroke. The divider is explicitly transparent so it does not repeat the shared boundary.'], }), MiddleActive: defineComponentFixture({ render: renderConnectedBorderContinuity(1), themes: ['dark'], - expectedVisualDescriptions: ['The middle active connected tab has smooth rounded cyan top corners shared with adjacent surfaces, without a straight overlay or corner notch.'], + expectedVisualDescriptions: ['The middle active connected tab has smooth rounded cyan top corners shared with adjacent surfaces, without a straight overlay, corner notch, or divider beside the active tab.'], }), }), ConnectedSurface: defineThemedFixtureGroup({ diff --git a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts index 3dd91d9c0eacdb..b69426f7f72d7d 100644 --- a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts +++ b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts @@ -147,7 +147,10 @@ for (const [style, expected] of [ }); } -for (const fixture of ['FirstActive', 'MiddleActive']) { +for (const [fixture, expectedDividers] of [ + ['FirstActive', ['rgba(0, 0, 0, 0)']], + ['MiddleActive', []], +] as const) { test(`connected border continuity stays aligned for ${fixture}`, async ({ page }) => { await openFixture(page, `editor/editorTabBar/editorTabBar/ConnectedBorderContinuity/${fixture}/Dark`, '.tabs-container > .tab.active'); const state = await page.locator('.part.editor').evaluate(editor => { @@ -161,6 +164,9 @@ for (const fixture of ['FirstActive', 'MiddleActive']) { } const editorRect = editor.getBoundingClientRect(); const fillRect = fill.getBoundingClientRect(); + const visibleDividers = [...group.querySelectorAll('.tab-divider')] + .filter(element => getComputedStyle(element).display !== 'none') + .map(element => getComputedStyle(element).backgroundColor); return { editorBorder: getComputedStyle(editor).borderTopColor, capTop: getComputedStyle(fill).borderTopColor, @@ -168,6 +174,7 @@ for (const fixture of ['FirstActive', 'MiddleActive']) { separator: getComputedStyle(strip, '::after').backgroundColor, indicator: getComputedStyle(indicator).display, topAligned: Math.abs(editorRect.top - fillRect.top) <= 1, + visibleDividers, }; }); expect(state).toEqual({ @@ -177,6 +184,7 @@ for (const fixture of ['FirstActive', 'MiddleActive']) { separator: 'rgb(34, 211, 238)', indicator: 'none', topAligned: true, + visibleDividers: expectedDividers, }); }); } From 80dda2c7009ebfc197233dad69d37c357e210e1e Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Tue, 29 Sep 2026 12:00:38 -0700 Subject: [PATCH 09/23] editor: expand tab divider fixture coverage Use a longer inactive tab run so divider ownership remains visually obvious across legacy, pill, and connected styles. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../componentFixtures/editor/editorTabBar.fixture.ts | 11 ++++++++--- .../playwright/tests/editorTabBar.spec.ts | 6 +++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts index fa258acecd551b..fc4412129cae71 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -414,11 +414,16 @@ function customizeTheme(theme: IColorTheme, customizations: Readonly void { return render(modernUI, { editorTabStyle, + width: 1200, editors: [ { resource: file('/project/alpha.ts'), pinned: true }, - { resource: file('/project/beta.ts'), pinned: true, active: true }, + { resource: file('/project/beta.ts'), pinned: true }, { resource: file('/project/gamma.ts'), pinned: true }, - { resource: file('/project/delta.ts'), pinned: true }, + { resource: file('/project/delta.ts'), pinned: true, active: true }, + { resource: file('/project/epsilon.ts'), pinned: true }, + { resource: file('/project/zeta.ts'), pinned: true }, + { resource: file('/project/eta.ts'), pinned: true }, + { resource: file('/project/theta.ts'), pinned: true }, ], colorCustomizations: getLegacyEditorTabBorderCustomizations(), }); @@ -1008,7 +1013,7 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { Connected: defineComponentFixture({ render: renderBorderOwnership(true, ModernUIEditorTabStyle.Connected), themes: ['dark'], - expectedVisualDescriptions: ['Connected tabs paint the customized cyan top border on the rounded cap, retain the pink bottom indicator, and use yellow tab boundaries. A short white divider appears only between adjacent inactive tabs and overlays the shared boundary.'], + expectedVisualDescriptions: ['Connected tabs paint the customized cyan top border on the rounded cap, retain the pink bottom indicator, and use yellow tab boundaries. Five short white dividers span the long run of adjacent inactive tabs, while both boundaries beside the active tab suppress the divider.'], }), }), ConnectedBorderContinuity: defineThemedFixtureGroup({ diff --git a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts index b69426f7f72d7d..a34924ad63d808 100644 --- a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts +++ b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts @@ -94,7 +94,7 @@ for (const [style, expected] of [ ['Legacy', { topIndicator: { display: 'block', color: 'rgb(34, 211, 238)' }, bottomIndicator: { display: 'block', color: 'rgb(244, 63, 94)' }, - visibleDividers: ['rgb(255, 255, 255)', 'rgb(255, 255, 255)', 'rgb(255, 255, 255)'], + visibleDividers: Array(7).fill('rgb(255, 255, 255)'), }], ['Pill', { topIndicator: { display: 'none' }, @@ -104,7 +104,7 @@ for (const [style, expected] of [ fillSide: 'rgb(250, 204, 21)', topAccent: 'rgb(34, 211, 238)', bottomAccent: 'rgb(244, 63, 94)', - visibleDividers: ['rgb(255, 255, 255)', 'rgb(255, 255, 255)', 'rgb(255, 255, 255)'], + visibleDividers: Array(7).fill('rgb(255, 255, 255)'), }], ['Connected', { topIndicator: { display: 'none' }, @@ -112,7 +112,7 @@ for (const [style, expected] of [ fillTop: 'rgb(34, 211, 238)', fillBottom: 'rgba(0, 0, 0, 0)', fillSide: 'rgb(250, 204, 21)', - visibleDividers: ['rgb(255, 255, 255)'], + visibleDividers: Array(5).fill('rgb(255, 255, 255)'), }], ] as const) { test(`${style} tabs retain their border ownership`, async ({ page }) => { From 48859a9e0c5c525a089150c207a446a1a50f4973 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Tue, 29 Sep 2026 13:01:20 -0700 Subject: [PATCH 10/23] editor: limit connected tab boundaries to active cap Keep inactive connected tabs unoutlined so their dedicated dividers own shared boundaries. Preserve the active left edge and inset selected top accents before their corners. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/media/connectedEditorTabs.css | 30 +++++++++++++++++++ .../editor/editorTabBar.fixture.ts | 2 +- .../playwright/tests/editorTabBar.spec.ts | 28 ++++++++++++++--- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 81894b3de58a7a..dec18572b76ca8 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -154,6 +154,20 @@ bottom: calc(-1 * var(--vscode-strokeThickness)); } +/* In connected tabs the active cap owns the structural tab boundary. Inactive + * tabs use the dedicated divider instead of repeating that boundary around each fill. */ +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.active):not(.connected-tab-upper-row) > .tab-fill { + border-color: transparent; +} + +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container.active > .title.tabs .tabs-container > .tab:hover:not(.selected):not(.active):not(.connected-tab-upper-row) > .tab-fill { + border-bottom-color: var(--modern-ui-editor-tab-hover-border); +} + +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container:not(.active) > .title.tabs .tabs-container > .tab:hover:not(.selected):not(.active):not(.connected-tab-upper-row) > .tab-fill { + border-bottom-color: var(--modern-ui-editor-tab-unfocused-hover-border); +} + .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .sticky-tabs-background, .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tabs-bar-add-tab { background-color: var(--modern-ui-connected-tab-strip-background, var(--vscode-editorGroupHeader-tabsBackground)); @@ -234,6 +248,15 @@ display: none; } +/* Non-active selection remains an accent rather than another connected cap. + * Match the inset modified indicator so the stroke stops before either corner. */ +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.selected:not(.active).tab-border-top > .tab-border-top-container { + left: var(--vscode-spacing-size20); + width: calc(100% - var(--vscode-spacing-size40)); + height: var(--vscode-spacing-size20); + border-radius: var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) 0 0; +} + /* The foreground mask below removes the cap's side stroke where each shoulder curves out. */ .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::before, .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::after, @@ -608,6 +631,13 @@ border-left-color: transparent; } +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:first-child:not(.connected-tab-upper-row) > .tab-fill, +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping .tabs-container > .tab.active:first-child > .tab-fill, +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.last-in-row + .tab.active > .tab-fill { + border-left-width: var(--vscode-strokeThickness); + border-left-color: var(--modern-ui-connected-tab-border); +} + .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container:not(.wrapping) .tabs-container > .tab.connected-tab-upper-row:not(.sticky-compact):not(.tab-actions-left) > .tab-actions { right: var(--modern-ui-connected-tab-action-inset); } diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts index fc4412129cae71..c7b18bc4694c4f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -1013,7 +1013,7 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { Connected: defineComponentFixture({ render: renderBorderOwnership(true, ModernUIEditorTabStyle.Connected), themes: ['dark'], - expectedVisualDescriptions: ['Connected tabs paint the customized cyan top border on the rounded cap, retain the pink bottom indicator, and use yellow tab boundaries. Five short white dividers span the long run of adjacent inactive tabs, while both boundaries beside the active tab suppress the divider.'], + expectedVisualDescriptions: ['The active connected cap alone owns the customized yellow tab boundary, cyan top border, and pink bottom indicator. Five short white dividers span the long run of adjacent inactive tabs, whose fills remain unoutlined, while both boundaries beside the active tab suppress the divider.'], }), }), ConnectedBorderContinuity: defineThemedFixtureGroup({ diff --git a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts index a34924ad63d808..127d347a5f5057 100644 --- a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts +++ b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts @@ -49,13 +49,15 @@ for (const [group, expected] of [ activeTop: { indicator: 'none', color: 'rgb(34, 211, 238)' }, activeBottom: { display: 'block', color: 'rgb(244, 63, 94)' }, activeSide: 'rgb(250, 204, 21)', - selectedTop: { display: 'block', color: 'rgb(163, 230, 53)' }, + selectedTop: { display: 'block', color: 'rgb(163, 230, 53)', height: 2, leftInset: 2, rightInset: 2 }, + selectedBorder: 'rgba(0, 0, 0, 0)', }], ['InactiveGroup', { activeTop: { indicator: 'none', color: 'rgb(192, 132, 252)' }, activeBottom: { display: 'block', color: 'rgb(251, 146, 60)' }, activeSide: 'rgb(250, 204, 21)', - selectedTop: { display: 'block', color: 'rgb(163, 230, 53)' }, + selectedTop: { display: 'block', color: 'rgb(163, 230, 53)', height: 2, leftInset: 2, rightInset: 2 }, + selectedBorder: 'rgba(0, 0, 0, 0)', }], ] as const) { test(`connected tabs show legacy border customizations in ${group}`, async ({ page }) => { @@ -68,7 +70,8 @@ for (const [group, expected] of [ const activeBottom = active?.querySelector('.tab-border-bottom-container'); const activeFill = active?.querySelector('.tab-fill'); const selectedTop = selected?.querySelector('.tab-border-top-container'); - if (!activeTop || !activeBottom || !activeFill || !selectedTop) { + const selectedFill = selected?.querySelector('.tab-fill'); + if (!activeTop || !activeBottom || !activeFill || !selectedTop || !selectedFill) { throw new Error('Expected active and selected connected-tab border indicators'); } const style = (element: HTMLElement) => { @@ -82,7 +85,13 @@ for (const [group, expected] of [ }, activeBottom: style(activeBottom), activeSide: getComputedStyle(activeFill).borderRightColor, - selectedTop: style(selectedTop), + selectedTop: { + ...style(selectedTop), + height: selectedTop.getBoundingClientRect().height, + leftInset: selectedTop.getBoundingClientRect().left - selectedFill.getBoundingClientRect().left, + rightInset: selectedFill.getBoundingClientRect().right - selectedTop.getBoundingClientRect().right, + }, + selectedBorder: getComputedStyle(selectedFill).borderRightColor, }; }); @@ -112,6 +121,7 @@ for (const [style, expected] of [ fillTop: 'rgb(34, 211, 238)', fillBottom: 'rgba(0, 0, 0, 0)', fillSide: 'rgb(250, 204, 21)', + inactiveBorder: 'rgba(0, 0, 0, 0)', visibleDividers: Array(5).fill('rgb(255, 255, 255)'), }], ] as const) { @@ -125,6 +135,10 @@ for (const [style, expected] of [ throw new Error('Expected active tab border elements'); } const fillStyle = getComputedStyle(fill); + const inactiveFill = active.parentElement!.querySelector('.tab:not(.active) > .tab-fill'); + if (!inactiveFill) { + throw new Error('Expected an inactive tab fill'); + } const visibleDividers = [...active.parentElement!.querySelectorAll('.tab-divider')] .filter(element => getComputedStyle(element).display !== 'none') .map(element => getComputedStyle(element).backgroundColor); @@ -138,6 +152,7 @@ for (const [style, expected] of [ fillTop: fillStyle.borderTopColor, fillBottom: fillStyle.borderBottomColor, fillSide: fillStyle.borderRightColor, + inactiveBorder: getComputedStyle(inactiveFill).borderRightColor, topAccent: getComputedStyle(fill, '::before').backgroundColor, bottomAccent: getComputedStyle(fill, '::after').backgroundColor, visibleDividers, @@ -164,12 +179,15 @@ for (const [fixture, expectedDividers] of [ } const editorRect = editor.getBoundingClientRect(); const fillRect = fill.getBoundingClientRect(); + const fillStyle = getComputedStyle(fill); const visibleDividers = [...group.querySelectorAll('.tab-divider')] .filter(element => getComputedStyle(element).display !== 'none') .map(element => getComputedStyle(element).backgroundColor); return { editorBorder: getComputedStyle(editor).borderTopColor, capTop: getComputedStyle(fill).borderTopColor, + capLeft: fillStyle.borderLeftColor, + capLeftWidth: fillStyle.borderLeftWidth, capSide: getComputedStyle(fill).borderRightColor, separator: getComputedStyle(strip, '::after').backgroundColor, indicator: getComputedStyle(indicator).display, @@ -180,6 +198,8 @@ for (const [fixture, expectedDividers] of [ expect(state).toEqual({ editorBorder: 'rgb(34, 211, 238)', capTop: 'rgb(34, 211, 238)', + capLeft: 'rgb(34, 211, 238)', + capLeftWidth: '1px', capSide: 'rgb(34, 211, 238)', separator: 'rgb(34, 211, 238)', indicator: 'none', From a3bd39ab9c2327a57848c35fdbdce1c00a2375cc Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Tue, 29 Sep 2026 13:17:56 -0700 Subject: [PATCH 11/23] editor: clarify connected row boundary ownership Expose first-in-row state directly instead of deriving it from sibling selectors, and add Dark and Light High Contrast fixtures and assertions for connected tab boundaries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../parts/editor/multiEditorTabsControl.ts | 37 +++++++++------- .../browser/media/connectedEditorTabs.css | 18 +++----- .../editor/editorTabBar.fixture.ts | 6 ++- .../editor/multiEditorTabsControl.test.ts | 6 ++- .../playwright/tests/editorTabBar.spec.ts | 43 +++++++++++++++++++ 5 files changed, 80 insertions(+), 30 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index 447027bf75789f..6d436bda841b01 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -2202,7 +2202,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { // Remove old css classes that are not needed anymore for (const tab of tabsContainer.children) { - tab.classList.remove('last-in-row'); + tab.classList.remove('first-in-row', 'last-in-row'); } } @@ -2272,16 +2272,13 @@ export class MultiEditorTabsControl extends EditorTabsControl { }); } - // Update the `last-in-row` class on tabs when wrapping - // is enabled (it doesn't do any harm otherwise). This - // class controls additional properties of tab when it is - // the last tab in a row + // Measure before changing row-boundary classes to avoid layout churn. if (tabsWrapMultiLine) { // Using a map here to change classes after the for loop is // crucial for performance because changing the class on a // tab can result in layouts of the rendering engine. - const tabs = new Map(); + const tabs = new Map(); let currentTabsPosY: number | undefined = undefined; let lastTab: HTMLElement | undefined = undefined; @@ -2293,28 +2290,38 @@ export class MultiEditorTabsControl extends EditorTabsControl { const tabPosY = tab.offsetTop; // Marks a new or the first row of tabs - if (tabPosY !== currentTabsPosY) { + const firstInRow = tabPosY !== currentTabsPosY; + if (firstInRow) { currentTabsPosY = tabPosY; if (lastTab) { - tabs.set(lastTab, true); // previous tab must be last in row then + tabs.get(lastTab)!.lastInRow = true; } } - // Always remember last tab and ensure the - // last-in-row class is not present until - // we know the tab is last lastTab = tab; - tabs.set(tab, false); + tabs.set(tab, { firstInRow, lastInRow: false }); } - // Last tab overally is always last-in-row + // The final tab is always last-in-row. if (lastTab) { - tabs.set(lastTab, true); + tabs.get(lastTab)!.lastInRow = true; } - for (const [tab, lastInRow] of tabs) { + for (const [tab, { firstInRow, lastInRow }] of tabs) { + tab.classList.toggle('first-in-row', firstInRow); tab.classList.toggle('last-in-row', lastInRow); } + } else { + let firstInRow = true; + for (const child of tabsContainer.children) { + if (child === this.addTabContainer) { + continue; + } + + child.classList.toggle('first-in-row', firstInRow); + child.classList.remove('last-in-row'); + firstInRow = false; + } } return tabsWrapMultiLine; diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index dec18572b76ca8..b88cb4aff56ec8 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -281,8 +281,7 @@ box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--modern-ui-connected-tab-current-background); } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:first-child > .tab-fill::before, -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.last-in-row + .tab.active > .tab-fill::before { +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.first-in-row > .tab-fill::before { content: none; } @@ -331,8 +330,7 @@ right: 0; } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-left-edge > .tab-connected-edge::before, -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.last-in-row + .tab.active > .tab-connected-edge::before, +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:is(.connected-tab-left-edge, .first-in-row) > .tab-connected-edge::before, .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .modern-ui-editor-tab.active.connected-tab-left-edge > .tab-connected-edge::before { content: none; } @@ -434,7 +432,7 @@ right: calc(var(--modern-ui-connected-tab-action-inset) - var(--vscode-spacing-size20) + var(--vscode-strokeThickness)); } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container:not(.wrapping) .tabs-container > .tab:not(.connected-tab-upper-row):not(:first-child):not(.sticky-compact).tab-actions-left > .tab-actions { +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container:not(.wrapping) .tabs-container > .tab:not(.connected-tab-upper-row):not(.first-in-row):not(.sticky-compact).tab-actions-left > .tab-actions { left: calc(var(--modern-ui-connected-tab-action-inset) - var(--vscode-spacing-size20) + var(--vscode-strokeThickness)); } @@ -461,7 +459,7 @@ outline-offset: calc(-1 * var(--vscode-strokeThickness)); } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping .tabs-container > .tab.last-in-row + .tab:not(.connected-tab-upper-row):not(.sticky-compact).tab-actions-left > .tab-actions { +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping .tabs-container > .tab.first-in-row:not(.connected-tab-upper-row):not(.sticky-compact).tab-actions-left > .tab-actions { left: calc(var(--modern-ui-connected-tab-action-inset) - var(--vscode-spacing-size20) - var(--vscode-strokeThickness)); } @@ -621,9 +619,7 @@ border-top-color: transparent; } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:first-child:not(.connected-tab-upper-row) > .tab-fill, -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping .tabs-container > .tab:first-child > .tab-fill, -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.last-in-row + .tab > .tab-fill { +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.first-in-row > .tab-fill { left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; @@ -631,9 +627,7 @@ border-left-color: transparent; } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:first-child:not(.connected-tab-upper-row) > .tab-fill, -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping .tabs-container > .tab.active:first-child > .tab-fill, -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.last-in-row + .tab.active > .tab-fill { +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.first-in-row > .tab-fill { border-left-width: var(--vscode-strokeThickness); border-left-color: var(--modern-ui-connected-tab-border); } diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts index c7b18bc4694c4f..42a7a8f6278c5e 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -1013,7 +1013,11 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { Connected: defineComponentFixture({ render: renderBorderOwnership(true, ModernUIEditorTabStyle.Connected), themes: ['dark'], - expectedVisualDescriptions: ['The active connected cap alone owns the customized yellow tab boundary, cyan top border, and pink bottom indicator. Five short white dividers span the long run of adjacent inactive tabs, whose fills remain unoutlined, while both boundaries beside the active tab suppress the divider.'], + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: [ + 'In the standard dark theme, the active connected cap alone owns the customized yellow tab boundary, cyan top border, and pink bottom indicator. Five short white dividers span the long run of adjacent inactive tabs, whose fills remain unoutlined.', + 'In High Contrast themes, explicit contrast and focus outlines intentionally replace the standard-theme active-only boundary policy so every tab remains distinguishable.', + ], }), }), ConnectedBorderContinuity: defineThemedFixtureGroup({ diff --git a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts index 892db65d4f27e9..5340719d1ac42a 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -986,7 +986,7 @@ suite('MultiEditorTabsControl', () => { ...context, insets: [0, 0, 0], corners: [rowStart ? '0px' : radius, radius, active && !upper ? '0px' : '4px', rowStart || active && !upper ? '0px' : '4px'], - leftBorder: rowStart ? 'rgba(0, 0, 0, 0)' : undefined, + leftBorder: rowStart ? active ? 'rgb(0, 0, 0)' : 'rgba(0, 0, 0, 0)' : undefined, }); } } @@ -1020,14 +1020,16 @@ suite('MultiEditorTabsControl', () => { const expected = { top: tab.offsetTop === tabs[0].offsetTop, upper: tab.offsetTop !== tabs.at(-1)!.offsetTop, + first: index === 0 || (wrapping && tab.offsetTop !== tabs[index - 1].offsetTop), last: wrapping && (index === tabs.length - 1 || tab.offsetTop !== tabs[index + 1].offsetTop), }; const actual = { top: tab.classList.contains('connected-tab-top-row'), upper: tab.classList.contains('connected-tab-upper-row'), + first: tab.classList.contains('first-in-row'), last: tab.classList.contains('last-in-row'), }; - if (actual.top !== expected.top || actual.upper !== expected.upper || actual.last !== expected.last) { + if (actual.top !== expected.top || actual.upper !== expected.upper || actual.first !== expected.first || actual.last !== expected.last) { mismatches.push({ width, activeIndex, index, expected, actual }); } } diff --git a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts index 127d347a5f5057..87fc27fa81889d 100644 --- a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts +++ b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts @@ -162,6 +162,49 @@ for (const [style, expected] of [ }); } +for (const [theme, expectedFocusBorder] of [ + ['DarkHighContrast', 'rgb(243, 133, 24)'], + ['LightHighContrast', 'rgb(0, 107, 189)'], +] as const) { + test(`connected borders retain high contrast ownership in ${theme}`, async ({ page }) => { + await openFixture(page, `editor/editorTabBar/editorTabBar/BorderOwnership/Connected/${theme}`, '.tabs-container > .tab.active'); + const ownership = await page.locator('.editor-group-container').evaluate(group => { + const activeFill = group.querySelector('.tab.active > .tab-fill'); + const inactiveFill = group.querySelector('.tab:not(.active) > .tab-fill'); + if (!activeFill || !inactiveFill) { + throw new Error('Expected active and inactive connected tab fills'); + } + + const activeStyle = getComputedStyle(activeFill); + const inactiveStyle = getComputedStyle(inactiveFill); + return { + active: { + top: activeStyle.borderTopColor, + side: activeStyle.borderRightColor, + bottom: activeStyle.borderBottomColor, + }, + inactive: { + top: inactiveStyle.borderTopColor, + side: inactiveStyle.borderRightColor, + }, + frame: getComputedStyle(group, '::after').borderColor, + }; + }); + expect(ownership).toEqual({ + active: { + top: 'rgb(34, 211, 238)', + side: expectedFocusBorder, + bottom: 'rgba(0, 0, 0, 0)', + }, + inactive: { + top: 'rgb(250, 204, 21)', + side: 'rgb(250, 204, 21)', + }, + frame: expectedFocusBorder, + }); + }); +} + for (const [fixture, expectedDividers] of [ ['FirstActive', ['rgba(0, 0, 0, 0)']], ['MiddleActive', []], From 2b24715eed01744a70518b1d887653e5a9039bde Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Tue, 29 Sep 2026 13:37:42 -0700 Subject: [PATCH 12/23] editor: avoid duplicate connected frame edge Let the outer editor frame own the left edge when the first connected tab is active, while retaining the tab boundary when no outer frame is present. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/media/connectedEditorTabs.css | 5 +++++ .../playwright/tests/editorTabBar.spec.ts | 20 +++++++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index b88cb4aff56ec8..0f3e90c04e960a 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -632,6 +632,11 @@ border-left-color: var(--modern-ui-connected-tab-border); } +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .monaco-grid-view .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.first-in-row > .tab-fill { + border-left-width: 0; + border-left-color: transparent; +} + .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container:not(.wrapping) .tabs-container > .tab.connected-tab-upper-row:not(.sticky-compact):not(.tab-actions-left) > .tab-actions { right: var(--modern-ui-connected-tab-action-inset); } diff --git a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts index 87fc27fa81889d..237ef4d99e3c5d 100644 --- a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts +++ b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts @@ -205,9 +205,17 @@ for (const [theme, expectedFocusBorder] of [ }); } -for (const [fixture, expectedDividers] of [ - ['FirstActive', ['rgba(0, 0, 0, 0)']], - ['MiddleActive', []], +for (const [fixture, expected] of [ + ['FirstActive', { + capLeft: 'rgba(0, 0, 0, 0)', + capLeftWidth: '0px', + dividers: ['rgba(0, 0, 0, 0)'], + }], + ['MiddleActive', { + capLeft: 'rgb(34, 211, 238)', + capLeftWidth: '1px', + dividers: [], + }], ] as const) { test(`connected border continuity stays aligned for ${fixture}`, async ({ page }) => { await openFixture(page, `editor/editorTabBar/editorTabBar/ConnectedBorderContinuity/${fixture}/Dark`, '.tabs-container > .tab.active'); @@ -241,13 +249,13 @@ for (const [fixture, expectedDividers] of [ expect(state).toEqual({ editorBorder: 'rgb(34, 211, 238)', capTop: 'rgb(34, 211, 238)', - capLeft: 'rgb(34, 211, 238)', - capLeftWidth: '1px', + capLeft: expected.capLeft, + capLeftWidth: expected.capLeftWidth, capSide: 'rgb(34, 211, 238)', separator: 'rgb(34, 211, 238)', indicator: 'none', topAligned: true, - visibleDividers: expectedDividers, + visibleDividers: expected.dividers, }); }); } From b1002307ca528c53cb32e600237164870b69e6bc Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Tue, 29 Sep 2026 14:33:37 -0700 Subject: [PATCH 13/23] editor: refine tab divider ownership Keep legacy tab separators single-owned, make modern divider policies explicit, align the connected active bottom accent, and inherit high-contrast divider accents from the active group frame. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../editor/media/multieditortabscontrol.css | 4 --- .../browser/media/connectedEditorTabs.css | 8 +++++ .../contrib/modernUI/browser/media/tabs.css | 4 +++ .../editor/editorTabBar.fixture.ts | 12 +++---- .../playwright/tests/editorTabBar.spec.ts | 31 +++++++++++++------ 5 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css index 869fb661dbe36f..1e11ae5f864b17 100644 --- a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css +++ b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css @@ -140,10 +140,6 @@ transform: translateY(-50%); } -.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:not(.last-in-row) + .tab > .tab-divider { - display: block; -} - .monaco-workbench .part.editor > .content .editor-group-container > .title > .tabs-and-actions-container > .monaco-scrollable-element > .tab-connected-overflow-edge { display: none; } diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 0f3e90c04e960a..756f983fad720c 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -74,6 +74,10 @@ display: block; } +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab > .tab-divider { + background-color: var(--modern-ui-connected-tab-border); +} + .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.multi-selected:not(:focus) > .tab-fill { outline: var(--vscode-strokeThickness) solid var(--vscode-contrastActiveBorder); outline-offset: calc(-1 * (var(--vscode-spacing-size20) + var(--vscode-strokeThickness))); @@ -248,6 +252,10 @@ display: none; } +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.tab-border-bottom > .tab-border-bottom-container { + bottom: calc(-1 * var(--vscode-strokeThickness)); +} + /* Non-active selection remains an accent rather than another connected cap. * Match the inset modified indicator so the stroke stops before either corner. */ .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.selected:not(.active).tab-border-top > .tab-border-top-container { diff --git a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css index 7a85246461f8a3..1a05e85dc3388e 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css @@ -274,6 +274,10 @@ border-block-width: var(--vscode-spacing-size20) var(--vscode-spacing-size60); } +.modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:not(.last-in-row) + .tab > .tab-divider { + display: block; +} + /* Tab fills retain the theme's transparency. The action overlay uses a separately flattened * version of the same color so labels beneath the close button cannot show through. */ .modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor .editor-group-container.active .tabs-container > .tab:not(.selected):not(.active) > .tab-fill, diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts index 42a7a8f6278c5e..b78759cc9e422f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -22,7 +22,7 @@ import { TestConfigurationService } from '../../../../../platform/configuration/ import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; import { listErrorForeground, listWarningForeground } from '../../../../../platform/theme/common/colors/listColors.js'; -import { isDark } from '../../../../../platform/theme/common/theme.js'; +import { isDark, isHighContrast } from '../../../../../platform/theme/common/theme.js'; import { asCssVariableName } from '../../../../../platform/theme/common/colorUtils.js'; import { IColorTheme, IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; @@ -412,7 +412,7 @@ function customizeTheme(theme: IColorTheme, customizations: Readonly void { - return render(modernUI, { + return ctx => render(modernUI, { editorTabStyle, width: 1200, editors: [ @@ -425,8 +425,8 @@ function renderBorderOwnership(modernUI: boolean, editorTabStyle?: ModernUIEdito { resource: file('/project/eta.ts'), pinned: true }, { resource: file('/project/theta.ts'), pinned: true }, ], - colorCustomizations: getLegacyEditorTabBorderCustomizations(), - }); + colorCustomizations: isHighContrast(ctx.theme.type) ? undefined : getLegacyEditorTabBorderCustomizations(), + })(ctx); } function renderConnectedBorderContinuity(activeTabIndex: number): (ctx: ComponentFixtureContext) => void { @@ -1003,7 +1003,7 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { Legacy: defineComponentFixture({ render: renderBorderOwnership(false), themes: ['dark'], - expectedVisualDescriptions: ['Legacy tabs retain their standard full-width top and bottom border indicators, with a short white divider at every shared tab boundary.'], + expectedVisualDescriptions: ['Legacy tabs retain their standard full-width top and bottom border indicators. Their existing tab.border separators remain the sole paint owner at shared boundaries, so no dedicated divider is added.'], }), Pill: defineComponentFixture({ render: renderBorderOwnership(true, ModernUIEditorTabStyle.Pill), @@ -1016,7 +1016,7 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { additionalThemes: ['darkHighContrast', 'lightHighContrast'], expectedVisualDescriptions: [ 'In the standard dark theme, the active connected cap alone owns the customized yellow tab boundary, cyan top border, and pink bottom indicator. Five short white dividers span the long run of adjacent inactive tabs, whose fills remain unoutlined.', - 'In High Contrast themes, explicit contrast and focus outlines intentionally replace the standard-theme active-only boundary policy so every tab remains distinguishable.', + 'In High Contrast themes, default theme colors are used without customization. Explicit contrast and focus outlines keep every tab distinguishable, and inactive dividers inherit the active editor group accent.', ], }), }), diff --git a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts index 237ef4d99e3c5d..3136cdd00eb476 100644 --- a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts +++ b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts @@ -103,7 +103,7 @@ for (const [style, expected] of [ ['Legacy', { topIndicator: { display: 'block', color: 'rgb(34, 211, 238)' }, bottomIndicator: { display: 'block', color: 'rgb(244, 63, 94)' }, - visibleDividers: Array(7).fill('rgb(255, 255, 255)'), + visibleDividers: [], }], ['Pill', { topIndicator: { display: 'none' }, @@ -142,6 +142,10 @@ for (const [style, expected] of [ const visibleDividers = [...active.parentElement!.querySelectorAll('.tab-divider')] .filter(element => getComputedStyle(element).display !== 'none') .map(element => getComputedStyle(element).backgroundColor); + const tabsAndActions = active.closest('.tabs-and-actions-container'); + if (!tabsAndActions) { + throw new Error('Expected tabs and actions container'); + } const indicatorStyle = (element: HTMLElement) => { const style = getComputedStyle(element); return { display: style.display, color: style.backgroundColor }; @@ -155,16 +159,20 @@ for (const [style, expected] of [ inactiveBorder: getComputedStyle(inactiveFill).borderRightColor, topAccent: getComputedStyle(fill, '::before').backgroundColor, bottomAccent: getComputedStyle(fill, '::after').backgroundColor, + bottomBoundaryOffset: bottom.getBoundingClientRect().bottom - tabsAndActions.getBoundingClientRect().bottom, visibleDividers, }; }); expect(ownership).toMatchObject(expected); + if (style === 'Connected') { + expect(ownership.bottomBoundaryOffset).toBe(0); + } }); } -for (const [theme, expectedFocusBorder] of [ - ['DarkHighContrast', 'rgb(243, 133, 24)'], - ['LightHighContrast', 'rgb(0, 107, 189)'], +for (const [theme, expected] of [ + ['DarkHighContrast', { activeTop: 'rgb(243, 133, 24)', accent: 'rgb(243, 133, 24)' }], + ['LightHighContrast', { activeTop: 'rgb(181, 32, 13)', accent: 'rgb(0, 107, 189)' }], ] as const) { test(`connected borders retain high contrast ownership in ${theme}`, async ({ page }) => { await openFixture(page, `editor/editorTabBar/editorTabBar/BorderOwnership/Connected/${theme}`, '.tabs-container > .tab.active'); @@ -177,6 +185,9 @@ for (const [theme, expectedFocusBorder] of [ const activeStyle = getComputedStyle(activeFill); const inactiveStyle = getComputedStyle(inactiveFill); + const visibleDividers = [...group.querySelectorAll('.tab-divider')] + .filter(element => getComputedStyle(element).display !== 'none') + .map(element => getComputedStyle(element).backgroundColor); return { active: { top: activeStyle.borderTopColor, @@ -188,19 +199,21 @@ for (const [theme, expectedFocusBorder] of [ side: inactiveStyle.borderRightColor, }, frame: getComputedStyle(group, '::after').borderColor, + visibleDividers, }; }); expect(ownership).toEqual({ active: { - top: 'rgb(34, 211, 238)', - side: expectedFocusBorder, + top: expected.activeTop, + side: expected.accent, bottom: 'rgba(0, 0, 0, 0)', }, inactive: { - top: 'rgb(250, 204, 21)', - side: 'rgb(250, 204, 21)', + top: 'rgba(0, 0, 0, 0)', + side: 'rgba(0, 0, 0, 0)', }, - frame: expectedFocusBorder, + frame: expected.accent, + visibleDividers: Array(5).fill(expected.accent), }); }); } From c5965a06e7d3276ce9dfb46746b84ccc9176f906 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Tue, 29 Sep 2026 15:32:43 -0700 Subject: [PATCH 14/23] editor: preserve high contrast tab boundaries Keep dedicated dividers out of High Contrast, retain complete theme-owned tab outlines, and render customized wrapped-tab accents inside rounded pill corners. Align the tab geometry tests with production row and grid ownership. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/media/connectedEditorTabs.css | 19 ++++- .../contrib/modernUI/browser/media/tabs.css | 8 ++ .../browser/modernUI.contribution.test.ts | 22 +++--- .../editor/editorTabBar.fixture.ts | 22 +++++- .../editor/multiEditorTabsControl.test.ts | 6 +- .../playwright/tests/editorTabBar.spec.ts | 73 +++++++++++++++++-- 6 files changed, 129 insertions(+), 21 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 756f983fad720c..b1442fbb587f0b 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -74,8 +74,8 @@ display: block; } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab > .tab-divider { - background-color: var(--modern-ui-connected-tab-border); +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:not(.active):not(.last-in-row) + .tab:not(.active) > .tab-divider { + display: none; } .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.multi-selected:not(:focus) > .tab-fill { @@ -662,6 +662,21 @@ content: none; } +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-upper-row.tab-border-bottom > .tab-border-bottom-container { + display: none; +} + +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-upper-row.tab-border-bottom > .tab-fill::after { + content: ''; + position: absolute; + right: var(--vscode-cornerRadius-small); + bottom: calc(-1 * var(--vscode-strokeThickness)); + left: var(--vscode-cornerRadius-small); + height: var(--vscode-strokeThickness); + background-color: var(--tab-border-bottom-color); + pointer-events: none; +} + .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row > .tab-connected-edge { display: none; } diff --git a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css index 1a05e85dc3388e..cf3d4f5f5ef558 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css @@ -278,6 +278,10 @@ display: block; } +.modern-ui-tabs.monaco-workbench:is(.hc-black, .hc-light) .part.editor > .content .editor-group-container > .title .tabs-container > .tab:not(.last-in-row) + .tab > .tab-divider { + display: none; +} + /* Tab fills retain the theme's transparency. The action overlay uses a separately flattened * version of the same color so labels beneath the close button cannot show through. */ .modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor .editor-group-container.active .tabs-container > .tab:not(.selected):not(.active) > .tab-fill, @@ -839,6 +843,10 @@ outline: none !important; } +:is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:not(.active):not(.selected):not(:focus):not(:hover) > .tab-fill { + border-color: var(--vscode-tab-border); +} + :is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs) .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab:is(.active, .selected):not(:focus) > .tab-fill, :is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench.modern-ui-connected-editor-tabs .part.editor > .content .editor-group-container > .title .tabs-and-actions-container .tabs-container > .tab.selected:not(.active):not(:focus) > .tab-fill, :is(.hc-black, .hc-light).modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs) .modern-ui-editor-tab.active:not(:focus) > .modern-ui-editor-tab-fill { diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index 1233d0246bbbe5..d66146fa2bb191 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -2643,7 +2643,7 @@ suite('ModernUIContribution', () => { const title = appendElement(group, 'title tabs'); const row = appendElement(title, 'tabs-and-actions-container'); const tabs = appendElement(row, 'tabs-container'); - const firstFill = appendElement(appendElement(tabs, 'tab'), 'tab-fill'); + const firstFill = appendElement(appendElement(tabs, 'tab first-in-row'), 'tab-fill'); const tab = appendElement(tabs, 'tab active connected-tab-top-row'); tab.style.width = '120px'; const fill = appendElement(tab, 'tab-fill'); @@ -2752,7 +2752,7 @@ suite('ModernUIContribution', () => { const title = appendElement(group, 'title tabs'); const row = appendElement(title, 'tabs-and-actions-container'); const tabs = appendElement(row, 'tabs-container'); - const tab = appendElement(tabs, 'tab'); + const tab = appendElement(tabs, 'tab first-in-row'); tab.style.position = 'relative'; tab.style.width = '120px'; const fill = appendElement(tab, 'tab-fill'); @@ -2853,7 +2853,7 @@ suite('ModernUIContribution', () => { const group = appendElement(content, 'editor-group-container active'); const title = appendElement(group, 'title tabs'); const tabs = appendElement(title, 'tabs-container'); - const firstTab = appendElement(tabs, 'tab active'); + const firstTab = appendElement(tabs, 'tab active first-in-row'); const firstFill = appendElement(firstTab, 'tab-fill'); const middleTab = appendElement(tabs, 'tab'); const middleFill = appendElement(middleTab, 'tab-fill'); @@ -2903,13 +2903,14 @@ suite('ModernUIContribution', () => { document.body.appendChild(root); store.add(toDisposable(() => root.remove())); - const editor = appendElement(root, 'part editor'); + const gridView = appendElement(root, 'monaco-grid-view'); + const editor = appendElement(gridView, 'part editor'); const content = appendElement(editor, 'content'); const group = appendElement(content, 'editor-group-container active'); const title = appendElement(group, 'title tabs'); const row = appendElement(title, 'tabs-and-actions-container wrapping'); const tabs = appendElement(row, 'tabs-container'); - const upperTab = appendElement(tabs, 'tab active connected-tab-upper-row connected-tab-top-row'); + const upperTab = appendElement(tabs, 'tab active connected-tab-upper-row connected-tab-top-row first-in-row'); const upperFill = appendElement(upperTab, 'tab-fill'); const inactiveBottomTab = appendElement(tabs, 'tab'); const inactiveBottomFill = appendElement(inactiveBottomTab, 'tab-fill'); @@ -2951,13 +2952,14 @@ suite('ModernUIContribution', () => { document.body.appendChild(root); store.add(toDisposable(() => root.remove())); - const editor = appendElement(root, 'part editor'); + const gridView = appendElement(root, 'monaco-grid-view'); + const editor = appendElement(gridView, 'part editor'); const content = appendElement(editor, 'content'); const group = appendElement(content, 'editor-group-container active'); const title = appendElement(group, 'title tabs'); const row = appendElement(title, 'tabs-and-actions-container'); const tabs = appendElement(row, 'tabs-container'); - const tab = appendElement(tabs, 'tab active connected-tab-top-row'); + const tab = appendElement(tabs, 'tab active connected-tab-top-row first-in-row'); const fill = appendElement(tab, 'tab-fill'); const targetWindow = getWindow(root); const outlines = []; @@ -2998,7 +3000,7 @@ suite('ModernUIContribution', () => { const rowEndTab = appendElement(tabs, 'tab active last-in-row'); const rowEndFill = appendElement(rowEndTab, 'tab-fill'); const rowEndEdge = appendElement(rowEndTab, 'tab-connected-edge'); - const rowStartTab = appendElement(tabs, 'tab'); + const rowStartTab = appendElement(tabs, 'tab first-in-row'); const rowStartFill = appendElement(rowStartTab, 'tab-fill'); const rowStartEdge = appendElement(rowStartTab, 'tab-connected-edge'); appendElement(appendElement(tabs, 'tab'), 'tab-fill'); @@ -3705,8 +3707,8 @@ suite('ModernUIContribution', () => { paneForeground: getWindow(paneAction.actionLabel).getComputedStyle(paneAction.actionLabel).color, activeBackground: getWindow(activeFill).getComputedStyle(activeFill).backgroundColor, activeForeground: getWindow(activeLabelAnchor).getComputedStyle(activeLabelAnchor).color, - activeBorderTop: getWindow(activeFill).getComputedStyle(activeFill).borderTopColor, - activeBorderBottom: getWindow(activeFill).getComputedStyle(activeFill).borderBottomColor, + activeBorderTop: getWindow(activeFill).getComputedStyle(activeFill, '::before').backgroundColor, + activeBorderBottom: getWindow(activeFill).getComputedStyle(activeFill, '::after').backgroundColor, inactiveBackground: getWindow(inactiveFill).getComputedStyle(inactiveFill).backgroundColor, inactiveForeground: getWindow(inactiveLabelAnchor).getComputedStyle(inactiveLabelAnchor).color, unfocusedActiveBackground: getWindow(unfocusedFill).getComputedStyle(unfocusedFill).backgroundColor, diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts index b78759cc9e422f..43ced9e14fe122 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -930,6 +930,15 @@ function renderWrappedConnectedSurface(activeTabIndex: number, forcedHoverTab?: }); } +function renderWrappedConnectedBorderOwnership(): (ctx: ComponentFixtureContext) => void { + return renderConnectedSurface(0, undefined, undefined, { + width: 820, + editors: manyEditorSpecs().slice(0, 10).map((spec, index) => ({ ...spec, active: index === 0 })), + partOptions: { wrapTabs: true, editorActionsLocation: 'hidden' }, + colorCustomizations: getLegacyEditorTabBorderCustomizations(), + }); +} + function renderWrappedConnectedCloseActionHover(activeTabIndex: number): (ctx: ComponentFixtureContext) => void { return renderConnectedSurface(activeTabIndex, activeTabIndex, undefined, { width: 820, @@ -1008,7 +1017,11 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { Pill: defineComponentFixture({ render: renderBorderOwnership(true, ModernUIEditorTabStyle.Pill), themes: ['dark'], - expectedVisualDescriptions: ['Pill tabs hide the standard indicators and paint the customized yellow outline around every inset rounded fill. Inset cyan and pink accents stop before the rounded corners, and a short white divider appears at every shared boundary, including beside the active pill.'], + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: [ + 'In the standard dark theme, Pill tabs hide the standard indicators and paint the customized yellow outline around every inset rounded fill. Inset cyan and pink accents stop before the rounded corners, and a short white divider appears at every shared boundary, including beside the active pill.', + 'In High Contrast themes, default theme colors are used without customization. Every pill retains its complete theme boundary, and the dedicated divider remains hidden.', + ], }), Connected: defineComponentFixture({ render: renderBorderOwnership(true, ModernUIEditorTabStyle.Connected), @@ -1016,9 +1029,14 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { additionalThemes: ['darkHighContrast', 'lightHighContrast'], expectedVisualDescriptions: [ 'In the standard dark theme, the active connected cap alone owns the customized yellow tab boundary, cyan top border, and pink bottom indicator. Five short white dividers span the long run of adjacent inactive tabs, whose fills remain unoutlined.', - 'In High Contrast themes, default theme colors are used without customization. Explicit contrast and focus outlines keep every tab distinguishable, and inactive dividers inherit the active editor group accent.', + 'In High Contrast themes, default theme colors are used without customization. Every tab retains its explicit theme boundary, and the dedicated divider remains hidden to avoid duplicating shared edges.', ], }), + ConnectedWrapped: defineComponentFixture({ + render: renderWrappedConnectedBorderOwnership(), + themes: ['dark'], + expectedVisualDescriptions: ['The active tab in the upper wrapped row retains rounded pill geometry. Its cyan top and inset pink bottom accents stop before the corners instead of drawing full-width straight indicators.'], + }), }), ConnectedBorderContinuity: defineThemedFixtureGroup({ FirstActive: defineComponentFixture({ diff --git a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts index 5340719d1ac42a..57fb6f594219f3 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -160,9 +160,11 @@ suite('MultiEditorTabsControl', () => { mainWindow.document.body.appendChild(root); disposables.add(toDisposable(() => root.remove())); const editor = $('.part.editor.editor-tabs-multiple'); + const gridView = $('.monaco-grid-view'); const content = $('.content'); const group = $('.editor-group-container.active'); - root.appendChild(editor); + root.appendChild(gridView); + gridView.appendChild(editor); editor.appendChild(content); content.appendChild(group); group.appendChild(container); @@ -986,7 +988,7 @@ suite('MultiEditorTabsControl', () => { ...context, insets: [0, 0, 0], corners: [rowStart ? '0px' : radius, radius, active && !upper ? '0px' : '4px', rowStart || active && !upper ? '0px' : '4px'], - leftBorder: rowStart ? active ? 'rgb(0, 0, 0)' : 'rgba(0, 0, 0, 0)' : undefined, + leftBorder: rowStart ? 'rgba(0, 0, 0, 0)' : undefined, }); } } diff --git a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts index 3136cdd00eb476..7254ff173edb61 100644 --- a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts +++ b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts @@ -171,9 +171,37 @@ for (const [style, expected] of [ } for (const [theme, expected] of [ - ['DarkHighContrast', { activeTop: 'rgb(243, 133, 24)', accent: 'rgb(243, 133, 24)' }], - ['LightHighContrast', { activeTop: 'rgb(181, 32, 13)', accent: 'rgb(0, 107, 189)' }], + ['DarkHighContrast', { activeTop: 'rgb(243, 133, 24)', accent: 'rgb(243, 133, 24)', tabBorder: 'rgb(111, 195, 223)' }], + ['LightHighContrast', { activeTop: 'rgb(181, 32, 13)', accent: 'rgb(0, 107, 189)', tabBorder: 'rgb(15, 74, 133)' }], ] as const) { + test(`pill borders retain high contrast ownership in ${theme}`, async ({ page }) => { + await openFixture(page, `editor/editorTabBar/editorTabBar/BorderOwnership/Pill/${theme}`, '.tabs-container > .tab.active'); + const ownership = await page.locator('.tabs-container').evaluate(tabs => { + const activeFill = tabs.querySelector('.tab.active > .tab-fill'); + const inactiveFill = tabs.querySelector('.tab:not(.active) > .tab-fill'); + if (!activeFill || !inactiveFill) { + throw new Error('Expected active and inactive pill tab fills'); + } + return { + active: { + top: getComputedStyle(activeFill).borderTopColor, + side: getComputedStyle(activeFill).borderRightColor, + }, + inactive: { + top: getComputedStyle(inactiveFill).borderTopColor, + side: getComputedStyle(inactiveFill).borderRightColor, + }, + visibleDividers: [...tabs.querySelectorAll('.tab-divider')] + .filter(element => getComputedStyle(element).display !== 'none').length, + }; + }); + expect(ownership).toEqual({ + active: { top: expected.accent, side: expected.accent }, + inactive: { top: expected.tabBorder, side: expected.tabBorder }, + visibleDividers: 0, + }); + }); + test(`connected borders retain high contrast ownership in ${theme}`, async ({ page }) => { await openFixture(page, `editor/editorTabBar/editorTabBar/BorderOwnership/Connected/${theme}`, '.tabs-container > .tab.active'); const ownership = await page.locator('.editor-group-container').evaluate(group => { @@ -209,15 +237,50 @@ for (const [theme, expected] of [ bottom: 'rgba(0, 0, 0, 0)', }, inactive: { - top: 'rgba(0, 0, 0, 0)', - side: 'rgba(0, 0, 0, 0)', + top: expected.tabBorder, + side: expected.tabBorder, }, frame: expected.accent, - visibleDividers: Array(5).fill(expected.accent), + visibleDividers: [], }); }); } +test('wrapped upper connected tabs inset customized border accents', async ({ page }) => { + await openFixture(page, 'editor/editorTabBar/editorTabBar/BorderOwnership/ConnectedWrapped/Dark', '.tabs-container > .tab.active.connected-tab-upper-row'); + const ownership = await page.locator('.tab.active.connected-tab-upper-row').evaluate(active => { + const fill = active.querySelector('.tab-fill'); + const top = active.querySelector('.tab-border-top-container'); + const bottom = active.querySelector('.tab-border-bottom-container'); + if (!fill || !top || !bottom) { + throw new Error('Expected wrapped active tab border elements'); + } + const bottomAccent = getComputedStyle(fill, '::after'); + return { + topIndicator: getComputedStyle(top).display, + topColor: getComputedStyle(fill).borderTopColor, + bottomIndicator: getComputedStyle(bottom).display, + bottomAccent: { + color: bottomAccent.backgroundColor, + left: bottomAccent.left, + right: bottomAccent.right, + height: bottomAccent.height, + }, + }; + }); + expect(ownership).toEqual({ + topIndicator: 'none', + topColor: 'rgb(34, 211, 238)', + bottomIndicator: 'none', + bottomAccent: { + color: 'rgb(244, 63, 94)', + left: '4px', + right: '4px', + height: '1px', + }, + }); +}); + for (const [fixture, expected] of [ ['FirstActive', { capLeft: 'rgba(0, 0, 0, 0)', From fc29cd9c52cdadb5b78c7bd9c5b1c2626da4d717 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Tue, 29 Sep 2026 15:48:04 -0700 Subject: [PATCH 15/23] editor: fix shared connected tab CI regressions Keep editor-only Pill accent pseudo-elements off shared Sessions tabs, model production row-start ownership in the Sessions fixture, and restore the missing component-fixture theme type import. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/test/browser/editorPart.test.ts | 3 ++- .../browser/media/connectedEditorTabs.css | 5 +++++ .../contrib/modernUI/browser/media/tabs.css | 20 ++++++++++++------- .../editor/editorTabBar.fixture.ts | 1 + .../playwright/tests/editorTabBar.spec.ts | 7 +++++-- 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/vs/sessions/test/browser/editorPart.test.ts b/src/vs/sessions/test/browser/editorPart.test.ts index 39e9085790f946..0de4ab788104ed 100644 --- a/src/vs/sessions/test/browser/editorPart.test.ts +++ b/src/vs/sessions/test/browser/editorPart.test.ts @@ -54,7 +54,7 @@ suite('Sessions - EditorPart', () => { const row = appendElement(title, 'tabs-and-actions-container'); const scrollable = appendElement(row, 'monaco-scrollable-element'); const tabs = appendElement(scrollable, 'tabs-container'); - const first = appendElement(tabs, 'tab active connected-tab-top-row'); + const first = appendElement(tabs, 'tab active connected-tab-top-row first-in-row'); const firstFill = appendElement(first, 'tab-fill'); const second = appendElement(tabs, 'tab connected-tab-top-row'); const secondFill = appendElement(second, 'tab-fill'); @@ -107,6 +107,7 @@ suite('Sessions - EditorPart', () => { row.classList.toggle('wrapping', wrapped); first.classList.toggle('last-in-row', wrapped); first.classList.toggle('connected-tab-upper-row', wrapped); + second.classList.toggle('first-in-row', wrapped); second.classList.toggle('connected-tab-top-row', !wrapped); const cap = mainWindow.getComputedStyle(secondFill); assert.deepStrictEqual({ diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index b1442fbb587f0b..2c02b982614ae4 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -645,6 +645,11 @@ border-left-color: transparent; } +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .monaco-grid-view .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.first-in-row:not(.active):not(.selected):not(:focus):not(:hover) > .tab-fill { + border-left-width: 0; + border-left-color: transparent; +} + .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container:not(.wrapping) .tabs-container > .tab.connected-tab-upper-row:not(.sticky-compact):not(.tab-actions-left) > .tab-actions { right: var(--modern-ui-connected-tab-action-inset); } diff --git a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css index cf3d4f5f5ef558..e0b2f13e560d3b 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/tabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/tabs.css @@ -324,10 +324,18 @@ background-color: var(--modern-ui-editor-tab-unfocused-active-background); } +.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .modern-ui-editor-tab-group.modern-ui-editor-tab-group-active .modern-ui-editor-tab.active > .modern-ui-editor-tab-fill { + border-top: var(--vscode-strokeThickness) solid var(--modern-ui-editor-tab-active-border-top); + border-bottom: var(--vscode-strokeThickness) solid var(--modern-ui-editor-tab-active-border); +} + +.modern-ui-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .modern-ui-editor-tab-group:not(.modern-ui-editor-tab-group-active) .modern-ui-editor-tab.active > .modern-ui-editor-tab-fill { + border-top: var(--vscode-strokeThickness) solid var(--modern-ui-editor-tab-unfocused-active-border-top); + border-bottom: var(--vscode-strokeThickness) solid var(--modern-ui-editor-tab-unfocused-active-border); +} + .modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs):not(:is(.hc-black, .hc-light)) .part.editor .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::before, -.modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs):not(:is(.hc-black, .hc-light)) .part.editor .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::after, -.modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs):not(:is(.hc-black, .hc-light)) .modern-ui-editor-tab.active > .modern-ui-editor-tab-fill::before, -.modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs):not(:is(.hc-black, .hc-light)) .modern-ui-editor-tab.active > .modern-ui-editor-tab-fill::after { +.modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs):not(:is(.hc-black, .hc-light)) .part.editor .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::after { content: ''; position: absolute; right: var(--vscode-cornerRadius-small); @@ -336,14 +344,12 @@ pointer-events: none; } -.modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs):not(:is(.hc-black, .hc-light)) .part.editor .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::before, -.modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs):not(:is(.hc-black, .hc-light)) .modern-ui-editor-tab.active > .modern-ui-editor-tab-fill::before { +.modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs):not(:is(.hc-black, .hc-light)) .part.editor .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::before { top: calc(-1 * var(--vscode-strokeThickness)); background-color: var(--modern-ui-editor-tab-current-border-top); } -.modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs):not(:is(.hc-black, .hc-light)) .part.editor .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::after, -.modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs):not(:is(.hc-black, .hc-light)) .modern-ui-editor-tab.active > .modern-ui-editor-tab-fill::after { +.modern-ui-tabs.monaco-workbench:not(.modern-ui-connected-editor-tabs):not(:is(.hc-black, .hc-light)) .part.editor .tabs-container > .tab.active:not(.multi-selected) > .tab-fill::after { bottom: calc(-1 * var(--vscode-strokeThickness)); background-color: var(--modern-ui-editor-tab-current-border-bottom); } diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts index 43ced9e14fe122..2841aa5f339be1 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -31,6 +31,7 @@ import { testWorkspace } from '../../../../../platform/workspace/test/common/tes import { ITreeViewsDnDService } from '../../../../../editor/common/services/treeViewsDndService.js'; import { TreeViewsDnDService } from '../../../../../editor/common/services/treeViewsDnd.js'; import { CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { ColorThemeData } from '../../../../services/themes/common/colorThemeData.js'; import { EditorInput } from '../../../../common/editor/editorInput.js'; import { EditorInputCapabilities, EditorsOrder, IEditorPartOptions, IToolbarActions, Verbosity } from '../../../../common/editor.js'; import { EditorGroupModel } from '../../../../common/editor/editorGroupModel.js'; diff --git a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts index 7254ff173edb61..f86d1e7b97bf12 100644 --- a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts +++ b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts @@ -206,8 +206,9 @@ for (const [theme, expected] of [ await openFixture(page, `editor/editorTabBar/editorTabBar/BorderOwnership/Connected/${theme}`, '.tabs-container > .tab.active'); const ownership = await page.locator('.editor-group-container').evaluate(group => { const activeFill = group.querySelector('.tab.active > .tab-fill'); - const inactiveFill = group.querySelector('.tab:not(.active) > .tab-fill'); - if (!activeFill || !inactiveFill) { + const inactiveFill = group.querySelector('.tab:not(.active):not(.first-in-row) > .tab-fill'); + const firstFill = group.querySelector('.tab.first-in-row > .tab-fill'); + if (!activeFill || !inactiveFill || !firstFill) { throw new Error('Expected active and inactive connected tab fills'); } @@ -226,6 +227,7 @@ for (const [theme, expected] of [ top: inactiveStyle.borderTopColor, side: inactiveStyle.borderRightColor, }, + firstLeft: getComputedStyle(firstFill).borderLeftColor, frame: getComputedStyle(group, '::after').borderColor, visibleDividers, }; @@ -240,6 +242,7 @@ for (const [theme, expected] of [ top: expected.tabBorder, side: expected.tabBorder, }, + firstLeft: expected.tabBorder, frame: expected.accent, visibleDividers: [], }); From 9521da9b0850e084a42d3088f190d28f12affa6b Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Tue, 29 Sep 2026 16:12:10 -0700 Subject: [PATCH 16/23] editor: avoid duplicate connected hover borders Use the boundary-aligned standard indicator for bottom-row Connected hover accents while upper wrapped pills keep their inset fill-owned border. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modernUI/browser/media/connectedEditorTabs.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 2c02b982614ae4..d7bfc3f58d95f0 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -164,12 +164,12 @@ border-color: transparent; } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container.active > .title.tabs .tabs-container > .tab:hover:not(.selected):not(.active):not(.connected-tab-upper-row) > .tab-fill { - border-bottom-color: var(--modern-ui-editor-tab-hover-border); +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:hover:not(.selected):not(.active):not(.connected-tab-upper-row) > .tab-border-bottom-container { + bottom: calc(-1 * var(--vscode-strokeThickness)); } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container:not(.active) > .title.tabs .tabs-container > .tab:hover:not(.selected):not(.active):not(.connected-tab-upper-row) > .tab-fill { - border-bottom-color: var(--modern-ui-editor-tab-unfocused-hover-border); +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row:hover:not(.selected):not(.active) > .tab-border-bottom-container { + display: none; } .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .sticky-tabs-background, From af0937a6a28d73eaa420c2b7ef9bdb3dbbc4ee22 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Wed, 30 Sep 2026 11:02:39 -0700 Subject: [PATCH 17/23] editor: restore wrapped connected hover accents Render focused and unfocused hover-border customizations as a single inset accent on upper wrapped tabs, preserving their rounded pill geometry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/media/connectedEditorTabs.css | 16 ++++++- .../editor/editorTabBar.fixture.ts | 4 ++ .../playwright/tests/editorTabBar.spec.ts | 45 +++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index d7bfc3f58d95f0..7ffab6a2c20808 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -671,17 +671,29 @@ display: none; } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-upper-row.tab-border-bottom > .tab-fill::after { +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-upper-row.tab-border-bottom > .tab-fill::after, +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row:hover:not(.selected):not(.active) > .tab-fill::after { content: ''; position: absolute; right: var(--vscode-cornerRadius-small); bottom: calc(-1 * var(--vscode-strokeThickness)); left: var(--vscode-cornerRadius-small); height: var(--vscode-strokeThickness); - background-color: var(--tab-border-bottom-color); pointer-events: none; } +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-upper-row.tab-border-bottom > .tab-fill::after { + background-color: var(--tab-border-bottom-color); +} + +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container.active > .title.tabs .tabs-container > .tab.connected-tab-upper-row:hover:not(.selected):not(.active) > .tab-fill::after { + background-color: var(--modern-ui-editor-tab-hover-border); +} + +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container:not(.active) > .title.tabs .tabs-container > .tab.connected-tab-upper-row:hover:not(.selected):not(.active) > .tab-fill::after { + background-color: var(--modern-ui-editor-tab-unfocused-hover-border); +} + .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row > .tab-connected-edge { display: none; } diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts index 2841aa5f339be1..318a3afea02d29 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -53,9 +53,11 @@ import { TAB_ACTIVE_BORDER_TOP, TAB_BORDER, TAB_DIVIDER, + TAB_HOVER_BORDER, TAB_SELECTED_BORDER_TOP, TAB_UNFOCUSED_ACTIVE_BORDER, TAB_UNFOCUSED_ACTIVE_BORDER_TOP, + TAB_UNFOCUSED_HOVER_BORDER, } from '../../../../common/theme.js'; import { DEFAULT_EDITOR_PART_OPTIONS, IEditorGroupMenuIds, IEditorGroupsView, IEditorGroupView, IEditorPartsView } from '../../../../browser/parts/editor/editor.js'; import { BreadcrumbsService, IBreadcrumbsService } from '../../../../browser/parts/editor/breadcrumbs.js'; @@ -864,8 +866,10 @@ function getLegacyEditorTabBorderCustomizations(): Readonly { + await openFixture(page, 'editor/editorTabBar/editorTabBar/BorderOwnership/ConnectedWrapped/Dark', '.tabs-container > .tab.active.connected-tab-upper-row'); + const hovered = page.locator('.tabs-container > .tab.connected-tab-upper-row:not(.active)').first(); + await hovered.hover(); + const readOwnership = () => hovered.evaluate(tab => { + const fill = tab.querySelector('.tab-fill'); + const bottom = tab.querySelector('.tab-border-bottom-container'); + if (!fill || !bottom) { + throw new Error('Expected hovered wrapped tab border elements'); + } + const accent = getComputedStyle(fill, '::after'); + return { + fillBottom: getComputedStyle(fill).borderBottomColor, + indicator: getComputedStyle(bottom).display, + accent: { + color: accent.backgroundColor, + left: accent.left, + right: accent.right, + height: accent.height, + }, + }; + }); + expect(await readOwnership()).toEqual({ + fillBottom: 'rgba(0, 0, 0, 0)', + indicator: 'none', + accent: { + color: 'rgb(249, 115, 22)', + left: '4px', + right: '4px', + height: '1px', + }, + }); + await page.locator('.editor-group-container').evaluate(group => group.classList.remove('active')); + expect(await readOwnership()).toEqual({ + fillBottom: 'rgba(0, 0, 0, 0)', + indicator: 'none', + accent: { + color: 'rgb(168, 85, 247)', + left: '4px', + right: '4px', + height: '1px', + }, + }); +}); + for (const [fixture, expected] of [ ['FirstActive', { capLeft: 'rgba(0, 0, 0, 0)', From 7ea82c4ed94f3929e18ec7c9e7889cc6ac420380 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Wed, 30 Sep 2026 13:14:36 -0700 Subject: [PATCH 18/23] editor: complete connected tab color parity Honor explicit Modern editor action colors in Connected tabs while preserving seamless defaults. Guard the complete 46-token editor-tab audit across legacy, shared Modern, editor-specific Modern, and editor-group-header color families. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../lib/stylelint/vscode-known-variables.json | 3 + src/vs/workbench/contrib/modernUI/README.md | 4 +- .../browser/media/connectedEditorTabs.css | 12 +- .../browser/modernUI.contribution.test.ts | 113 +++++++++++++++++- .../browser/modernTabColorCustomizations.ts | 6 + .../editor/editorTabBar.fixture.ts | 17 +++ .../playwright/tests/editorTabBar.spec.ts | 60 ++++++++++ 7 files changed, 205 insertions(+), 10 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index d284d2578f477c..d588aafcf4cf34 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1125,6 +1125,9 @@ "--modern-ui-editor-tab-current-border-bottom", "--modern-ui-editor-tab-current-border-top", "--modern-ui-editor-tab-custom-active-background", + "--modern-ui-editor-tab-custom-action-active-background", + "--modern-ui-editor-tab-custom-action-active-hover-background", + "--modern-ui-editor-tab-custom-action-hover-background", "--modern-ui-editor-tab-custom-active-hover-background", "--modern-ui-editor-tab-custom-border", "--modern-ui-editor-tab-custom-hover-background", diff --git a/src/vs/workbench/contrib/modernUI/README.md b/src/vs/workbench/contrib/modernUI/README.md index 60e521c11b3595..47a33319855955 100644 --- a/src/vs/workbench/contrib/modernUI/README.md +++ b/src/vs/workbench/contrib/modernUI/README.md @@ -32,7 +32,7 @@ Wrapped tabs reserve the same action space before and after row classification; Automatic reveal includes the complete shoulder and rounds fractional layout bounds outward so the selected action is not clipped. Manual scrolling can still move part of the selected tab, including its action, offscreen. In that case a stationary cap and shoulder finish the visible outline; their stroke is aligned with the document separator, and the clipping mask falls back to `editor.background` when the theme does not define a tab-strip background. -By default, the connected design uses `editor.background` for the active tab so the selected tab and editor body read as one document well. The strip and inactive tabs use `editorGroupHeader.connectedTabsBackground`, and their hover fill is derived from `foreground` over that background. Explicit tab fill and label color customizations from `tab.*`, `modernTab.*`, and `modernEditorTab.*` override those defaults for connected and pill tabs alike; editor-specific Modern UI colors take precedence over shared Modern UI colors, which take precedence over legacy colors. Connected action surfaces and frame colors retain their connected-specific treatment: the active tab's action container remains transparent, while its outside stroke, shoulders, and strip separator use `editorGroupHeader.tabsBorder`, falling back to `tab.border`. Upper wrapped and separate pinned rows change only the tab shape, not its fill or label color precedence. +By default, the connected design uses `editor.background` for the active tab so the selected tab and editor body read as one document well. The strip and inactive tabs use `editorGroupHeader.connectedTabsBackground`, and their hover fill is derived from `foreground` over that background. Explicit tab fill, label, and action color customizations from `tab.*`, `modernTab.*`, and `modernEditorTab.*` override those defaults for connected and pill tabs alike; editor-specific Modern UI colors take precedence over shared Modern UI colors, which take precedence over legacy colors. The active Connected tab's action container remains transparent unless a corresponding `modernEditorTab.*ActionBackground` color is explicitly customized. Its outside stroke, shoulders, and strip separator use `editorGroupHeader.tabsBorder`, falling back to `tab.border`. Upper wrapped and separate pinned rows change only the tab shape, not its color precedence. Agents chat tabs use the same connected shape, strip colors, and theme-aware stroke as side-panel tabs, with the selected tab joining the active or inactive session's background instead of `editor.background`. Overflowing chat tabs expose a thin, draggable horizontal scrollbar on hover without increasing the tab-row height. Tab selection, close actions, status indicators, inline renaming, drag and drop, and keyboard navigation retain their existing behavior. @@ -92,7 +92,7 @@ Resize grip dots mark only boundaries between top-level parts, not modal editor Activity bar items in non-default top or bottom positions use the `modernTab.*` colors because they share the pane tab presentation. -Editor tab fill and label colors have the same precedence in the `connected` and `pill` styles. Existing `tab.*` fill and label customizations remain effective unless the corresponding `modernTab.*` or `modernEditorTab.*` color is explicitly customized. Connected action backgrounds and frame borders continue to use the connected treatment described above. +Editor tab fill, label, and action colors have the same precedence in the `connected` and `pill` styles. Existing `tab.*` fill and label customizations remain effective unless the corresponding `modernTab.*` or `modernEditorTab.*` color is explicitly customized. Connected action backgrounds retain their seamless defaults until an explicit editor action color overrides them; frame borders continue to use the connected treatment described above. `statusBar.inactiveBackground` only changes the resting background in an inactive window with a workspace or folder open. Debugging and other status bar background overrides take precedence. Empty windows continue to use `statusBar.noFolderBackground`. Leaving the inactive color unset preserves the existing status bar behavior. diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 7ffab6a2c20808..d78f3b9a284b7d 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -97,8 +97,8 @@ --modern-ui-editor-tab-unfocused-inactive-background: var(--modern-ui-editor-tab-custom-unfocused-inactive-background, var(--modern-ui-connected-tab-strip-background, var(--vscode-editorGroupHeader-tabsBackground))); --modern-ui-editor-tab-hover-background: var(--modern-ui-editor-tab-custom-hover-background, color-mix(in srgb, var(--vscode-foreground) 6%, var(--modern-ui-connected-tab-strip-background, var(--vscode-editorGroupHeader-tabsBackground)))); --modern-ui-editor-tab-unfocused-hover-background: var(--modern-ui-editor-tab-custom-unfocused-hover-background, color-mix(in srgb, var(--vscode-foreground) 6%, var(--modern-ui-connected-tab-strip-background, var(--vscode-editorGroupHeader-tabsBackground)))); - --modern-ui-editor-tab-action-hover-background: var(--modern-ui-editor-tab-hover-background); - --modern-ui-editor-tab-action-unfocused-hover-background: var(--modern-ui-editor-tab-unfocused-hover-background); + --modern-ui-editor-tab-action-hover-background: var(--modern-ui-editor-tab-custom-action-hover-background, var(--modern-ui-editor-tab-hover-background)); + --modern-ui-editor-tab-action-unfocused-hover-background: var(--modern-ui-editor-tab-custom-action-hover-background, var(--modern-ui-editor-tab-unfocused-hover-background)); --modern-ui-connected-tab-action-background: var(--modern-ui-editor-tab-inactive-background); --modern-ui-connected-tab-radius: var(--vscode-cornerRadius-small); --modern-ui-connected-tab-cap-radius: calc(var(--modern-ui-connected-tab-radius) + var(--vscode-strokeThickness)); @@ -399,10 +399,10 @@ .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-actions { --modern-ui-connected-tab-action-background: transparent; - --modern-ui-editor-tab-action-active-background: transparent; - --modern-ui-editor-tab-action-unfocused-active-background: transparent; - --modern-ui-editor-tab-action-active-hover-background: transparent; - --modern-ui-editor-tab-action-unfocused-active-hover-background: transparent; + --modern-ui-editor-tab-action-active-background: var(--modern-ui-editor-tab-custom-action-active-background, transparent); + --modern-ui-editor-tab-action-unfocused-active-background: var(--modern-ui-editor-tab-custom-action-active-background, transparent); + --modern-ui-editor-tab-action-active-hover-background: var(--modern-ui-editor-tab-custom-action-active-hover-background, transparent); + --modern-ui-editor-tab-action-unfocused-active-hover-background: var(--modern-ui-editor-tab-custom-action-active-hover-background, transparent); border-radius: 0 var(--modern-ui-connected-tab-radius) 0 0; } diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index d66146fa2bb191..999abc3138c32c 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -3608,6 +3608,79 @@ suite('ModernUIContribution', () => { }); }); + test('keeps the connected editor tab color audit exhaustive', () => { + const ids = colorRegistry.getColors().map(color => color.id); + const tabColors = ids.filter(id => id.startsWith('tab.')).sort(); + const modernTabColors = ids.filter(id => id.startsWith('modernTab.')).sort(); + const modernEditorTabColors = ids.filter(id => id.startsWith('modernEditorTab.')).sort(); + + assert.deepStrictEqual({ + tabColors, + modernTabColors, + modernEditorTabColors, + editorGroupHeaderColors: [ + 'editorGroupHeader.connectedTabsBackground', + 'editorGroupHeader.tabsBackground', + 'editorGroupHeader.tabsBorder', + ].filter(id => ids.includes(id)), + }, { + tabColors: [ + 'tab.activeBackground', + 'tab.activeBorder', + 'tab.activeBorderTop', + 'tab.activeForeground', + 'tab.activeModifiedBorder', + 'tab.border', + 'tab.divider', + 'tab.dragAndDropBorder', + 'tab.hoverBackground', + 'tab.hoverBorder', + 'tab.hoverForeground', + 'tab.inactiveBackground', + 'tab.inactiveForeground', + 'tab.inactiveModifiedBorder', + 'tab.lastPinnedBorder', + 'tab.selectedBackground', + 'tab.selectedBorderTop', + 'tab.selectedForeground', + 'tab.unfocusedActiveBackground', + 'tab.unfocusedActiveBorder', + 'tab.unfocusedActiveBorderTop', + 'tab.unfocusedActiveForeground', + 'tab.unfocusedActiveModifiedBorder', + 'tab.unfocusedHoverBackground', + 'tab.unfocusedHoverBorder', + 'tab.unfocusedHoverForeground', + 'tab.unfocusedInactiveBackground', + 'tab.unfocusedInactiveForeground', + 'tab.unfocusedInactiveModifiedBorder', + ], + modernTabColors: [ + 'modernTab.activeBackground', + 'modernTab.activeForeground', + 'modernTab.hoverBackground', + 'modernTab.hoverForeground', + ], + modernEditorTabColors: [ + 'modernEditorTab.activeActionBackground', + 'modernEditorTab.activeBackground', + 'modernEditorTab.activeForeground', + 'modernEditorTab.activeHoverActionBackground', + 'modernEditorTab.activeHoverBackground', + 'modernEditorTab.hoverActionBackground', + 'modernEditorTab.hoverBackground', + 'modernEditorTab.hoverForeground', + 'modernEditorTab.inactiveBackground', + 'modernEditorTab.selectedActionBackground', + ], + editorGroupHeaderColors: [ + 'editorGroupHeader.connectedTabsBackground', + 'editorGroupHeader.tabsBackground', + 'editorGroupHeader.tabsBorder', + ], + }); + }); + test('uses legacy color customizations for Modern UI editor tabs only', () => { const theme = ColorThemeData.createUnloadedTheme('vs-dark', { [editorBackground]: '#000000', @@ -3776,6 +3849,8 @@ suite('ModernUIContribution', () => { theme.setCustomColors({ [MODERN_TAB_ACTIVE_BACKGROUND]: '#ABCDEF', [MODERN_TAB_ACTIVE_FOREGROUND]: '#102030', + [MODERN_TAB_HOVER_BACKGROUND]: '#9A3412', + [MODERN_TAB_HOVER_FOREGROUND]: '#FFF7ED', [TAB_ACTIVE_BACKGROUND]: '#123456', [TAB_ACTIVE_FOREGROUND]: '#FEDCBA', }); @@ -3786,7 +3861,7 @@ suite('ModernUIContribution', () => { store.add(toDisposable(() => style.remove())); const root = document.createElement('div'); - root.className = 'modern-tab-customization-theme monaco-workbench modern-ui-tabs'; + root.className = 'modern-tab-customization-theme monaco-workbench modern-ui-tabs modern-ui-connected-editor-tabs'; document.body.appendChild(root); store.add(toDisposable(() => root.remove())); const paneAction = createCompositeAction(root, 35, true); @@ -3800,17 +3875,25 @@ suite('ModernUIContribution', () => { const label = appendElement(tab, 'tab-label'); const labelAnchor = document.createElement('a'); label.appendChild(labelAnchor); + const hoverBackgroundProbe = appendElement(root, 'hover-background-probe'); + hoverBackgroundProbe.style.backgroundColor = 'var(--modern-ui-editor-tab-custom-hover-background)'; + const hoverForegroundProbe = appendElement(root, 'hover-foreground-probe'); + hoverForegroundProbe.style.color = 'var(--modern-ui-editor-tab-hover-foreground)'; assert.deepStrictEqual({ paneBackground: getWindow(paneAction.indicator).getComputedStyle(paneAction.indicator).backgroundColor, paneForeground: getWindow(paneAction.actionLabel).getComputedStyle(paneAction.actionLabel).color, editorBackground: getWindow(fill).getComputedStyle(fill).backgroundColor, editorForeground: getWindow(labelAnchor).getComputedStyle(labelAnchor).color, + hoverBackground: getWindow(hoverBackgroundProbe).getComputedStyle(hoverBackgroundProbe).backgroundColor, + hoverForeground: getWindow(hoverForegroundProbe).getComputedStyle(hoverForegroundProbe).color, }, { paneBackground: 'rgb(171, 205, 239)', paneForeground: 'rgb(16, 32, 48)', editorBackground: 'rgb(171, 205, 239)', editorForeground: 'rgb(16, 32, 48)', + hoverBackground: 'rgb(154, 52, 18)', + hoverForeground: 'rgb(255, 247, 237)', }); }); @@ -3822,9 +3905,15 @@ suite('ModernUIContribution', () => { }); theme.setCustomColors({ [MODERN_EDITOR_TAB_ACTIVE_BACKGROUND]: '#2468AC', + [MODERN_EDITOR_TAB_ACTIVE_ACTION_BACKGROUND]: '#0E3747', [MODERN_EDITOR_TAB_ACTIVE_FOREGROUND]: '#13579B', [MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND]: '#48ACF0', + [MODERN_EDITOR_TAB_ACTIVE_HOVER_ACTION_BACKGROUND]: '#4C1678', [MODERN_EDITOR_TAB_INACTIVE_BACKGROUND]: '#369CF0', + [MODERN_EDITOR_TAB_HOVER_BACKGROUND]: '#7C2D12', + [MODERN_EDITOR_TAB_HOVER_ACTION_BACKGROUND]: '#5A1F0C', + [MODERN_EDITOR_TAB_HOVER_FOREGROUND]: '#FFEDD5', + [MODERN_EDITOR_TAB_SELECTED_ACTION_BACKGROUND]: '#166534', [TAB_ACTIVE_BACKGROUND]: '#123456', [TAB_ACTIVE_FOREGROUND]: '#FEDCBA', [TAB_HOVER_BACKGROUND]: '#456789', @@ -3857,6 +3946,16 @@ suite('ModernUIContribution', () => { activeHoverProbe.style.backgroundColor = 'var(--modern-ui-editor-tab-active-hover-background)'; const activeHoverActionProbe = appendElement(root, 'active-hover-action-probe'); activeHoverActionProbe.style.backgroundColor = 'var(--modern-ui-editor-tab-action-active-hover-background)'; + const activeActionProbe = appendElement(root, 'active-action-probe'); + activeActionProbe.style.backgroundColor = 'var(--modern-ui-editor-tab-custom-action-active-background)'; + const hoverBackgroundProbe = appendElement(root, 'hover-background-probe'); + hoverBackgroundProbe.style.backgroundColor = 'var(--modern-ui-editor-tab-custom-hover-background)'; + const hoverActionProbe = appendElement(root, 'hover-action-probe'); + hoverActionProbe.style.backgroundColor = 'var(--modern-ui-editor-tab-custom-action-hover-background)'; + const hoverForegroundProbe = appendElement(root, 'hover-foreground-probe'); + hoverForegroundProbe.style.color = 'var(--modern-ui-editor-tab-hover-foreground)'; + const selectedActionProbe = appendElement(root, 'selected-action-probe'); + selectedActionProbe.style.backgroundColor = 'var(--vscode-modernEditorTab-selectedActionBackground)'; assert.deepStrictEqual({ paneBackground: getWindow(paneAction.indicator).getComputedStyle(paneAction.indicator).backgroundColor, @@ -3864,16 +3963,26 @@ suite('ModernUIContribution', () => { editorBackground: getWindow(fill).getComputedStyle(fill).backgroundColor, editorForeground: getWindow(labelAnchor).getComputedStyle(labelAnchor).color, editorInactiveBackground: getWindow(inactiveFill).getComputedStyle(inactiveFill).backgroundColor, + editorActiveActionBackground: getWindow(activeActionProbe).getComputedStyle(activeActionProbe).backgroundColor, editorActiveHoverBackground: getWindow(activeHoverProbe).getComputedStyle(activeHoverProbe).backgroundColor, editorActiveHoverActionBackground: getWindow(activeHoverActionProbe).getComputedStyle(activeHoverActionProbe).backgroundColor, + editorHoverBackground: getWindow(hoverBackgroundProbe).getComputedStyle(hoverBackgroundProbe).backgroundColor, + editorHoverActionBackground: getWindow(hoverActionProbe).getComputedStyle(hoverActionProbe).backgroundColor, + editorHoverForeground: getWindow(hoverForegroundProbe).getComputedStyle(hoverForegroundProbe).color, + editorSelectedActionBackground: getWindow(selectedActionProbe).getComputedStyle(selectedActionProbe).backgroundColor, }, { paneBackground: 'rgb(171, 205, 239)', paneForeground: 'rgb(16, 32, 48)', editorBackground: 'rgb(36, 104, 172)', editorForeground: 'rgb(19, 87, 155)', editorInactiveBackground: 'rgb(54, 156, 240)', + editorActiveActionBackground: 'rgb(14, 55, 71)', editorActiveHoverBackground: 'rgb(72, 172, 240)', - editorActiveHoverActionBackground: 'rgb(72, 172, 240)', + editorActiveHoverActionBackground: 'rgb(76, 22, 120)', + editorHoverBackground: 'rgb(124, 45, 18)', + editorHoverActionBackground: 'rgb(90, 31, 12)', + editorHoverForeground: 'rgb(255, 237, 213)', + editorSelectedActionBackground: 'rgb(22, 101, 52)', }); }); diff --git a/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts b/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts index 0d1652dfe227c7..19d8a4f5ec2b8c 100644 --- a/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts +++ b/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts @@ -56,6 +56,9 @@ export function collectModernTabColorCustomizations(theme: ColorThemeData, colle const hasModernActiveActionBackground = !!theme.getColorCustomization(MODERN_EDITOR_TAB_ACTIVE_ACTION_BACKGROUND); const hasModernHoverActionBackground = !!theme.getColorCustomization(MODERN_EDITOR_TAB_HOVER_ACTION_BACKGROUND); const hasModernActiveHoverActionBackground = !!theme.getColorCustomization(MODERN_EDITOR_TAB_ACTIVE_HOVER_ACTION_BACKGROUND); + const explicitActiveActionBackground = hasModernActiveActionBackground ? theme.getColor(MODERN_EDITOR_TAB_ACTIVE_ACTION_BACKGROUND) : undefined; + const explicitHoverActionBackground = hasModernHoverActionBackground ? theme.getColor(MODERN_EDITOR_TAB_HOVER_ACTION_BACKGROUND) : undefined; + const explicitActiveHoverActionBackground = hasModernActiveHoverActionBackground ? theme.getColor(MODERN_EDITOR_TAB_ACTIVE_HOVER_ACTION_BACKGROUND) : undefined; const explicitActiveBackground = resolveExplicitTabColor(theme, MODERN_EDITOR_TAB_ACTIVE_BACKGROUND, [MODERN_EDITOR_TAB_ACTIVE_BACKGROUND, MODERN_TAB_ACTIVE_BACKGROUND], TAB_ACTIVE_BACKGROUND); const explicitUnfocusedActiveBackground = resolveExplicitTabColor(theme, MODERN_EDITOR_TAB_ACTIVE_BACKGROUND, [MODERN_EDITOR_TAB_ACTIVE_BACKGROUND, MODERN_TAB_ACTIVE_BACKGROUND], TAB_UNFOCUSED_ACTIVE_BACKGROUND, TAB_ACTIVE_BACKGROUND); const explicitInactiveBackground = resolveExplicitTabColor(theme, MODERN_EDITOR_TAB_INACTIVE_BACKGROUND, [MODERN_EDITOR_TAB_INACTIVE_BACKGROUND], TAB_INACTIVE_BACKGROUND); @@ -97,6 +100,9 @@ export function collectModernTabColorCustomizations(theme: ColorThemeData, colle addColorVariable(collector, '--modern-ui-editor-tab-custom-unfocused-hover-background', explicitUnfocusedHoverBackground); addColorVariable(collector, '--modern-ui-editor-tab-custom-active-hover-background', explicitActiveHoverBackground); addColorVariable(collector, '--modern-ui-editor-tab-custom-unfocused-active-hover-background', explicitUnfocusedActiveHoverBackground); + addColorVariable(collector, '--modern-ui-editor-tab-custom-action-active-background', explicitActiveActionBackground); + addColorVariable(collector, '--modern-ui-editor-tab-custom-action-hover-background', explicitHoverActionBackground); + addColorVariable(collector, '--modern-ui-editor-tab-custom-action-active-hover-background', explicitActiveHoverActionBackground); if (activeBackground && !hasModernActiveActionBackground) { addColorVariable(collector, '--modern-ui-editor-tab-action-active-background', flattenTabBackground(activeBackground, editorBackgroundColor)); diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts index 318a3afea02d29..59be142b8901e7 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/editorTabBar.fixture.ts @@ -874,6 +874,18 @@ function getLegacyEditorTabBorderCustomizations(): Readonly void { + return ctx => render(true, { + editorTabStyle: ModernUIEditorTabStyle.Connected, + editors: [ + { resource: file('/project/alpha.ts'), pinned: true, selected: true }, + { resource: file('/project/beta.ts'), pinned: true, active: true, selected: true }, + { resource: file('/project/gamma.ts'), pinned: true }, + ], + colorCustomizations: getModernEditorTabColorCustomizations(ctx.theme), + })(ctx); +} + function renderConnectedLegacyBorders(active: boolean): (ctx: ComponentFixtureContext) => void { return render(true, { active, @@ -1042,6 +1054,11 @@ export default defineThemedFixtureGroup({ path: 'editor/editorTabBar/' }, { themes: ['dark'], expectedVisualDescriptions: ['The active tab in the upper wrapped row retains rounded pill geometry. Its cyan top and inset pink bottom accents stop before the corners instead of drawing full-width straight indicators.'], }), + ConnectedModernEditorTokens: defineComponentFixture({ + render: renderConnectedModernEditorTabCustomizations(), + themes: ['dark'], + expectedVisualDescriptions: ['Connected tabs honor every explicitly customized modernEditorTab fill, label, and action color while retaining connected geometry.'], + }), }), ConnectedBorderContinuity: defineThemedFixtureGroup({ FirstActive: defineComponentFixture({ diff --git a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts index 61d8eb6652b2e3..4adaf05f1d783a 100644 --- a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts +++ b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts @@ -329,6 +329,66 @@ test('wrapped upper connected hover borders use focused and unfocused inset acce }); }); +test('connected tabs honor all modern editor tab customizations', async ({ page }) => { + await openFixture(page, 'editor/editorTabBar/editorTabBar/BorderOwnership/ConnectedModernEditorTokens/Dark', '.tabs-container > .tab.active'); + const tabs = page.locator('.tabs-container'); + const active = tabs.locator('> .tab.active'); + const inactive = tabs.locator('> .tab:not(.active):not(.selected)').first(); + const selected = tabs.locator('> .tab.selected:not(.active)').first(); + const readTab = (tab: typeof active) => tab.evaluate(element => { + const fill = element.querySelector('.tab-fill'); + const label = element.querySelector('.tab-label a'); + const actions = element.querySelector('.tab-actions'); + if (!fill || !label || !actions) { + throw new Error('Expected tab fill, label, and actions'); + } + return { + background: getComputedStyle(fill).backgroundColor, + foreground: getComputedStyle(label).color, + actionBackground: getComputedStyle(actions).backgroundColor, + }; + }); + + await page.locator('.editor-container').hover(); + await active.locator('.action-label').focus(); + expect(await readTab(active)).toEqual({ + background: 'rgb(22, 78, 99)', + foreground: 'rgb(207, 250, 254)', + actionBackground: 'rgb(14, 55, 71)', + }); + + await active.hover(); + expect(await readTab(active)).toEqual({ + background: 'rgb(107, 33, 168)', + foreground: 'rgb(207, 250, 254)', + actionBackground: 'rgb(76, 22, 120)', + }); + + await inactive.hover(); + expect(await readTab(inactive)).toEqual({ + background: 'rgb(124, 45, 18)', + foreground: 'rgb(255, 237, 213)', + actionBackground: 'rgb(90, 31, 12)', + }); + + await selected.hover(); + expect((await readTab(selected)).actionBackground).toBe('rgb(22, 101, 52)'); + + await page.locator('.editor-group-container').evaluate(group => group.classList.remove('active')); + await active.hover(); + expect(await readTab(active)).toEqual({ + background: 'rgb(107, 33, 168)', + foreground: 'rgb(207, 250, 254)', + actionBackground: 'rgb(76, 22, 120)', + }); + await inactive.hover(); + expect(await readTab(inactive)).toEqual({ + background: 'rgb(124, 45, 18)', + foreground: 'rgb(255, 237, 213)', + actionBackground: 'rgb(90, 31, 12)', + }); +}); + for (const [fixture, expected] of [ ['FirstActive', { capLeft: 'rgba(0, 0, 0, 0)', From ce075325a49f1a8b35fe16649e09a8bc7e6639ca Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Thu, 1 Oct 2026 12:29:40 -0700 Subject: [PATCH 19/23] editor: align merged color fixtures with tab geometry Preserve the shared HC frame edge and load the production editor-frame styles in the migrated tab fixture harness. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/modernUI/browser/media/connectedEditorTabs.css | 5 +++++ .../test/browser/componentFixtures/editor/tabs.fixture.ts | 1 + 2 files changed, 6 insertions(+) diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 366efee54a15fe..da6db3892388d4 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -672,6 +672,11 @@ border-left-color: transparent; } +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:is(.hc-black, .hc-light) .monaco-grid-view .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab:first-child:not(.active):not(.selected):not(:focus):not(:hover) > .tab-fill { + border-left-width: 0; + border-left-color: transparent; +} + .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row:not(.sticky-compact):not(.tab-actions-left) > .tab-actions { right: var(--modern-ui-connected-tab-action-inset); } diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts index 4111765d2a5d6d..b7da78707d940b 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts @@ -75,6 +75,7 @@ import { workbenchInstantiationService } from '../../workbenchTestServices.js'; import { ComponentFixtureAdditionalTheme, ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; import '../../../../contrib/modernUI/browser/media/tabs.css'; import '../../../../contrib/modernUI/browser/connectedEditorTabs.js'; +import '../../../../contrib/modernUI/browser/media/editorBorder.css'; // ============================================================================ // Fixture editor input From ac2fa2b601b10d9d3b7ddd9c53659977c4242928 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Thu, 1 Oct 2026 12:45:07 -0700 Subject: [PATCH 20/23] editor: remove stale tab fixture imports Drop editor-group-header color imports that became unused when the color fixtures moved into the curated tabs harness. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/browser/componentFixtures/editor/tabs.fixture.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts index b7da78707d940b..62ea10b4fdc4ea 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts @@ -36,10 +36,8 @@ import { EditorInputCapabilities, EditorsOrder, IEditorPartOptions, IToolbarActi import { EditorGroupModel } from '../../../../common/editor/editorGroupModel.js'; import { EDITOR_BORDER, - EDITOR_GROUP_HEADER_CONNECTED_TABS_BACKGROUND, EDITOR_GROUP_HEADER_NO_TABS_BACKGROUND, EDITOR_GROUP_HEADER_TABS_BACKGROUND, - EDITOR_GROUP_HEADER_TABS_BORDER, MODERN_EDITOR_TAB_ACTIVE_ACTION_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_FOREGROUND, From f0624de695ae16c577279f837f0725253bd0eff6 Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Thu, 1 Oct 2026 13:09:06 -0700 Subject: [PATCH 21/23] editor: defer row structure to tab layout Remove the redundant first-in-row marker introduced by this branch and use the row-boundary selectors owned by the merged tab structure overhaul. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../parts/editor/multiEditorTabsControl.ts | 37 ++++++++----------- .../browser/media/connectedEditorTabs.css | 7 ++-- .../editor/multiEditorTabsControl.test.ts | 4 +- .../playwright/tests/editorTabBar.spec.ts | 4 +- 4 files changed, 22 insertions(+), 30 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index 6a83fcb0444b77..c1089df6b9dec7 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -2211,7 +2211,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { // Remove old css classes that are not needed anymore for (const tab of tabsContainer.children) { - tab.classList.remove('first-in-row', 'last-in-row'); + tab.classList.remove('last-in-row'); } } @@ -2281,13 +2281,16 @@ export class MultiEditorTabsControl extends EditorTabsControl { }); } - // Measure before changing row-boundary classes to avoid layout churn. + // Update the `last-in-row` class on tabs when wrapping + // is enabled (it doesn't do any harm otherwise). This + // class controls additional properties of tab when it is + // the last tab in a row if (tabsWrapMultiLine) { // Using a map here to change classes after the for loop is // crucial for performance because changing the class on a // tab can result in layouts of the rendering engine. - const tabs = new Map(); + const tabs = new Map(); let currentTabsPosY: number | undefined = undefined; let lastTab: HTMLElement | undefined = undefined; @@ -2299,38 +2302,28 @@ export class MultiEditorTabsControl extends EditorTabsControl { const tabPosY = tab.offsetTop; // Marks a new or the first row of tabs - const firstInRow = tabPosY !== currentTabsPosY; - if (firstInRow) { + if (tabPosY !== currentTabsPosY) { currentTabsPosY = tabPosY; if (lastTab) { - tabs.get(lastTab)!.lastInRow = true; + tabs.set(lastTab, true); // previous tab must be last in row then } } + // Always remember last tab and ensure the + // last-in-row class is not present until + // we know the tab is last lastTab = tab; - tabs.set(tab, { firstInRow, lastInRow: false }); + tabs.set(tab, false); } - // The final tab is always last-in-row. + // Last tab overally is always last-in-row if (lastTab) { - tabs.get(lastTab)!.lastInRow = true; + tabs.set(lastTab, true); } - for (const [tab, { firstInRow, lastInRow }] of tabs) { - tab.classList.toggle('first-in-row', firstInRow); + for (const [tab, lastInRow] of tabs) { tab.classList.toggle('last-in-row', lastInRow); } - } else { - let firstInRow = true; - for (const child of tabsContainer.children) { - if (child === this.addTabContainer) { - continue; - } - - child.classList.toggle('first-in-row', firstInRow); - child.classList.remove('last-in-row'); - firstInRow = false; - } } return tabsWrapMultiLine; diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index da6db3892388d4..87b8ad6593e126 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -357,7 +357,8 @@ right: 0; } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:is(.connected-tab-left-edge, .first-in-row) > .tab-connected-edge::before, +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-left-edge > .tab-connected-edge::before, +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.last-in-row + .tab.active > .tab-connected-edge::before, .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .modern-ui-editor-tab.active.connected-tab-left-edge > .tab-connected-edge::before { content: none; } @@ -464,7 +465,7 @@ right: calc(var(--modern-ui-connected-tab-action-inset) - var(--vscode-spacing-size20) + var(--vscode-strokeThickness)); } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container:not(.wrapping) .tabs-container > .tab:not(.connected-tab-upper-row):not(.first-in-row):not(.sticky-compact).tab-actions-left > .tab-actions { +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container:not(.wrapping) .tabs-container > .tab:not(.connected-tab-upper-row):not(:first-child):not(.sticky-compact).tab-actions-left > .tab-actions { left: calc(var(--modern-ui-connected-tab-action-inset) - var(--vscode-spacing-size20) + var(--vscode-strokeThickness)); } @@ -491,7 +492,7 @@ outline-offset: calc(-1 * var(--vscode-strokeThickness)); } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping .tabs-container > .tab.first-in-row:not(.connected-tab-upper-row):not(.sticky-compact).tab-actions-left > .tab-actions { +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping .tabs-container > .tab.last-in-row + .tab:not(.connected-tab-upper-row):not(.sticky-compact).tab-actions-left > .tab-actions { left: calc(var(--modern-ui-connected-tab-action-inset) - var(--vscode-spacing-size20) - var(--vscode-strokeThickness)); } diff --git a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts index 0240c0866d7402..c917df2c89abab 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -1146,16 +1146,14 @@ suite('MultiEditorTabsControl', () => { const expected = { top: tab.offsetTop === tabs[0].offsetTop, upper: tab.offsetTop !== tabs.at(-1)!.offsetTop, - first: index === 0 || (wrapping && tab.offsetTop !== tabs[index - 1].offsetTop), last: wrapping && (index === tabs.length - 1 || tab.offsetTop !== tabs[index + 1].offsetTop), }; const actual = { top: tab.classList.contains('connected-tab-top-row'), upper: tab.classList.contains('connected-tab-upper-row'), - first: tab.classList.contains('first-in-row'), last: tab.classList.contains('last-in-row'), }; - if (actual.top !== expected.top || actual.upper !== expected.upper || actual.first !== expected.first || actual.last !== expected.last) { + if (actual.top !== expected.top || actual.upper !== expected.upper || actual.last !== expected.last) { mismatches.push({ width, activeIndex, index, expected, actual }); } } diff --git a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts index 63c27942f6928c..77fa361caaee58 100644 --- a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts +++ b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts @@ -206,8 +206,8 @@ for (const [theme, expected] of [ await openFixture(page, `editor/tabs/Colors/BorderOwnership/Connected/${theme}`, '.tabs-container > .tab.active'); const ownership = await page.locator('.editor-group-container').evaluate(group => { const activeFill = group.querySelector('.tab.active > .tab-fill'); - const inactiveFill = group.querySelector('.tab:not(.active):not(.first-in-row) > .tab-fill'); - const firstFill = group.querySelector('.tab.first-in-row > .tab-fill'); + const inactiveFill = group.querySelector('.tab:not(.active):not(:first-child) > .tab-fill'); + const firstFill = group.querySelector('.tab:first-child > .tab-fill'); if (!activeFill || !inactiveFill || !firstFill) { throw new Error('Expected active and inactive connected tab fills'); } From 9f31f83a97ef79937fc156dc02bd3023ac5c0e4e Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Thu, 1 Oct 2026 13:24:32 -0700 Subject: [PATCH 22/23] editor: isolate tab color fixture styling Scope the editor frame to continuity fixtures, suppress uncustomized Connected active-top accents, and require exact cap/frame/body alignment without changing the tab layout geometry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../lib/stylelint/vscode-known-variables.json | 4 ++- .../browser/media/connectedEditorTabs.css | 12 ++++++- .../browser/modernUI.contribution.test.ts | 2 ++ .../browser/modernTabColorCustomizations.ts | 8 +++-- .../componentFixtures/editor/tabs.fixture.ts | 19 +++++++---- .../playwright/tests/editorTabBar.spec.ts | 33 +++++++++++++++++-- 6 files changed, 64 insertions(+), 14 deletions(-) diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 4b64b6239198fe..d078b793f18f1d 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -1124,15 +1124,17 @@ "--modern-ui-editor-tab-border", "--modern-ui-editor-tab-current-border-bottom", "--modern-ui-editor-tab-current-border-top", - "--modern-ui-editor-tab-custom-active-background", "--modern-ui-editor-tab-custom-action-active-background", "--modern-ui-editor-tab-custom-action-active-hover-background", "--modern-ui-editor-tab-custom-action-hover-background", + "--modern-ui-editor-tab-custom-active-background", + "--modern-ui-editor-tab-custom-active-border-top", "--modern-ui-editor-tab-custom-active-hover-background", "--modern-ui-editor-tab-custom-border", "--modern-ui-editor-tab-custom-hover-background", "--modern-ui-editor-tab-custom-inactive-background", "--modern-ui-editor-tab-custom-unfocused-active-background", + "--modern-ui-editor-tab-custom-unfocused-active-border-top", "--modern-ui-editor-tab-custom-unfocused-active-hover-background", "--modern-ui-editor-tab-custom-unfocused-hover-background", "--modern-ui-editor-tab-custom-unfocused-inactive-background", diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 87b8ad6593e126..76caf2ece7fded 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -244,11 +244,21 @@ box-shadow: none; } +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench.floating-panels .monaco-grid-view .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.connected-tab-upper-row) > .tab-fill, +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench.floating-panels .monaco-grid-view .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.connected-tab-upper-row) > .tab-connected-edge { + bottom: calc(-3 * var(--vscode-strokeThickness)); + transform: translateY(calc(-1 * var(--vscode-strokeThickness))); +} + /* The connected cap owns its rounded top stroke. Painting the standard straight * indicator over it squares the corners and creates a notch where the cap meets * adjacent surfaces. */ .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.tab-border-top > .tab-fill { - border-top-color: var(--tab-border-top-color); + border-top-color: var(--modern-ui-editor-tab-custom-active-border-top, var(--modern-ui-connected-tab-border)); +} + +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container:not(.active) > .title.tabs .tabs-container > .tab.active.tab-border-top > .tab-fill { + border-top-color: var(--modern-ui-editor-tab-custom-unfocused-active-border-top, var(--modern-ui-connected-tab-border)); } .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.multi-selected.tab-border-top > .tab-fill { diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index 001c4d7ef123aa..4e6f0ca4850259 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -2336,6 +2336,8 @@ suite('ModernUIContribution', () => { test('uses legacy border customizations for connected tabs', () => { const root = appendElement(document.body, 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'); store.add(toDisposable(() => root.remove())); + root.style.setProperty('--modern-ui-editor-tab-custom-active-border-top', '#123456'); + root.style.setProperty('--modern-ui-editor-tab-custom-unfocused-active-border-top', '#234567'); const content = appendElement(appendElement(root, 'part editor'), 'content'); const createGroup = (active: boolean) => { const group = appendElement(content, `editor-group-container${active ? ' active' : ''}`); diff --git a/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts b/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts index 19d8a4f5ec2b8c..5987eaab20d670 100644 --- a/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts +++ b/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts @@ -68,6 +68,8 @@ export function collectModernTabColorCustomizations(theme: ColorThemeData, colle const explicitActiveHoverBackground = resolveExplicitTabColor(theme, MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, [MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_BACKGROUND, MODERN_TAB_HOVER_BACKGROUND], TAB_HOVER_BACKGROUND); const explicitUnfocusedActiveHoverBackground = resolveExplicitTabColor(theme, MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, [MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_BACKGROUND, MODERN_TAB_HOVER_BACKGROUND], TAB_UNFOCUSED_HOVER_BACKGROUND, TAB_HOVER_BACKGROUND); const tabBorder = resolveLegacyTabColor(theme, TAB_BORDER, []); + const activeBorderTop = resolveLegacyTabColor(theme, TAB_ACTIVE_BORDER_TOP, []); + const unfocusedActiveBorderTop = resolveLegacyTabColor(theme, TAB_UNFOCUSED_ACTIVE_BORDER_TOP, [], TAB_ACTIVE_BORDER_TOP); addColorVariable(collector, '--modern-ui-editor-tab-active-background', activeBackground); addColorVariable(collector, '--modern-ui-editor-tab-unfocused-active-background', unfocusedActiveBackground); @@ -88,8 +90,10 @@ export function collectModernTabColorCustomizations(theme: ColorThemeData, colle addColorVariable(collector, '--modern-ui-editor-tab-last-pinned-border', resolveLegacyTabColor(theme, TAB_LAST_PINNED_BORDER, [])); addColorVariable(collector, '--modern-ui-editor-tab-active-border', resolveLegacyTabColor(theme, TAB_ACTIVE_BORDER, [])); addColorVariable(collector, '--modern-ui-editor-tab-unfocused-active-border', resolveLegacyTabColor(theme, TAB_UNFOCUSED_ACTIVE_BORDER, [], TAB_ACTIVE_BORDER)); - addColorVariable(collector, '--modern-ui-editor-tab-active-border-top', resolveLegacyTabColor(theme, TAB_ACTIVE_BORDER_TOP, [])); - addColorVariable(collector, '--modern-ui-editor-tab-unfocused-active-border-top', resolveLegacyTabColor(theme, TAB_UNFOCUSED_ACTIVE_BORDER_TOP, [], TAB_ACTIVE_BORDER_TOP)); + addColorVariable(collector, '--modern-ui-editor-tab-active-border-top', activeBorderTop); + addColorVariable(collector, '--modern-ui-editor-tab-unfocused-active-border-top', unfocusedActiveBorderTop); + addColorVariable(collector, '--modern-ui-editor-tab-custom-active-border-top', activeBorderTop); + addColorVariable(collector, '--modern-ui-editor-tab-custom-unfocused-active-border-top', unfocusedActiveBorderTop); addColorVariable(collector, '--modern-ui-editor-tab-hover-border', resolveLegacyTabColor(theme, TAB_HOVER_BORDER, [])); addColorVariable(collector, '--modern-ui-editor-tab-unfocused-hover-border', resolveLegacyTabColor(theme, TAB_UNFOCUSED_HOVER_BORDER, [], TAB_HOVER_BORDER)); addColorVariable(collector, '--modern-ui-editor-tab-custom-active-background', explicitActiveBackground); diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts index 62ea10b4fdc4ea..1d0d64721bc6d7 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts @@ -73,7 +73,6 @@ import { workbenchInstantiationService } from '../../workbenchTestServices.js'; import { ComponentFixtureAdditionalTheme, ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; import '../../../../contrib/modernUI/browser/media/tabs.css'; import '../../../../contrib/modernUI/browser/connectedEditorTabs.js'; -import '../../../../contrib/modernUI/browser/media/editorBorder.css'; // ============================================================================ // Fixture editor input @@ -496,6 +495,11 @@ export function renderEditorTabsFixture(ctx: ComponentFixtureContext, options: I groupContainer.appendChild(editorContainer); if (options.editorFrame) { container.classList.add('floating-panels'); + editorPart.style.border = 'var(--vscode-strokeThickness) solid var(--vscode-editor-border)'; + editorPart.style.borderRadius = 'var(--vscode-cornerRadius-large)'; + editorPart.style.boxSizing = 'border-box'; + editorPart.style.overflow = 'hidden'; + editorPart.style.backgroundColor = 'var(--vscode-editor-background)'; const grid = $('.monaco-grid-view'); grid.appendChild(editorPart); container.appendChild(grid); @@ -503,6 +507,10 @@ export function renderEditorTabsFixture(ctx: ComponentFixtureContext, options: I container.appendChild(editorPart); } + container.style.width = `${width}px`; + groupContainer.style.width = options.editorFrame ? '100%' : `${width}px`; + const layoutWidth = groupContainer.clientWidth; + if (options.editorContents !== undefined && model.activeEditor instanceof FixtureEditorInput) { editorContainer.style.height = '240px'; const editorServices = createEditorServices(disposableStore, { colorTheme: theme }); @@ -517,12 +525,9 @@ export function renderEditorTabsFixture(ctx: ComponentFixtureContext, options: I padding: { top: 16 }, }, { contributions: [] })); editor.setModel(textModel); - editor.layout(new Dimension(width, 240)); + editor.layout(new Dimension(layoutWidth, 240)); } - container.style.width = `${width}px`; - groupContainer.style.width = `${width}px`; - const titleControl = disposableStore.add(instantiationService.createInstance( EditorTitleControl, titleContainer, @@ -538,8 +543,8 @@ export function renderEditorTabsFixture(ctx: ComponentFixtureContext, options: I const layout = () => { titleControl.layout({ - container: new Dimension(width, titleControl.getHeight().total), - available: new Dimension(width, 200), + container: new Dimension(layoutWidth, titleControl.getHeight().total), + available: new Dimension(layoutWidth, 200), }, options.headerWidth); }; groupView.relayoutFn = layout; diff --git a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts index 77fa361caaee58..28af30420c895f 100644 --- a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts +++ b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts @@ -44,6 +44,27 @@ test('Dark Modern keeps legacy, connected, and pill tab surfaces distinct', asyn }); }); +test('Connected defaults do not surface the theme active-top accent', async ({ page }) => { + await openFixture(page, 'editor/tabs/TabStyles/Connected/Dark', '.tabs-container > .tab.active'); + const colors = await page.locator('.editor-group-container').evaluate(group => { + const activeFill = group.querySelector('.tab.active > .tab-fill'); + if (!activeFill) { + throw new Error('Expected an active connected tab fill'); + } + const groupStyle = getComputedStyle(group); + return { + capTop: getComputedStyle(activeFill).borderTopColor, + structuralBoundary: groupStyle.getPropertyValue('--modern-ui-connected-tab-border').trim(), + themeActiveTop: getComputedStyle(group.closest('.monaco-workbench')!).getPropertyValue('--vscode-tab-activeBorderTop').trim(), + }; + }); + expect(colors).toEqual({ + capTop: 'rgb(42, 43, 44)', + structuralBoundary: '#2a2b2c', + themeActiveTop: '#3994bc', + }); +}); + for (const [group, expected] of [ ['ActiveGroup', { activeTop: { indicator: 'none', color: 'rgb(34, 211, 238)' }, @@ -172,7 +193,7 @@ for (const [style, expected] of [ for (const [theme, expected] of [ ['DarkHighContrast', { activeTop: 'rgb(243, 133, 24)', accent: 'rgb(243, 133, 24)', tabBorder: 'rgb(111, 195, 223)' }], - ['LightHighContrast', { activeTop: 'rgb(181, 32, 13)', accent: 'rgb(0, 107, 189)', tabBorder: 'rgb(15, 74, 133)' }], + ['LightHighContrast', { activeTop: 'rgb(0, 107, 189)', accent: 'rgb(0, 107, 189)', tabBorder: 'rgb(15, 74, 133)' }], ] as const) { test(`pill borders retain high contrast ownership in ${theme}`, async ({ page }) => { await openFixture(page, `editor/tabs/Colors/BorderOwnership/Pill/${theme}`, '.tabs-container > .tab.active'); @@ -409,11 +430,13 @@ for (const [fixture, expected] of [ const fill = active?.querySelector('.tab-fill'); const indicator = active?.querySelector('.tab-border-top-container'); const strip = group?.querySelector('.tabs-and-actions-container'); - if (!group || !active || !fill || !indicator || !strip) { + const body = group?.querySelector('.editor-container'); + if (!group || !active || !fill || !indicator || !strip || !body) { throw new Error('Expected connected editor frame and active tab'); } const editorRect = editor.getBoundingClientRect(); const fillRect = fill.getBoundingClientRect(); + const bodyRect = body.getBoundingClientRect(); const fillStyle = getComputedStyle(fill); const visibleDividers = [...group.querySelectorAll('.tab-divider')] .filter(element => getComputedStyle(element).display !== 'none') @@ -426,7 +449,9 @@ for (const [fixture, expected] of [ capSide: getComputedStyle(fill).borderRightColor, separator: getComputedStyle(strip, '::after').backgroundColor, indicator: getComputedStyle(indicator).display, - topAligned: Math.abs(editorRect.top - fillRect.top) <= 1, + topAligned: editorRect.top === fillRect.top, + bodyOverlap: fillRect.bottom - bodyRect.top, + frameInsets: [bodyRect.left - editorRect.left, editorRect.right - bodyRect.right], visibleDividers, }; }); @@ -439,6 +464,8 @@ for (const [fixture, expected] of [ separator: 'rgb(34, 211, 238)', indicator: 'none', topAligned: true, + bodyOverlap: 1, + frameInsets: [1, 1], visibleDividers: expected.dividers, }); }); From c8696e7df80185c8ebc86b9d6c3638f1096013ba Mon Sep 17 00:00:00 2001 From: Cherry Wang Date: Thu, 1 Oct 2026 15:45:08 -0700 Subject: [PATCH 23/23] editor: preserve connected tab contours and color inheritance Restore continuous shoulder strokes with the updated tab geometry and reserve wrapped action space before row classification. Preserve selected-border and upper-row action-color fallbacks, and validate customizations with isolated production theme resolution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/workbench/contrib/modernUI/README.md | 6 +- .../browser/media/connectedEditorTabs.css | 58 ++++++--- .../browser/modernUI.contribution.test.ts | 80 +++++++++++- .../browser/modernTabColorCustomizations.ts | 11 +- .../componentFixtures/editor/tabs.fixture.ts | 121 ++++++++++++------ .../editor/multiEditorTabsControl.test.ts | 66 ++++++---- .../playwright/tests/editorTabBar.spec.ts | 97 +++++++++++++- 7 files changed, 335 insertions(+), 104 deletions(-) diff --git a/src/vs/workbench/contrib/modernUI/README.md b/src/vs/workbench/contrib/modernUI/README.md index f8dfded06e155a..c70938aefd2fd9 100644 --- a/src/vs/workbench/contrib/modernUI/README.md +++ b/src/vs/workbench/contrib/modernUI/README.md @@ -24,11 +24,11 @@ The connected root marker is defined with the editor control constants and share Connected tabs preserve at least the first basename character, an ellipsis, the extension, any decoration badge, and the action column when shrinking. File icons collapse first and return when the editor is widened; full names remain available in the hover and accessible label. Default `fit` sizing remains content-sized: the measured minimum is a compression safeguard, not a preferred tab width. Active close actions and dirty indicators remain visible, while clean inactive close actions appear on hover or keyboard focus. The final tab and the right viewport boundary keep an inset curved shoulder instead of a straight clipped edge. Explicit compact pinned tabs retain their icon-only sizing. -The bottom shoulders and freestanding caps share the same control radius plus the outside stroke (5px with the default tokens), including at clipped viewport edges. The outermost connected row shares the editor's top stroke without squaring its free corners. Only corners adjoining the left frame are straight; the editor's own clipping supplies its outer corner radius. Upper wrapped rows use the original control radius on their free corners and align to the same left edge as the bottom row. Wrapped fills occupy equal-height row hit boxes (28px normally, 24px compact), without horizontal gutters between their painted surfaces, with spacing only between rows and no extra gutter below the final row. Hover does not change their geometry. +The bottom shoulders and freestanding caps share the Inner radius plus the outside stroke (7px with the default tokens), including at clipped viewport edges. Each shoulder paints a complete curved stroke meeting the body separator; the edge mask removes only the cap's straight continuation behind that curve. Theme colors and explicit border customizations do not change these dimensions. The outermost connected row shares the editor's top stroke without squaring its free corners. Only corners adjoining the left frame are straight; the editor's own clipping supplies its outer corner radius. Upper wrapped rows use the Inner radius on their free corners and align to the same left edge as the bottom row. Wrapped rows retain equal-height hit boxes, with spacing only between rows and one shared separator slot below the final row. Hover does not change their geometry. Close-action clearance is derived from the existing tab height and row gutters, independently of the shoulder radius. The same clearance reserves label space and positions the 20px hover target. Painted borders are excluded from the available content area: a wrapped connected row centers its action below the cap's top stroke, while a single standard row uses the shared frame. Stroke clearance is reserved in inactive tabs too, so selecting a tab never moves its close target. Separate pinned-row pills add their horizontal fill inset to the reservation. Left and right actions use the same centered action surface. High-contrast hover and focus indicators belong to the close target, not a second border around its action container. -Wrapped tabs reserve the same action space before and after row classification; upper-row markers and row-end markers must not trigger another fit-tab reflow. Selection must not change a tab's width or margin. Shoulder space is reserved only after the final tab, independently of selection; intermediate shoulders paint over adjacent tab surfaces without inserting a gap. +Wrapped tabs reserve the upper pill's action space before row classification; assigning upper-row and row-end markers must not change fit-tab widths or trigger another reflow. Close targets share the same trailing clearance on upper and bottom rows, including at compact density. Selection must not change a tab's width or margin. Shoulder space is reserved only after the final tab, independently of selection; intermediate shoulders paint over adjacent tab surfaces without inserting a gap. Automatic reveal includes the complete shoulder and rounds fractional layout bounds outward so the selected action is not clipped. Manual scrolling can still move part of the selected tab, including its action, offscreen. In that case a stationary cap and shoulder finish the visible outline; their stroke is aligned with the document separator, and the clipping mask falls back to `editor.background` when the theme does not define a tab-strip background. @@ -36,6 +36,8 @@ By default, the connected design uses `editor.background` for the active tab so Agents chat tabs use the same connected shape, strip colors, and theme-aware stroke as side-panel tabs, with the selected tab joining the active or inactive session's background instead of `editor.background`. Overflowing chat tabs expose a thin, draggable horizontal scrollbar on hover without increasing the tab-row height. Tab selection, close actions, status indicators, inline renaming, drag and drop, and keyboard navigation retain their existing behavior. +An explicitly customized `tab.selectedBorderTop` remains the active tab's fallback when the theme has no active top-border color for that group's focus state. It does not override an existing active top-border color or enable the theme's uncustomized top accent. Explicit hover-action backgrounds apply equally to bottom connected tabs, upper wrapped pills, and separate pinned rows. + `editorGroupHeader.connectedTabsBackground` defaults to `editorGroupHeader.tabsBackground`, so themes can keep their established strip palette without customization. The bundled connected palettes are Dark 2026 `#202122`, Light 2026 `#EAEAEA`, Dark Modern `#2B2B2B`, Light Modern `#E5E5E5`, the classic dark themes `#303031`, the classic light themes `#E8E8E8`, and Quiet Light `#E4E4E4`. Their original global `editorGroupHeader.tabsBackground` and `tab.inactiveBackground` values remain unchanged. Shared connected theme derivations, including Agents-window tabs, use `editorGroupHeader.tabsBackground` outside Modern UI; only Modern UI editor tabs adopt the dedicated connected-strip palette. Connected tabs use `tab.inactiveForeground` rather than dimming the general foreground to 50% opacity. The default palettes pair these fills with readable inactive text (at least 4.5:1), including in inactive editor groups. Light Modern and the classic light themes use the existing neutral `#616161`; the classic dark themes use `#A6A6A6`. Explicit legacy foreground customizations still take precedence. HC styling and the original pill label defaults are unchanged. diff --git a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css index 76caf2ece7fded..c10a79648b9c4f 100644 --- a/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css +++ b/src/vs/workbench/contrib/modernUI/browser/media/connectedEditorTabs.css @@ -215,7 +215,8 @@ .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping .tabs-container { --modern-ui-connected-tab-action-inset: calc((var(--editor-group-tab-height) - var(--vscode-spacing-size200)) / 2 + var(--vscode-spacing-size20)); - --modern-ui-tab-action-padding: calc(var(--vscode-spacing-size200) + 2 * var(--modern-ui-connected-tab-action-inset)); + /* Reserve upper-pill clearance before row classification so assigning rows cannot rewrap tabs. */ + --modern-ui-tab-action-padding: calc(var(--vscode-spacing-size200) + 2 * var(--modern-ui-connected-tab-action-inset) + var(--vscode-spacing-size20)); row-gap: var(--vscode-spacing-size20); } @@ -247,31 +248,43 @@ .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench.floating-panels .monaco-grid-view .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.connected-tab-upper-row) > .tab-fill, .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench.floating-panels .monaco-grid-view .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.connected-tab-upper-row) > .tab-connected-edge { bottom: calc(-3 * var(--vscode-strokeThickness)); +} + +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench.floating-panels .monaco-grid-view .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.connected-tab-upper-row) > .tab-fill, +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench.floating-panels .monaco-grid-view .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.connected-tab-upper-row) > .tab-connected-edge, +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench.floating-panels .monaco-grid-view .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.connected-tab-upper-row) > .tab-border-top-container { transform: translateY(calc(-1 * var(--vscode-strokeThickness))); } -/* The connected cap owns its rounded top stroke. Painting the standard straight - * indicator over it squares the corners and creates a notch where the cap meets - * adjacent surfaces. */ -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.tab-border-top > .tab-fill { +/* Keep one structural color around the rounded cap. The explicit active-top + * accent is inset so it cannot split or square either corner. */ +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-upper-row.tab-border-top > .tab-fill { border-top-color: var(--modern-ui-editor-tab-custom-active-border-top, var(--modern-ui-connected-tab-border)); } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container:not(.active) > .title.tabs .tabs-container > .tab.active.tab-border-top > .tab-fill { +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container:not(.active) > .title.tabs .tabs-container > .tab.active.connected-tab-upper-row.tab-border-top > .tab-fill { border-top-color: var(--modern-ui-editor-tab-custom-unfocused-active-border-top, var(--modern-ui-connected-tab-border)); } .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.multi-selected.tab-border-top > .tab-fill { border-color: var(--modern-ui-connected-tab-border); - border-top-color: var(--tab-border-top-color); border-bottom-color: transparent; } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container:not(.active) > .title.tabs .tabs-container > .tab.active.multi-selected.tab-border-top > .tab-fill { - border-top-color: var(--vscode-tab-unfocusedActiveBorderTop, var(--tab-border-top-color)); +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:not(.connected-tab-upper-row).tab-border-top > .tab-border-top-container { + display: block; + top: 0; + left: var(--modern-ui-connected-tab-cap-radius); + width: calc(100% - 2 * var(--modern-ui-connected-tab-cap-radius)); + height: var(--vscode-strokeThickness); + background-color: var(--modern-ui-editor-tab-custom-active-border-top, transparent); +} + +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container:not(.active) > .title.tabs .tabs-container > .tab.active:not(.connected-tab-upper-row).tab-border-top > .tab-border-top-container { + background-color: var(--modern-ui-editor-tab-custom-unfocused-active-border-top, transparent); } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.tab-border-top > .tab-border-top-container { +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active.connected-tab-upper-row.tab-border-top > .tab-border-top-container { display: none; } @@ -288,7 +301,7 @@ border-radius: var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) 0 0; } -/* The foreground mask below removes the cap's side stroke where each shoulder curves out. */ +/* The foreground mask below removes the cap's straight side stroke behind each curved shoulder. */ .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::before, .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::after, .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .modern-ui-editor-tab.active > .modern-ui-editor-tab-fill::before, @@ -312,11 +325,6 @@ box-shadow: calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) 0 calc(var(--modern-ui-connected-tab-shoulder-radius) / 2) var(--modern-ui-connected-tab-current-background); } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::before, -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench:not(:is(.hc-black, .hc-light)) .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active > .tab-fill::after { - border-bottom-color: transparent; -} - .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.active:first-child > .tab-fill::before, .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.last-in-row + .tab.active > .tab-fill::before { content: none; @@ -471,11 +479,11 @@ } /* Reserve the connected stroke in every state so selecting a tab only changes its paint. */ -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container:not(.wrapping) .tabs-container > .tab:not(.connected-tab-upper-row):not(.sticky-compact):not(.tab-actions-left) > .tab-actions { +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container .tabs-container > .tab:not(.connected-tab-upper-row):not(.sticky-compact):not(.tab-actions-left) > .tab-actions { right: calc(var(--modern-ui-connected-tab-action-inset) - var(--vscode-spacing-size20) + var(--vscode-strokeThickness)); } -.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container:not(.wrapping) .tabs-container > .tab:not(.connected-tab-upper-row):not(:first-child):not(.sticky-compact).tab-actions-left > .tab-actions { +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container .tabs-container > .tab:not(.connected-tab-upper-row):not(:first-child):not(.sticky-compact).tab-actions-left > .tab-actions { left: calc(var(--modern-ui-connected-tab-action-inset) - var(--vscode-spacing-size20) + var(--vscode-strokeThickness)); } @@ -503,7 +511,7 @@ } .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container.wrapping .tabs-container > .tab.last-in-row + .tab:not(.connected-tab-upper-row):not(.sticky-compact).tab-actions-left > .tab-actions { - left: calc(var(--modern-ui-connected-tab-action-inset) - var(--vscode-spacing-size20) - var(--vscode-strokeThickness)); + left: calc(var(--modern-ui-connected-tab-action-inset) - var(--vscode-spacing-size20)); } /* Keep the complete identity and action column; only the basename can ellipsize. */ @@ -638,8 +646,11 @@ .modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs .tabs-container > .tab.connected-tab-upper-row { --modern-ui-editor-tab-hover-background: var(--modern-ui-editor-tab-custom-hover-background, var(--modern-ui-connected-tab-upper-hover-background)); --modern-ui-editor-tab-unfocused-hover-background: var(--modern-ui-editor-tab-custom-unfocused-hover-background, var(--modern-ui-editor-tab-hover-background)); - --modern-ui-editor-tab-action-hover-background: var(--modern-ui-editor-tab-hover-background); - --modern-ui-editor-tab-action-unfocused-hover-background: var(--modern-ui-editor-tab-unfocused-hover-background); + --modern-ui-editor-tab-action-hover-background: var(--modern-ui-editor-tab-custom-action-hover-background, var(--modern-ui-editor-tab-hover-background)); + --modern-ui-editor-tab-action-unfocused-hover-background: var(--modern-ui-editor-tab-custom-action-hover-background, var(--modern-ui-editor-tab-unfocused-hover-background)); +} + +.modern-ui-tabs.modern-ui-connected-editor-tabs.monaco-workbench .part.editor > .content .editor-group-container > .title.tabs > .tabs-and-actions-container:not(.wrapping) .tabs-container > .tab.connected-tab-upper-row { --modern-ui-tab-action-padding: calc(var(--vscode-spacing-size200) + 2 * var(--modern-ui-connected-tab-action-inset) + var(--vscode-spacing-size20)); } @@ -716,7 +727,12 @@ right: var(--vscode-cornerRadius-small); bottom: calc(-1 * var(--vscode-strokeThickness)); left: var(--vscode-cornerRadius-small); + width: auto; height: var(--vscode-strokeThickness); + border: 0; + border-radius: 0; + box-shadow: none; + clip-path: none; pointer-events: none; } diff --git a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts index 4e6f0ca4850259..8862fdabef1b92 100644 --- a/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts +++ b/src/vs/workbench/contrib/modernUI/test/browser/modernUI.contribution.test.ts @@ -2363,7 +2363,7 @@ suite('ModernUIContribution', () => { const activeBottom = targetWindow.getComputedStyle(group.activeBottomBorder); const selectedTop = targetWindow.getComputedStyle(group.selectedTopBorder); return { - activeTop: [activeTop.display, targetWindow.getComputedStyle(group.activeFill).borderTopColor], + activeTop: [activeTop.display, targetWindow.getComputedStyle(group.activeFill).borderTopColor, activeTop.backgroundColor], activeBottom: [activeBottom.display, activeBottom.backgroundColor], selectedTop: [selectedTop.display, selectedTop.backgroundColor], }; @@ -2374,18 +2374,78 @@ suite('ModernUIContribution', () => { unfocusedGroup: borderStyles(unfocusedGroup), }, { activeGroup: { - activeTop: ['none', 'rgb(18, 52, 86)'], + activeTop: ['block', 'rgb(0, 0, 0)', 'rgb(18, 52, 86)'], activeBottom: ['block', 'rgb(52, 86, 120)'], selectedTop: ['block', 'rgb(86, 120, 154)'], }, unfocusedGroup: { - activeTop: ['none', 'rgb(35, 69, 103)'], + activeTop: ['block', 'rgb(0, 0, 0)', 'rgb(35, 69, 103)'], activeBottom: ['block', 'rgb(69, 103, 137)'], selectedTop: ['block', 'rgb(86, 120, 154)'], }, }); }); + test('uses explicitly customized selected borders only when the active top border is absent', () => { + const root = appendElement(document.body, 'selected-border-fallback monaco-workbench modern-ui-tabs modern-ui-connected-editor-tabs'); + store.add(toDisposable(() => root.remove())); + const style = document.createElement('style'); + root.appendChild(style); + const content = appendElement(appendElement(root, 'part editor'), 'content'); + const indicators = [true, false].map(active => { + const group = appendElement(content, `editor-group-container${active ? ' active' : ''}`); + const title = appendElement(group, 'title tabs'); + const tabs = appendElement(appendElement(title, 'tabs-and-actions-container'), 'tabs-container'); + const tab = appendElement(tabs, 'tab active selected multi-selected tab-border-top'); + appendElement(tab, 'tab-fill'); + return appendElement(tab, 'tab-border-top-container'); + }); + const cases: { colors: Record; customizations: Record; expected: string[] }[] = [ + { colors: {}, customizations: {}, expected: ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)'] }, + { colors: {}, customizations: { 'tab.selectedBorderTop': '#a3e635' }, expected: ['rgb(163, 230, 53)', 'rgb(163, 230, 53)'] }, + { colors: { 'tab.activeBorderTop': '#3994bc' }, customizations: { 'tab.selectedBorderTop': '#a3e635' }, expected: ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)'] }, + { colors: {}, customizations: { 'tab.selectedBorderTop': '#a3e635', 'tab.activeBorderTop': '#22d3ee', 'tab.unfocusedActiveBorderTop': '#c084fc' }, expected: ['rgb(34, 211, 238)', 'rgb(192, 132, 252)'] }, + { colors: {}, customizations: { 'tab.selectedBorderTop': '#a3e635', 'tab.activeBorderTop': '#00000000' }, expected: ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)'] }, + ]; + const actual = cases.map(({ colors, customizations }) => { + const theme = ColorThemeData.createUnloadedTheme('vs-dark', colors); + theme.setCustomColors(customizations); + style.textContent = generateColorThemeCSS(theme, '.selected-border-fallback', themingRegistry.getThemingParticipants(), TestEnvironmentService).code; + return indicators.map(indicator => getWindow(indicator).getComputedStyle(indicator).backgroundColor); + }); + assert.deepStrictEqual(actual, cases.map(testCase => testCase.expected)); + }); + + test('preserves hover action customization on connected wrapped and separate pinned rows', () => { + const root = appendElement(document.body, 'upper-action-customization monaco-workbench modern-ui-tabs modern-ui-connected-editor-tabs'); + store.add(toDisposable(() => root.remove())); + const style = document.createElement('style'); + root.appendChild(style); + const content = appendElement(appendElement(root, 'part editor'), 'content'); + const probes = [true, false].flatMap(active => { + const group = appendElement(content, `editor-group-container${active ? ' active' : ''}`); + return ['single', 'wrapped', 'pinned'].map(layout => { + const title = appendElement(group, `title tabs${layout === 'pinned' ? ' two-tab-bars' : ''}`); + const row = appendElement(title, `tabs-and-actions-container${layout === 'wrapped' ? ' wrapping' : ''}`); + const tabs = appendElement(row, 'tabs-container'); + const tab = appendElement(tabs, `tab${layout === 'single' ? '' : ' connected-tab-upper-row'}`); + const probe = appendElement(tab, 'action-hover-probe'); + probe.style.backgroundColor = `var(--modern-ui-editor-tab-action-${active ? '' : 'unfocused-'}hover-background)`; + return probe; + }); + }); + const actual = [false, true].map(customizeAction => { + const theme = ColorThemeData.createUnloadedTheme('vs-dark'); + theme.setCustomColors({ + [MODERN_EDITOR_TAB_HOVER_BACKGROUND]: '#7c2d12', + ...(customizeAction ? { [MODERN_EDITOR_TAB_HOVER_ACTION_BACKGROUND]: '#5a1f0c' } : {}), + }); + style.textContent = generateColorThemeCSS(theme, '.upper-action-customization', themingRegistry.getThemingParticipants(), TestEnvironmentService).code; + return probes.map(probe => getWindow(probe).getComputedStyle(probe).backgroundColor); + }); + assert.deepStrictEqual(actual, [Array(6).fill('rgb(124, 45, 18)'), Array(6).fill('rgb(90, 31, 12)')]); + }); + test('uses separate connected strip and border colors without changing the editor group header', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'; @@ -2800,7 +2860,10 @@ suite('ModernUIContribution', () => { geometry: geometry(), firstBorderLeftWidth: targetWindow.getComputedStyle(firstFill).borderLeftWidth, cap: [targetWindow.getComputedStyle(fill).borderTopColor, targetWindow.getComputedStyle(fill).borderLeftColor], - shoulder: targetWindow.getComputedStyle(fill, '::after').borderLeftColor, + shoulders: [ + [targetWindow.getComputedStyle(fill, '::before').borderRightColor, targetWindow.getComputedStyle(fill, '::before').borderBottomColor], + [targetWindow.getComputedStyle(fill, '::after').borderLeftColor, targetWindow.getComputedStyle(fill, '::after').borderBottomColor], + ], separator: targetWindow.getComputedStyle(row, '::after').backgroundColor, frame: [frame.content, frame.borderLeftWidth, frame.borderBottomWidth, frame.borderTopWidth, highContrast ? frame.borderLeftColor : undefined, frame.borderRadius, frame.pointerEvents], headerBackground: targetWindow.getComputedStyle(header).backgroundColor, @@ -2808,7 +2871,7 @@ suite('ModernUIContribution', () => { geometry: baseline, firstBorderLeftWidth: '0px', cap: ['rgba(0, 0, 0, 0)', border], - shoulder: border, + shoulders: [[border, border], [border, border]], separator: border, frame: highContrast ? ['""', '1px', '1px', '1px', border, '8px', 'none'] : ['none', '0px', '0px', '0px', undefined, '0px', 'auto'], headerBackground: 'rgb(51, 51, 51)', @@ -3019,6 +3082,7 @@ suite('ModernUIContribution', () => { const root = document.createElement('div'); root.className = 'monaco-workbench modern-ui modern-ui-tabs modern-ui-connected-editor-tabs'; root.style.setProperty('--vscode-spacing-size20', '2px'); + root.style.setProperty('--vscode-spacing-size200', '20px'); root.style.setProperty('--vscode-cornerRadius-small', '4px'); root.style.setProperty('--vscode-cornerRadius-medium', '6px'); root.style.setProperty('--vscode-strokeThickness', '1px'); @@ -3029,14 +3093,14 @@ suite('ModernUIContribution', () => { const editor = appendElement(gridView, 'part editor'); const content = appendElement(editor, 'content'); const group = appendElement(content, 'editor-group-container active'); - const singleTitle = appendElement(group, 'title tabs'); + const singleTitle = appendElement(group, 'title tabs connected-tabs-labels'); const singleRow = appendElement(singleTitle, 'tabs-and-actions-container'); const singleTabs = appendElement(singleRow, 'tabs-container'); const inactiveSingleTab = appendElement(singleTabs, 'tab'); const inactiveSingleFill = appendElement(inactiveSingleTab, 'tab-fill'); const activeSingleTab = appendElement(singleTabs, 'tab active'); const activeSingleFill = appendElement(activeSingleTab, 'tab-fill'); - const title = appendElement(group, 'title tabs'); + const title = appendElement(group, 'title tabs connected-tabs-labels'); const row = appendElement(title, 'tabs-and-actions-container wrapping'); const tabs = appendElement(row, 'tabs-container'); const upperTab = appendElement(tabs, 'tab active connected-tab-upper-row connected-tab-top-row first-in-row'); @@ -3058,6 +3122,7 @@ suite('ModernUIContribution', () => { assert.deepStrictEqual({ rowPaddingTop: targetWindow.getComputedStyle(row).paddingTop, + actionReservationMatches: targetWindow.getComputedStyle(upperTab).paddingRight === targetWindow.getComputedStyle(activeBottomTab).paddingRight, separatorSlots: [targetWindow.getComputedStyle(singleTabs).paddingBottom, targetWindow.getComputedStyle(tabs).paddingBottom], separatorOffsets: [targetWindow.getComputedStyle(singleRow, '::after').bottom, targetWindow.getComputedStyle(row, '::after').bottom], topFrame: { @@ -3076,6 +3141,7 @@ suite('ModernUIContribution', () => { }, }, { rowPaddingTop: '2px', + actionReservationMatches: true, separatorSlots: ['1px', '1px'], separatorOffsets: ['0px', '0px'], topFrame: { borderColors: ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)'], borderTopLeftRadius: '6px', borderWidths: ['1px', '1px'] }, diff --git a/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts b/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts index 5987eaab20d670..b1530e9d13d7a5 100644 --- a/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts +++ b/src/vs/workbench/services/themes/browser/modernTabColorCustomizations.ts @@ -6,7 +6,7 @@ import { Color } from '../../../../base/common/color.js'; import { ColorIdentifier, editorBackground } from '../../../../platform/theme/common/colorRegistry.js'; import { registerThemingParticipant } from '../../../../platform/theme/common/themeService.js'; -import { MODERN_EDITOR_TAB_ACTIVE_ACTION_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_FOREGROUND, MODERN_EDITOR_TAB_ACTIVE_HOVER_ACTION_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_ACTION_BACKGROUND, MODERN_EDITOR_TAB_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_FOREGROUND, MODERN_EDITOR_TAB_INACTIVE_BACKGROUND, MODERN_TAB_ACTIVE_BACKGROUND, MODERN_TAB_ACTIVE_FOREGROUND, MODERN_TAB_HOVER_BACKGROUND, MODERN_TAB_HOVER_FOREGROUND, TAB_ACTIVE_BACKGROUND, TAB_ACTIVE_BORDER, TAB_ACTIVE_BORDER_TOP, TAB_ACTIVE_FOREGROUND, TAB_BORDER, TAB_HOVER_BACKGROUND, TAB_HOVER_BORDER, TAB_HOVER_FOREGROUND, TAB_INACTIVE_BACKGROUND, TAB_INACTIVE_FOREGROUND, TAB_LAST_PINNED_BORDER, TAB_UNFOCUSED_ACTIVE_BACKGROUND, TAB_UNFOCUSED_ACTIVE_BORDER, TAB_UNFOCUSED_ACTIVE_BORDER_TOP, TAB_UNFOCUSED_ACTIVE_FOREGROUND, TAB_UNFOCUSED_HOVER_BACKGROUND, TAB_UNFOCUSED_HOVER_BORDER, TAB_UNFOCUSED_HOVER_FOREGROUND, TAB_UNFOCUSED_INACTIVE_BACKGROUND, TAB_UNFOCUSED_INACTIVE_FOREGROUND } from '../../../common/theme.js'; +import { MODERN_EDITOR_TAB_ACTIVE_ACTION_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_FOREGROUND, MODERN_EDITOR_TAB_ACTIVE_HOVER_ACTION_BACKGROUND, MODERN_EDITOR_TAB_ACTIVE_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_ACTION_BACKGROUND, MODERN_EDITOR_TAB_HOVER_BACKGROUND, MODERN_EDITOR_TAB_HOVER_FOREGROUND, MODERN_EDITOR_TAB_INACTIVE_BACKGROUND, MODERN_TAB_ACTIVE_BACKGROUND, MODERN_TAB_ACTIVE_FOREGROUND, MODERN_TAB_HOVER_BACKGROUND, MODERN_TAB_HOVER_FOREGROUND, TAB_ACTIVE_BACKGROUND, TAB_ACTIVE_BORDER, TAB_ACTIVE_BORDER_TOP, TAB_ACTIVE_FOREGROUND, TAB_BORDER, TAB_HOVER_BACKGROUND, TAB_HOVER_BORDER, TAB_HOVER_FOREGROUND, TAB_INACTIVE_BACKGROUND, TAB_INACTIVE_FOREGROUND, TAB_LAST_PINNED_BORDER, TAB_SELECTED_BORDER_TOP, TAB_UNFOCUSED_ACTIVE_BACKGROUND, TAB_UNFOCUSED_ACTIVE_BORDER, TAB_UNFOCUSED_ACTIVE_BORDER_TOP, TAB_UNFOCUSED_ACTIVE_FOREGROUND, TAB_UNFOCUSED_HOVER_BACKGROUND, TAB_UNFOCUSED_HOVER_BORDER, TAB_UNFOCUSED_HOVER_FOREGROUND, TAB_UNFOCUSED_INACTIVE_BACKGROUND, TAB_UNFOCUSED_INACTIVE_FOREGROUND } from '../../../common/theme.js'; import { ColorThemeData } from '../common/colorThemeData.js'; /** @@ -43,7 +43,7 @@ function flattenTabBackground(color: Color, editorBackgroundColor: Color | undef return editorBackgroundColor ? color.makeOpaque(editorBackgroundColor) : color; } -export function collectModernTabColorCustomizations(theme: ColorThemeData, collector: (name: string, color: Color) => void): void { +function collectModernTabColorCustomizations(theme: ColorThemeData, collector: (name: string, color: Color) => void): void { const activeBackground = resolveLegacyTabColor(theme, TAB_ACTIVE_BACKGROUND, [MODERN_EDITOR_TAB_ACTIVE_BACKGROUND, MODERN_TAB_ACTIVE_BACKGROUND]); const unfocusedActiveBackground = resolveLegacyTabColor(theme, TAB_UNFOCUSED_ACTIVE_BACKGROUND, [MODERN_EDITOR_TAB_ACTIVE_BACKGROUND, MODERN_TAB_ACTIVE_BACKGROUND], TAB_ACTIVE_BACKGROUND); const inactiveBackground = resolveLegacyTabColor(theme, TAB_INACTIVE_BACKGROUND, [MODERN_EDITOR_TAB_INACTIVE_BACKGROUND]); @@ -70,6 +70,9 @@ export function collectModernTabColorCustomizations(theme: ColorThemeData, colle const tabBorder = resolveLegacyTabColor(theme, TAB_BORDER, []); const activeBorderTop = resolveLegacyTabColor(theme, TAB_ACTIVE_BORDER_TOP, []); const unfocusedActiveBorderTop = resolveLegacyTabColor(theme, TAB_UNFOCUSED_ACTIVE_BORDER_TOP, [], TAB_ACTIVE_BORDER_TOP); + const selectedBorderTop = resolveLegacyTabColor(theme, TAB_SELECTED_BORDER_TOP, []); + const explicitActiveBorderTop = theme.getColor(TAB_ACTIVE_BORDER_TOP) ? activeBorderTop : selectedBorderTop; + const explicitUnfocusedActiveBorderTop = theme.getColor(TAB_UNFOCUSED_ACTIVE_BORDER_TOP) ? unfocusedActiveBorderTop : selectedBorderTop; addColorVariable(collector, '--modern-ui-editor-tab-active-background', activeBackground); addColorVariable(collector, '--modern-ui-editor-tab-unfocused-active-background', unfocusedActiveBackground); @@ -92,8 +95,8 @@ export function collectModernTabColorCustomizations(theme: ColorThemeData, colle addColorVariable(collector, '--modern-ui-editor-tab-unfocused-active-border', resolveLegacyTabColor(theme, TAB_UNFOCUSED_ACTIVE_BORDER, [], TAB_ACTIVE_BORDER)); addColorVariable(collector, '--modern-ui-editor-tab-active-border-top', activeBorderTop); addColorVariable(collector, '--modern-ui-editor-tab-unfocused-active-border-top', unfocusedActiveBorderTop); - addColorVariable(collector, '--modern-ui-editor-tab-custom-active-border-top', activeBorderTop); - addColorVariable(collector, '--modern-ui-editor-tab-custom-unfocused-active-border-top', unfocusedActiveBorderTop); + addColorVariable(collector, '--modern-ui-editor-tab-custom-active-border-top', explicitActiveBorderTop); + addColorVariable(collector, '--modern-ui-editor-tab-custom-unfocused-active-border-top', explicitUnfocusedActiveBorderTop); addColorVariable(collector, '--modern-ui-editor-tab-hover-border', resolveLegacyTabColor(theme, TAB_HOVER_BORDER, [])); addColorVariable(collector, '--modern-ui-editor-tab-unfocused-hover-border', resolveLegacyTabColor(theme, TAB_UNFOCUSED_HOVER_BORDER, [], TAB_HOVER_BORDER)); addColorVariable(collector, '--modern-ui-editor-tab-custom-active-background', explicitActiveBackground); diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts index 1d0d64721bc6d7..475ad48d9e4ff0 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/tabs.fixture.ts @@ -6,9 +6,8 @@ import { $, Dimension, getWindow, scheduleAtNextAnimationFrame } from '../../../../../base/browser/dom.js'; import { Action } from '../../../../../base/common/actions.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { Color } from '../../../../../base/common/color.js'; import { Event } from '../../../../../base/common/event.js'; -import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; import { basename, dirname } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -23,8 +22,7 @@ import { IContextKeyService } from '../../../../../platform/contextkey/common/co import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js'; import { listErrorForeground, listWarningForeground } from '../../../../../platform/theme/common/colors/listColors.js'; import { isDark, isHighContrast } from '../../../../../platform/theme/common/theme.js'; -import { asCssVariableName } from '../../../../../platform/theme/common/colorUtils.js'; -import { IColorTheme, IThemeService } from '../../../../../platform/theme/common/themeService.js'; +import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { TestThemeService } from '../../../../../platform/theme/test/common/testThemeService.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { testWorkspace } from '../../../../../platform/workspace/test/common/testWorkspace.js'; @@ -52,6 +50,7 @@ import { TAB_ACTIVE_BORDER_TOP, TAB_BORDER, TAB_DIVIDER, + TAB_HOVER_BACKGROUND, TAB_HOVER_BORDER, TAB_SELECTED_BORDER_TOP, TAB_UNFOCUSED_ACTIVE_BORDER, @@ -63,7 +62,7 @@ import { BreadcrumbsService, IBreadcrumbsService } from '../../../../browser/par import { EditorTitleControl } from '../../../../browser/parts/editor/editorTitleControl.js'; import { IDecorationData, IDecorationsProvider, IDecorationsService } from '../../../../services/decorations/common/decorations.js'; import { DecorationsService } from '../../../../services/decorations/browser/decorationsService.js'; -import { collectModernTabColorCustomizations } from '../../../../services/themes/browser/modernTabColorCustomizations.js'; +import '../../../../services/themes/browser/modernTabColorCustomizations.js'; import { ColorThemeData } from '../../../../services/themes/common/colorThemeData.js'; import { INotebookDocumentService, NotebookDocumentWorkbenchService } from '../../../../services/notebook/common/notebookDocumentService.js'; import { IOutlineService } from '../../../../services/outline/browser/outline.js'; @@ -71,6 +70,7 @@ import { LayoutSettings, ModernUIEditorTabStyle } from '../../../../services/lay import { TestContextService } from '../../../common/workbenchTestServices.js'; import { workbenchInstantiationService } from '../../workbenchTestServices.js'; import { ComponentFixtureAdditionalTheme, ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; +import { getThemeStyleSheet } from '../fixtureUtilsCss.js'; import '../../../../contrib/modernUI/browser/media/tabs.css'; import '../../../../contrib/modernUI/browser/connectedEditorTabs.js'; @@ -305,26 +305,17 @@ export interface IEditorTabsFixtureOptions { readonly editorFrame?: boolean; } -function customizeTheme(theme: IColorTheme, customizations: Readonly> | undefined): IColorTheme { +let customizedThemeId = 0; + +function customizeTheme(theme: ColorThemeData, customizations: Readonly> | undefined): ColorThemeData { if (!customizations) { return theme; } - const colors = new Map(Object.entries(customizations).map(([colorId, value]) => [colorId, Color.fromHex(value)])); - return new Proxy(theme, { - get(target, property, receiver) { - if (property === 'getColor') { - return (colorId: string, useDefault?: boolean) => colors.get(colorId) ?? target.getColor(colorId, useDefault); - } - if (property === 'defines') { - return (colorId: string) => colors.has(colorId) || target.defines(colorId); - } - if (property === 'getColorCustomization') { - return (colorId: string) => colors.get(colorId); - } - return Reflect.get(target, property, receiver); - } - }); + const customizedTheme = Object.assign(ColorThemeData.createLoadedEmptyTheme(theme.id, theme.settingsId), theme); + customizedTheme.id = `${theme.id} tab-custom-colors-${customizedThemeId++}`; + customizedTheme.setCustomColors(customizations); + return customizedTheme; } function createPartOptions(overrides?: Partial): IEditorPartOptions { @@ -339,7 +330,7 @@ function populateModel(model: EditorGroupModel, specs: IEditorSpec[], disposable // Open sticky editors first so their indices stay at the front. const ordered = [...specs].sort((a, b) => (a.sticky === b.sticky) ? 0 : a.sticky ? -1 : 1); const inputBySpec = new Map(); - for (const spec of ordered) { + for (const [index, spec] of ordered.entries()) { const input = disposableStore.add(new FixtureEditorInput(spec.resource, { typeId: spec.typeId, dirty: spec.dirty, @@ -348,6 +339,7 @@ function populateModel(model: EditorGroupModel, specs: IEditorSpec[], disposable })); inputBySpec.set(spec, input); model.openEditor(input, { + index, pinned: spec.pinned ?? true, sticky: spec.sticky, active: spec.active, @@ -368,10 +360,6 @@ export function renderEditorTabsFixture(ctx: ComponentFixtureContext, options: I const isGroupActive = options.active ?? true; const partOptions = createPartOptions(options.partOptions); - for (const [colorId, color] of Object.entries(options.colorCustomizations ?? {})) { - container.style.setProperty(asCssVariableName(colorId), color); - } - const configurationService = new TestConfigurationService(); configurationService.setUserConfiguration('breadcrumbs', { enabled: Boolean(options.breadcrumbs), @@ -391,8 +379,14 @@ export function renderEditorTabsFixture(ctx: ComponentFixtureContext, options: I const fixtureTheme = customizeTheme(theme, options.colorCustomizations); themeService.setTheme(fixtureTheme); themeService.setFileIconTheme(fileIconTheme); - if (options.colorCustomizations) { - collectModernTabColorCustomizations(fixtureTheme as ColorThemeData, (name, color) => container.style.setProperty(name, color.toString())); + if (fixtureTheme !== theme) { + const styleSheet = getThemeStyleSheet(fixtureTheme); + const targetDocument = container.ownerDocument; + container.classList.add(...fixtureTheme.classNames); + targetDocument.adoptedStyleSheets = [...targetDocument.adoptedStyleSheets, styleSheet]; + disposableStore.add(toDisposable(() => { + targetDocument.adoptedStyleSheets = targetDocument.adoptedStyleSheets.filter(sheet => sheet !== styleSheet); + })); } // Services the base workbench harness does not stub but the tab bar needs. @@ -616,7 +610,7 @@ function getModernEditorTabColorCustomizations(theme: ComponentFixtureContext['t }; } -function renderBorderOwnership(modernUI: boolean, editorTabStyle?: ModernUIEditorTabStyle): (ctx: ComponentFixtureContext) => void { +function renderBorderOwnership(modernUI: boolean, editorTabStyle?: ModernUIEditorTabStyle, customizeColors = true): (ctx: ComponentFixtureContext) => void { return ctx => renderEditorTabsFixture(ctx, { modernUI, editorTabStyle, @@ -631,7 +625,7 @@ function renderBorderOwnership(modernUI: boolean, editorTabStyle?: ModernUIEdito { resource: file('/project/eta.ts'), pinned: true }, { resource: file('/project/theta.ts'), pinned: true }, ], - colorCustomizations: isHighContrast(ctx.theme.type) ? undefined : getLegacyEditorTabBorderCustomizations(), + colorCustomizations: customizeColors && !isHighContrast(ctx.theme.type) ? getLegacyEditorTabBorderCustomizations() : undefined, }); } @@ -669,12 +663,14 @@ function renderWrappedConnectedBorderOwnership(): (ctx: ComponentFixtureContext) }); } -function renderConnectedModernEditorTabCustomizations(): (ctx: ComponentFixtureContext) => void { +function renderConnectedModernEditorTabCustomizations(layout: 'single' | 'wrapped' | 'pinned' = 'single'): (ctx: ComponentFixtureContext) => void { return ctx => renderEditorTabsFixture(ctx, { modernUI: true, editorTabStyle: ModernUIEditorTabStyle.Connected, + partOptions: layout === 'wrapped' ? { wrapTabs: true } : layout === 'pinned' ? { pinnedTabsOnSeparateRow: true, pinnedTabSizing: 'normal' } : undefined, + width: layout === 'wrapped' ? 260 : undefined, editors: [ - { resource: file('/project/alpha.ts'), pinned: true, selected: true }, + { resource: file('/project/alpha.ts'), pinned: true, selected: layout === 'single', sticky: layout === 'pinned' }, { resource: file('/project/beta.ts'), pinned: true, active: true, selected: true }, { resource: file('/project/gamma.ts'), pinned: true }, ], @@ -682,14 +678,15 @@ function renderConnectedModernEditorTabCustomizations(): (ctx: ComponentFixtureC }); } -function renderDensityLayout(layout: 'singleRow' | 'wrapped' | 'pinnedSeparateRow', tabHeight: IEditorPartOptions['tabHeight']): (ctx: ComponentFixtureContext) => Promise | void { - const wrapped = layout === 'wrapped'; +function renderDensityLayout(layout: 'singleRow' | 'wrapped' | 'wrappedBottomActive' | 'pinnedSeparateRow', tabHeight: IEditorPartOptions['tabHeight']): (ctx: ComponentFixtureContext) => Promise | void { + const wrapped = layout === 'wrapped' || layout === 'wrappedBottomActive'; + const bottomActive = layout === 'wrappedBottomActive'; const renderFixture = layout === 'pinnedSeparateRow' ? renderPinnedSeparateRow(tabHeight) : render(true, { - partOptions: { wrapTabs: wrapped, tabHeight }, - editors: wrapped ? manyEditorSpecs() : undefined, - width: wrapped ? 520 : undefined, + partOptions: { wrapTabs: wrapped, tabHeight, ...(bottomActive ? { editorActionsLocation: 'hidden' } : {}) }, + editors: bottomActive ? manyEditorSpecs(8).slice(0, 10) : wrapped ? manyEditorSpecs() : undefined, + width: bottomActive ? 820 : wrapped ? 520 : undefined, }); if (!wrapped) { return renderFixture; @@ -709,6 +706,12 @@ function renderDensityLayout(layout: 'singleRow' | 'wrapped' | 'pinnedSeparateRo tabs: tabs.map(tab => [tab.offsetLeft, tab.offsetTop, tab.offsetWidth, tab.offsetHeight]), }); if (layout === previousLayout) { + if (tabs.some((tab, index) => tab.classList.contains('last-in-row') !== (index === tabs.length - 1 || tab.offsetTop !== tabs[index + 1].offsetTop))) { + throw new Error('Wrapped tab row markers do not match the rendered rows'); + } + if (bottomActive && tabs.some(tab => tab.classList.contains('active') && (tab.offsetTop !== Math.max(...tabs.map(tab => tab.offsetTop)) || tab.classList.contains('connected-tab-upper-row')))) { + throw new Error('The active tab must be connected to the bottom wrapped row'); + } return; } previousLayout = layout; @@ -750,6 +753,18 @@ function createDensityFixtures() { expectedVisualDescriptions: ['Compact-density tabs wrap into equal-height rows while labels and actions remain vertically centered.'], }), }), + WrappedBottomActive: defineThemedFixtureGroup({ + Default: defineComponentFixture({ + render: renderDensityLayout('wrappedBottomActive', 'default'), + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: ['The active bottom-row tab joins the body separator with a continuous curved stroke on both sides, without changing the height of any row.'], + }), + Compact: defineComponentFixture({ + render: renderDensityLayout('wrappedBottomActive', 'compact'), + additionalThemes: ['darkHighContrast', 'lightHighContrast'], + expectedVisualDescriptions: ['Compact bottom-row tabs preserve the same continuous shoulder stroke as default density; upper rows remain separate pills.'], + }), + }), }; } @@ -846,6 +861,11 @@ export default defineThemedFixtureGroup({ path: 'editor/' }, { additionalThemes: ['darkHighContrast', 'lightHighContrast'], expectedVisualDescriptions: ['Connected tabs give the active cap boundary ownership and show dividers only between inactive tabs in standard themes.'], }), + ConnectedDefault: defineComponentFixture({ + render: renderBorderOwnership(true, ModernUIEditorTabStyle.Connected, false), + themes: ['dark'], + expectedVisualDescriptions: ['Default Connected tabs use the same cap, shoulder, mask, and separator geometry as customized Connected tabs.'], + }), ConnectedWrapped: defineComponentFixture({ render: renderWrappedConnectedBorderOwnership(), themes: ['dark'], @@ -856,6 +876,33 @@ export default defineThemedFixtureGroup({ path: 'editor/' }, { themes: ['dark'], expectedVisualDescriptions: ['Connected tabs honor every explicitly customized modernEditorTab fill, label, and action color.'], }), + WrappedModernEditorTokens: defineComponentFixture({ + render: renderConnectedModernEditorTabCustomizations('wrapped'), + themes: ['dark'], + expectedVisualDescriptions: ['Upper wrapped tabs retain the explicitly customized hover action background independently of the tab hover background.'], + }), + PinnedModernEditorTokens: defineComponentFixture({ + render: renderConnectedModernEditorTabCustomizations('pinned'), + themes: ['dark'], + expectedVisualDescriptions: ['Separate pinned-row tabs retain the same hover action color customization as ordinary connected tabs.'], + }), + }), + SelectedBorderFallback: defineThemedFixtureGroup({ + ActiveGroup: defineComponentFixture({ + render: render(true, { editors: multiSelectEditorSpecs(), colorCustomizations: { [TAB_SELECTED_BORDER_TOP]: '#a3e635' } }), + additionalThemes: ['darkPlus'], + expectedVisualDescriptions: ['An explicitly customized selected top border appears on the active tab when the theme has no active top border, but does not override an existing active top border.'], + }), + InactiveGroup: defineComponentFixture({ + render: render(true, { active: false, editors: multiSelectEditorSpecs(), colorCustomizations: { [TAB_SELECTED_BORDER_TOP]: '#a3e635' } }), + additionalThemes: ['darkPlus'], + expectedVisualDescriptions: ['The unfocused active tab uses the explicit selected-border fallback only when its active top border is absent.'], + }), + }), + DerivedColors: defineComponentFixture({ + render: render(true, { active: false, colorCustomizations: { [TAB_BORDER]: '#22d3ee', [TAB_HOVER_BACKGROUND]: '#7c2d12' } }), + additionalThemes: ['darkPlus'], + expectedVisualDescriptions: ['Dividers inherit the customized tab border. Unfocused hover preserves an explicitly defined theme color or derives the translucent hover color when the theme leaves it unset.'], }), Continuity: defineThemedFixtureGroup({ FirstActive: defineComponentFixture({ diff --git a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts index c917df2c89abab..5902991f2ee6b0 100644 --- a/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/multiEditorTabsControl.test.ts @@ -516,7 +516,7 @@ suite('MultiEditorTabsControl', () => { actionPadding: measurements.every(measurement => new Set(measurement.padding).size === 1 && measurement.padding[0] === multiple.padding[0]), }, { single: { top: true, right: true, left: true, width: true }, - horizontal: { clearance: [[6, 6, 6, 6], [6, 6, 6, 6], [3, 3, 3, 4], [4, 4, 4, 4]] }, + horizontal: { clearance: [[6, 6, 6, 6], [6, 6, 6, 6], [3, 4, 3, 5], [4, 4, 4, 4]] }, leftAction: { top: true, right: true, left: true, width: true }, balancedActionSurface: true, balancedActionInsets: true, @@ -595,7 +595,7 @@ suite('MultiEditorTabsControl', () => { ]); }); - test('close hover targets have equal vertical and trailing clearance at both tab densities', async () => { + test('close hover targets stay vertically centered and share trailing clearance across wrapped rows', async () => { const group = connectedGroup(); group.style.setProperty('--vscode-editorGroupHeader-tabsBorder', '#333333'); const measurements = []; @@ -626,10 +626,11 @@ suite('MultiEditorTabsControl', () => { : fill.right - action.right - (fillStyle.borderRightColor === 'rgba(0, 0, 0, 0)' ? 0 : Number.parseFloat(fillStyle.borderRightWidth)), leftBorder: rowStart && !upperRow ? mainWindow.getComputedStyle(tab.querySelector('.tab-fill')!).borderLeftColor : undefined, }); - const clearance = (tabHeight === 'compact' ? 4 : 6) - (wrapTabs ? 2 : 0) - (wrapTabs && activeIndex === 1 ? 1 : 0); + const trailing = (tabHeight === 'compact' ? 4 : 6) - (wrapTabs ? 2 : 0); + const clearance = trailing - (wrapTabs && activeIndex === 1 ? 1 : 0); expected.push({ tabHeight, tabActionLocation, wrapTabs, activeIndex, - top: clearance, bottom: clearance, trailing: clearance, + top: clearance, bottom: clearance, trailing, leftBorder: rowStart && !upperRow ? 'rgba(0, 0, 0, 0)' : undefined, }); } @@ -1133,28 +1134,35 @@ suite('MultiEditorTabsControl', () => { control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); await layoutConnectedGroup(group, 300); const tabs = Array.from(container.querySelectorAll('.tabs-container > .tab')); - const boundary = tabs[0].offsetWidth + tabs[1].offsetWidth; - const widths = Array.from({ length: 21 }, (_, index) => boundary - 10 + index); const mismatches = []; - for (const width of [...widths, ...widths.reverse()]) { - for (const activeIndex of [0, 1, 3]) { - model.openEditor(model.getEditorByIndex(activeIndex)!, { active: true }); - control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); - await layoutConnectedGroup(group, width); - const wrapping = container.querySelector('.tabs-and-actions-container')!.classList.contains('wrapping'); - for (const [index, tab] of tabs.entries()) { - const expected = { - top: tab.offsetTop === tabs[0].offsetTop, - upper: tab.offsetTop !== tabs.at(-1)!.offsetTop, - last: wrapping && (index === tabs.length - 1 || tab.offsetTop !== tabs[index + 1].offsetTop), - }; - const actual = { - top: tab.classList.contains('connected-tab-top-row'), - upper: tab.classList.contains('connected-tab-upper-row'), - last: tab.classList.contains('last-in-row'), - }; - if (actual.top !== expected.top || actual.upper !== expected.upper || actual.last !== expected.last) { - mismatches.push({ width, activeIndex, index, expected, actual }); + for (const tabHeight of ['default', 'compact'] as const) { + const oldOptions = partOptions; + partOptions = { ...partOptions, tabHeight }; + control.updateOptions(oldOptions, partOptions); + container.classList.toggle('compact-height', tabHeight === 'compact'); + await layoutConnectedGroup(group, 300); + const boundary = tabs[0].offsetWidth + tabs[1].offsetWidth; + const widths = Array.from({ length: 21 }, (_, index) => boundary - 10 + index); + for (const width of [...widths, ...widths.reverse()]) { + for (const activeIndex of [0, 1, 3]) { + model.openEditor(model.getEditorByIndex(activeIndex)!, { active: true }); + control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); + await layoutConnectedGroup(group, width); + const wrapping = container.querySelector('.tabs-and-actions-container')!.classList.contains('wrapping'); + for (const [index, tab] of tabs.entries()) { + const expected = { + top: tab.offsetTop === tabs[0].offsetTop, + upper: tab.offsetTop !== tabs.at(-1)!.offsetTop, + last: wrapping && (index === tabs.length - 1 || tab.offsetTop !== tabs[index + 1].offsetTop), + }; + const actual = { + top: tab.classList.contains('connected-tab-top-row'), + upper: tab.classList.contains('connected-tab-upper-row'), + last: tab.classList.contains('last-in-row'), + }; + if (actual.top !== expected.top || actual.upper !== expected.upper || actual.last !== expected.last) { + mismatches.push({ tabHeight, width, activeIndex, index, expected, actual }); + } } } } @@ -1212,6 +1220,7 @@ suite('MultiEditorTabsControl', () => { test('wrapped fills have equal visible heights and the bottom tab reaches the document', async () => { const group = connectedGroup(); group.style.setProperty('--modern-ui-connected-tab-surface', '#ffffff'); + group.style.setProperty('--modern-ui-editor-tab-custom-border', '#22d3ee'); model.openEditor(model.getEditorByIndex(1)!, { active: true }); control.openEditors(model.getEditors(EditorsOrder.SEQUENTIAL)); const measurements = []; @@ -1225,6 +1234,7 @@ suite('MultiEditorTabsControl', () => { const tab = strip.querySelector('.tab.active')!; const fill = tab.querySelector('.tab-fill')!; const fillStyle = mainWindow.getComputedStyle(fill); + const shoulderStyle = mainWindow.getComputedStyle(fill, '::after'); const stripStyle = mainWindow.getComputedStyle(strip, '::after'); const stripBounds = strip.getBoundingClientRect(); const tabBounds = tab.getBoundingClientRect(); @@ -1241,6 +1251,8 @@ suite('MultiEditorTabsControl', () => { clippingGap: strip.getBoundingClientRect().bottom - clippingBottom, bottomRadius: fillStyle.borderBottomRightRadius, shoulder: mainWindow.getComputedStyle(fill, '::after').content, + shoulderStroke: [shoulderStyle.borderLeftColor, shoulderStyle.borderBottomColor], + shoulderBaselineOffset: fill.getBoundingClientRect().bottom - parseFloat(fillStyle.borderBottomWidth) - parseFloat(shoulderStyle.bottom) - (separatorTop + parseFloat(stripStyle.height)), tabFillHeights: fills.map((rect, index) => Math.min(rect.bottom, tabs[index].bottom) - rect.top), rowGap: fills[1].top - fills[0].bottom, overflow: Array.from(strip.querySelectorAll('.tabs-container, .monaco-scrollable-element'), element => mainWindow.getComputedStyle(element).overflow), @@ -1249,8 +1261,8 @@ suite('MultiEditorTabsControl', () => { }); } assert.deepStrictEqual(measurements, [ - { tabHeight: 'default', stripHeight: 61, wrapping: true, upperRow: false, gap: -1, clippingGap: 0, bottomRadius: '0px', shoulder: '""', tabFillHeights: [28, 28], rowGap: 2, overflow: ['visible', 'visible'], separatorOffset: 0, connectionOverlap: 1 }, - { tabHeight: 'compact', stripHeight: 53, wrapping: true, upperRow: false, gap: -1, clippingGap: 0, bottomRadius: '0px', shoulder: '""', tabFillHeights: [24, 24], rowGap: 2, overflow: ['visible', 'visible'], separatorOffset: 0, connectionOverlap: 1 }, + { tabHeight: 'default', stripHeight: 61, wrapping: true, upperRow: false, gap: -1, clippingGap: 0, bottomRadius: '0px', shoulder: '""', shoulderStroke: ['rgb(34, 211, 238)', 'rgb(34, 211, 238)'], shoulderBaselineOffset: 0, tabFillHeights: [28, 28], rowGap: 2, overflow: ['visible', 'visible'], separatorOffset: 0, connectionOverlap: 1 }, + { tabHeight: 'compact', stripHeight: 53, wrapping: true, upperRow: false, gap: -1, clippingGap: 0, bottomRadius: '0px', shoulder: '""', shoulderStroke: ['rgb(34, 211, 238)', 'rgb(34, 211, 238)'], shoulderBaselineOffset: 0, tabFillHeights: [24, 24], rowGap: 2, overflow: ['visible', 'visible'], separatorOffset: 0, connectionOverlap: 1 }, ]); }); diff --git a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts index 28af30420c895f..9bee3ff63bfe6b 100644 --- a/test/componentFixtures/playwright/tests/editorTabBar.spec.ts +++ b/test/componentFixtures/playwright/tests/editorTabBar.spec.ts @@ -52,29 +52,42 @@ test('Connected defaults do not surface the theme active-top accent', async ({ p throw new Error('Expected an active connected tab fill'); } const groupStyle = getComputedStyle(group); + const leftShoulder = getComputedStyle(activeFill, '::before'); return { capTop: getComputedStyle(activeFill).borderTopColor, structuralBoundary: groupStyle.getPropertyValue('--modern-ui-connected-tab-border').trim(), themeActiveTop: getComputedStyle(group.closest('.monaco-workbench')!).getPropertyValue('--vscode-tab-activeBorderTop').trim(), + shoulder: { + bottom: leftShoulder.bottom, + height: leftShoulder.height, + radius: leftShoulder.borderBottomRightRadius, + color: leftShoulder.borderBottomColor, + }, }; }); expect(colors).toEqual({ capTop: 'rgb(42, 43, 44)', structuralBoundary: '#2a2b2c', themeActiveTop: '#3994bc', + shoulder: { + bottom: '0px', + height: '7px', + radius: '7px', + color: 'rgb(42, 43, 44)', + }, }); }); for (const [group, expected] of [ ['ActiveGroup', { - activeTop: { indicator: 'none', color: 'rgb(34, 211, 238)' }, + activeTop: { indicator: 'block', capColor: 'rgb(250, 204, 21)', accentColor: 'rgb(34, 211, 238)' }, activeBottom: { display: 'block', color: 'rgb(244, 63, 94)' }, activeSide: 'rgb(250, 204, 21)', selectedTop: { display: 'block', color: 'rgb(163, 230, 53)', height: 2, leftInset: 2, rightInset: 2 }, selectedBorder: 'rgba(0, 0, 0, 0)', }], ['InactiveGroup', { - activeTop: { indicator: 'none', color: 'rgb(192, 132, 252)' }, + activeTop: { indicator: 'block', capColor: 'rgb(250, 204, 21)', accentColor: 'rgb(192, 132, 252)' }, activeBottom: { display: 'block', color: 'rgb(251, 146, 60)' }, activeSide: 'rgb(250, 204, 21)', selectedTop: { display: 'block', color: 'rgb(163, 230, 53)', height: 2, leftInset: 2, rightInset: 2 }, @@ -102,7 +115,8 @@ for (const [group, expected] of [ return { activeTop: { indicator: getComputedStyle(activeTop).display, - color: getComputedStyle(activeFill).borderTopColor, + capColor: getComputedStyle(activeFill).borderTopColor, + accentColor: getComputedStyle(activeTop).backgroundColor, }, activeBottom: style(activeBottom), activeSide: getComputedStyle(activeFill).borderRightColor, @@ -137,9 +151,9 @@ for (const [style, expected] of [ visibleDividers: Array(7).fill('rgb(255, 255, 255)'), }], ['Connected', { - topIndicator: { display: 'none' }, + topIndicator: { display: 'block', color: 'rgb(34, 211, 238)' }, bottomIndicator: { display: 'block', color: 'rgb(244, 63, 94)' }, - fillTop: 'rgb(34, 211, 238)', + fillTop: 'rgb(250, 204, 21)', fillBottom: 'rgba(0, 0, 0, 0)', fillSide: 'rgb(250, 204, 21)', inactiveBorder: 'rgba(0, 0, 0, 0)', @@ -191,6 +205,50 @@ for (const [style, expected] of [ }); } +test('default and customized connected tabs share identical geometry', async ({ page }) => { + const readGeometry = async (fixture: string) => { + await openFixture(page, `editor/tabs/Colors/BorderOwnership/${fixture}/Dark`, '.tabs-container > .tab.active'); + return page.locator('.tabs-container > .tab.active').evaluate(active => { + const fill = active.querySelector('.tab-fill'); + const edge = active.querySelector('.tab-connected-edge'); + const strip = active.closest('.tabs-and-actions-container'); + if (!fill || !edge || !strip) { + throw new Error('Expected connected cap geometry'); + } + const activeRect = active.getBoundingClientRect(); + const fillRect = fill.getBoundingClientRect(); + const edgeRect = edge.getBoundingClientRect(); + const fillStyle = getComputedStyle(fill); + const leftShoulder = getComputedStyle(fill, '::before'); + const rightShoulder = getComputedStyle(fill, '::after'); + const leftMask = getComputedStyle(edge, '::before'); + return { + fillInsets: [ + fillRect.left - activeRect.left, + fillRect.top - activeRect.top, + activeRect.right - fillRect.right, + fillRect.bottom - activeRect.bottom, + ], + edgeInsets: [ + edgeRect.left - activeRect.left, + edgeRect.top - activeRect.top, + activeRect.right - edgeRect.right, + edgeRect.bottom - activeRect.bottom, + ], + capRadius: [fillStyle.borderTopLeftRadius, fillStyle.borderTopRightRadius], + shoulders: [ + [leftShoulder.bottom, leftShoulder.width, leftShoulder.height, leftShoulder.borderBottomRightRadius], + [rightShoulder.bottom, rightShoulder.width, rightShoulder.height, rightShoulder.borderBottomLeftRadius], + ], + mask: [leftMask.bottom, leftMask.width, leftMask.height], + separator: [getComputedStyle(strip, '::after').bottom, getComputedStyle(strip, '::after').height], + }; + }); + }; + + expect(await readGeometry('Connected')).toEqual(await readGeometry('ConnectedDefault')); +}); + for (const [theme, expected] of [ ['DarkHighContrast', { activeTop: 'rgb(243, 133, 24)', accent: 'rgb(243, 133, 24)', tabBorder: 'rgb(111, 195, 223)' }], ['LightHighContrast', { activeTop: 'rgb(0, 107, 189)', accent: 'rgb(0, 107, 189)', tabBorder: 'rgb(15, 74, 133)' }], @@ -280,6 +338,12 @@ test('wrapped upper connected tabs inset customized border accents', async ({ pa throw new Error('Expected wrapped active tab border elements'); } const bottomAccent = getComputedStyle(fill, '::after'); + const fillStyle = getComputedStyle(fill); + const availableWidth = fill.getBoundingClientRect().width + - Number.parseFloat(fillStyle.borderLeftWidth) + - Number.parseFloat(fillStyle.borderRightWidth) + - Number.parseFloat(bottomAccent.left) + - Number.parseFloat(bottomAccent.right); return { topIndicator: getComputedStyle(top).display, topColor: getComputedStyle(fill).borderTopColor, @@ -289,6 +353,7 @@ test('wrapped upper connected tabs inset customized border accents', async ({ pa left: bottomAccent.left, right: bottomAccent.right, height: bottomAccent.height, + spansAvailableWidth: Math.abs(Number.parseFloat(bottomAccent.width) - availableWidth) < 0.1, }, }; }); @@ -301,6 +366,7 @@ test('wrapped upper connected tabs inset customized border accents', async ({ pa left: '4px', right: '4px', height: '1px', + spansAvailableWidth: true, }, }); }); @@ -316,6 +382,12 @@ test('wrapped upper connected hover borders use focused and unfocused inset acce throw new Error('Expected hovered wrapped tab border elements'); } const accent = getComputedStyle(fill, '::after'); + const fillStyle = getComputedStyle(fill); + const availableWidth = fill.getBoundingClientRect().width + - Number.parseFloat(fillStyle.borderLeftWidth) + - Number.parseFloat(fillStyle.borderRightWidth) + - Number.parseFloat(accent.left) + - Number.parseFloat(accent.right); return { fillBottom: getComputedStyle(fill).borderBottomColor, indicator: getComputedStyle(bottom).display, @@ -324,6 +396,7 @@ test('wrapped upper connected hover borders use focused and unfocused inset acce left: accent.left, right: accent.right, height: accent.height, + spansAvailableWidth: Math.abs(Number.parseFloat(accent.width) - availableWidth) < 0.1, }, }; }); @@ -335,6 +408,7 @@ test('wrapped upper connected hover borders use focused and unfocused inset acce left: '4px', right: '4px', height: '1px', + spansAvailableWidth: true, }, }); await page.locator('.editor-group-container').evaluate(group => group.classList.remove('active')); @@ -346,6 +420,7 @@ test('wrapped upper connected hover borders use focused and unfocused inset acce left: '4px', right: '4px', height: '1px', + spansAvailableWidth: true, }, }); }); @@ -438,6 +513,8 @@ for (const [fixture, expected] of [ const fillRect = fill.getBoundingClientRect(); const bodyRect = body.getBoundingClientRect(); const fillStyle = getComputedStyle(fill); + const leftShoulder = getComputedStyle(fill, '::before'); + const rightShoulder = getComputedStyle(fill, '::after'); const visibleDividers = [...group.querySelectorAll('.tab-divider')] .filter(element => getComputedStyle(element).display !== 'none') .map(element => getComputedStyle(element).backgroundColor); @@ -452,6 +529,10 @@ for (const [fixture, expected] of [ topAligned: editorRect.top === fillRect.top, bodyOverlap: fillRect.bottom - bodyRect.top, frameInsets: [bodyRect.left - editorRect.left, editorRect.right - bodyRect.right], + shoulderTangents: { + left: [leftShoulder.bottom, leftShoulder.height, leftShoulder.borderBottomRightRadius, leftShoulder.borderBottomColor], + right: [rightShoulder.bottom, rightShoulder.height, rightShoulder.borderBottomLeftRadius, rightShoulder.borderBottomColor], + }, visibleDividers, }; }); @@ -462,10 +543,14 @@ for (const [fixture, expected] of [ capLeftWidth: expected.capLeftWidth, capSide: 'rgb(34, 211, 238)', separator: 'rgb(34, 211, 238)', - indicator: 'none', + indicator: 'block', topAligned: true, bodyOverlap: 1, frameInsets: [1, 1], + shoulderTangents: { + left: ['0px', '7px', '7px', 'rgb(34, 211, 238)'], + right: ['0px', '7px', '7px', 'rgb(34, 211, 238)'], + }, visibleDividers: expected.dividers, }); });