From 2aa489abdc04027ebd860058e402640e004cbf20 Mon Sep 17 00:00:00 2001 From: mathuo Date: Wed, 15 Jul 2026 21:51:14 +0000 Subject: [PATCH 1/4] fix(theme): correct spaced-theme tab height in wrap overflow mode Spaced themes give tabs a vertical margin (--dv-tab-margin), but core's multi-row wrap rule pins each wrapped tab to the full --dv-tabs-and-actions-container-height. flex-wrap counts the margin toward each row's height, so wrapped rows overshot the header height and the tab filled the full height instead of keeping the pill inset it has in single-row mode. Subtract the vertical tab margin from the wrapped tab height so each row equals exactly one header height and the pill proportions match the single-row layout. Splits --dv-tab-margin into block/inline parts so the calc stays in sync with the shorthand. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LKiKo1SEYyTuLpDDfi4HDL --- .../dockview-core/src/theme/_space-mixin.scss | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/dockview-core/src/theme/_space-mixin.scss b/packages/dockview-core/src/theme/_space-mixin.scss index 1883aa2b2..8f478bd0b 100644 --- a/packages/dockview-core/src/theme/_space-mixin.scss +++ b/packages/dockview-core/src/theme/_space-mixin.scss @@ -2,7 +2,13 @@ --dv-spacing-padding: 10px; --dv-tab-font-size: 12px; --dv-border-radius: 12px; - --dv-tab-margin: 0.5rem 0.25rem; + // Split into block (top/bottom) and inline (left/right) parts so the + // multi-row wrap rule below can subtract the exact vertical margin from + // the wrapped-tab height (calc can't read a component out of the + // `--dv-tab-margin` shorthand). + --dv-tab-margin-block: 0.5rem; + --dv-tab-margin-inline: 0.25rem; + --dv-tab-margin: var(--dv-tab-margin-block) var(--dv-tab-margin-inline); --dv-tabs-and-actions-container-height: 44px; --dv-tab-border-radius: 8px; --dv-sash-border-radius: 4px; @@ -105,6 +111,25 @@ } } + // Multi-row (wrap) tabs. Core pins each wrapped tab to the full + // `--dv-tabs-and-actions-container-height` (tabs.scss) so every row matches + // the single-row header height. That assumes a zero tab margin, which holds + // for the default theme but not the spaced themes: here `--dv-tab-margin` + // adds a vertical inset that flex-wrap counts toward each row's height, so a + // full-height tab plus its margins makes every wrapped row taller than the + // header — and the tab fills that whole height instead of keeping the pill + // inset it has in single-row mode (where flex `align-items: stretch` sizes + // the tab to the header height minus its margins). Subtract the vertical + // margin so each wrapped row is exactly one header height and the tab keeps + // its pill proportions. Horizontal headers only — the vertical wrap case + // sizes tabs from `--dv-wrap-vertical-tab-height` (measured live). + .dv-tabs-container--wrap:not(.dv-tabs-container-vertical) .dv-tab { + height: calc( + var(--dv-tabs-and-actions-container-height) - + 2 * var(--dv-tab-margin-block) + ); + } + // In writing-mode: vertical-rl (used by vertical tab bars), physical // margin-left/right are the cross-axis margins that control the tab // pill's visual "height" (physical width). Match the 0.5rem cross-axis From 0b2f449fe379299fdf69037861f449b4cd5b380d Mon Sep 17 00:00:00 2001 From: mathuo Date: Wed, 15 Jul 2026 21:53:25 +0000 Subject: [PATCH 2/4] fix(theme): correct spaced-theme edge-group tab width in wrap mode The vertical (edge-group) header is the cross-axis mirror of the horizontal wrap bug: core pins each wrapped vertical tab's width to the full --dv-tabs-and-actions-container-height, ignoring the spaced theme's cross-axis tab margin. The full-width tab plus its left/right margins overflows the edge-group strip and loses the pill inset it has in single-column mode. Subtract the cross-axis margin from the wrapped vertical tab width so the pill fits the strip exactly (height stays core's live-measured --dv-wrap-vertical-tab-height). Also expresses the vertical tab margin via the shared block/inline variables so --dv-tab-margin-block is the cross-axis margin in both orientations. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LKiKo1SEYyTuLpDDfi4HDL --- .../dockview-core/src/theme/_space-mixin.scss | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/dockview-core/src/theme/_space-mixin.scss b/packages/dockview-core/src/theme/_space-mixin.scss index 8f478bd0b..4f509ee7f 100644 --- a/packages/dockview-core/src/theme/_space-mixin.scss +++ b/packages/dockview-core/src/theme/_space-mixin.scss @@ -132,15 +132,34 @@ // In writing-mode: vertical-rl (used by vertical tab bars), physical // margin-left/right are the cross-axis margins that control the tab - // pill's visual "height" (physical width). Match the 0.5rem cross-axis - // margin used by horizontal tabs so the pill proportions are consistent - // across all four edge-group positions. + // pill's visual "height" (physical width). Swap the block/inline parts so + // the 0.5rem cross-axis margin matches the one horizontal tabs use, keeping + // the pill proportions consistent across all four edge-group positions. + // (`--dv-tab-margin-block` is thus the cross-axis margin in BOTH + // orientations — top/bottom horizontally, left/right vertically.) .dv-tabs-container-vertical { .dv-tab { - margin: 0.25rem 0.5rem; + margin: var(--dv-tab-margin-inline) var(--dv-tab-margin-block); } } + // Multi-column (wrap) tabs on a vertical edge-group header — the cross-axis + // mirror of the horizontal wrap rule above. Core pins each wrapped vertical + // tab to the full `--dv-tabs-and-actions-container-height` as its WIDTH + // (tabs.scss), which (as with the horizontal case) ignores the spaced + // theme's cross-axis tab margin: the full-width tab plus its left/right + // margins overflows the edge-group strip and the pill loses the inset it + // has in single-column mode (flex `align-items: stretch` sizing it to the + // strip width minus its margins). Subtract the cross-axis margin so the + // pill fits the strip exactly. Only the width (cross axis) is corrected — + // the height is core's live-measured `--dv-wrap-vertical-tab-height`. + .dv-tabs-container--wrap.dv-tabs-container-vertical .dv-tab { + width: calc( + var(--dv-tabs-and-actions-container-height) - + 2 * var(--dv-tab-margin-block) + ); + } + .dv-groupview { border-radius: var(--dv-border-radius); From 9fb7a9043022439538f39696fabe9a148483e4c2 Mon Sep 17 00:00:00 2001 From: mathuo Date: Wed, 15 Jul 2026 21:59:30 +0000 Subject: [PATCH 3/4] test(e2e): add ?theme= param to fixture for theme selection Lets the e2e fixture render any exported theme (e.g. abyssSpaced) by name so the spaced-theme wrap layout can be driven in a real browser. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LKiKo1SEYyTuLpDDfi4HDL --- e2e/fixtures/index.html | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/e2e/fixtures/index.html b/e2e/fixtures/index.html index c1535be22..f600bbda5 100644 --- a/e2e/fixtures/index.html +++ b/e2e/fixtures/index.html @@ -71,10 +71,22 @@ : undefined; // `?smooth=1` turns on the smooth tab reorder animation (needed // to exercise the 2-D cross-row wrap reorder path). + // `?theme=` selects an exported theme by its `lib.theme*` + // export name (e.g. `abyssSpaced` -> `lib.themeAbyssSpaced`) so + // the spaced-theme wrap layout can be exercised; inert (core + // default) when absent so other specs are unaffected. + const themeParam = params.get('theme'); + const themeExport = + themeParam && + lib[ + 'theme' + + themeParam.charAt(0).toUpperCase() + + themeParam.slice(1) + ]; const theme = params.get('smooth') === '1' ? { ...lib.themeAbyss, tabAnimation: 'smooth' } - : undefined; + : themeExport || undefined; // `?pinned=separate-row` exercises the two-row pinned layout; // `?compact=1` opts into icon-only pinned tabs. const pinnedMode = From 68a4262cf56a2bab5757ce74a85aa6a6799f8eaf Mon Sep 17 00:00:00 2001 From: mathuo Date: Wed, 15 Jul 2026 22:08:32 +0000 Subject: [PATCH 4/4] test(e2e): guard spaced-theme wrapped tab pill inset Adds two real-browser regression tests for the spaced-theme wrap fixes: horizontal (each wrapped row band == one header height) and vertical edge-group (each wrapped column band == one header width). The invariant is the row/column BAND (tab border-box + its cross-axis margins), which the existing zero-margin default-theme assertion (tabH == rowH) can't catch. Both fail on the pre-fix CSS (tab fills the full unit, band overshoots) and pass with the compensating margin subtraction. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01LKiKo1SEYyTuLpDDfi4HDL --- e2e/tests/multi-row-tabs.spec.ts | 103 +++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/e2e/tests/multi-row-tabs.spec.ts b/e2e/tests/multi-row-tabs.spec.ts index 60d871b8c..66c965d4f 100644 --- a/e2e/tests/multi-row-tabs.spec.ts +++ b/e2e/tests/multi-row-tabs.spec.ts @@ -694,6 +694,109 @@ test.describe('multi-row tabs (wrap mode)', () => { }).toPass(); }); + // --- Spaced themes: the wrapped tab must keep its pill inset --- + // Spaced themes give tabs a `--dv-tab-margin`. Core pins each wrapped tab to + // the full row-height/column-width; without the theme's compensating + // subtraction the tab fills that whole size and its margin then pushes the + // row band past one header unit (horizontal) / overflows the strip + // (vertical) — the pill inset it has in single-row mode is lost. The + // invariant is the ROW BAND (tab border-box + its cross-axis margins), not + // the tab box alone: it must equal exactly one header unit. The default + // theme has a zero tab margin, so only the spaced theme exercises this. + test('spaced theme: each wrapped row band equals one header height (pill inset kept)', async ({ + page, + }) => { + await page.goto( + '/e2e/fixtures/index.html?overflow=wrap&theme=abyssSpaced' + ); + await page.waitForFunction(() => (window as any).__ready === true); + await page.evaluate(() => (window as any).__dv.setupWrapTabs(12)); + + const m = await page.evaluate(() => { + const header = document.querySelector( + '.dv-tabs-and-actions-container' + ) as HTMLElement; + const tab = document.querySelector( + '.dv-tabs-container .dv-tab' + ) as HTMLElement; + const rowH = parseFloat( + getComputedStyle(header).getPropertyValue( + '--dv-tabs-and-actions-container-height' + ) + ); + const cs = getComputedStyle(tab); + const band = + tab.offsetHeight + + parseFloat(cs.marginTop) + + parseFloat(cs.marginBottom); + const rows = new Set(); + document + .querySelectorAll('.dv-tabs-container .dv-tab') + .forEach((t) => rows.add(t.offsetTop)); + return { + rowH, + tabH: tab.offsetHeight, + marginY: + parseFloat(cs.marginTop) + parseFloat(cs.marginBottom), + band, + headerH: header.offsetHeight, + rowCount: rows.size, + }; + }); + + // The theme actually has a vertical tab margin (this is what the bug + // hinged on — a zero-margin theme can't regress here). + expect(m.marginY).toBeGreaterThan(0); + // The tab keeps its pill inset: it is SHORTER than the full row unit ... + expect(m.tabH).toBeLessThan(m.rowH); + // ... and the row band (tab + margins) is exactly one row unit, so + // wrapped rows don't overshoot (the pre-fix band was rowH + marginY). + expect(m.band).toBeCloseTo(m.rowH, 0); + // Tabs really wrapped, and the header is a whole number of row units. + expect(m.rowCount).toBeGreaterThan(1); + expect(m.headerH).toBeCloseTo(m.rowCount * m.rowH, 0); + }); + + test('spaced theme (vertical header): each wrapped column band equals one header width (pill inset kept)', async ({ + page, + }) => { + await setupVertical(page, 'overflow=wrap&theme=abyssSpaced', 12); + await expect.poll(() => columnCount(page)).toBeGreaterThan(1); + + const m = await page.evaluate(() => { + const header = document.querySelector( + '.dv-tabs-and-actions-container' + ) as HTMLElement; + const tab = document.querySelector( + '.dv-tabs-container .dv-tab' + ) as HTMLElement; + const colW = parseFloat( + getComputedStyle(header).getPropertyValue( + '--dv-tabs-and-actions-container-height' + ) + ); + const cs = getComputedStyle(tab); + const band = + tab.offsetWidth + + parseFloat(cs.marginLeft) + + parseFloat(cs.marginRight); + return { + colW, + tabW: tab.offsetWidth, + marginX: + parseFloat(cs.marginLeft) + parseFloat(cs.marginRight), + band, + }; + }); + + expect(m.marginX).toBeGreaterThan(0); + // The cross-axis mirror: the vertical tab keeps its pill inset (narrower + // than the strip) and its column band fits exactly one header width, so + // the strip doesn't overflow (the pre-fix band was colW + marginX). + expect(m.tabW).toBeLessThan(m.colW); + expect(m.band).toBeCloseTo(m.colW, 0); + }); + test('Arrow Up/Down move focus between wrapped rows', async ({ page }) => { await setup(page);