fix(tabs): wrap case clusters box by box and never route a lineage line through a tab

With tabArrangement 'case' at 1440px (nine tabs in four cases), the lineage
routes ran horizontally through the middle of tabs, cluster labels and box
borders. #538's clusters are `max-width: 100%` boxes that may shrink, so in
the one-line strip every box squeezed and wrapped inside itself (a one-tab
case's swatch alone on a line above its tab). The strip then never
overflowed, so updateTabOverflowMode() never wrapped it, and #544's routing
room (`.lineage-tree.tabs-auto-wrap`: row gap and spine channel) never
applied. computeLineageRows grouped the squeezed tabs by top alone into rows
that overlap (10-40, 20-50, 42-74), and gapUnder put the first row's gap at
y 30, inside the tabs below it.

- On the desktop header strip a cluster keeps its width, so clusters that do
  not fit overflow and the strip wraps box by box; the boxes' own gaps read
  --lineage-row-gap, so the routes run between box rows and through the
  spine channel. A case wider than the whole strip still wraps inside its
  box, and now wraps the strip too (`_tabClustersWrapInside`, the new
  `innerWrap` input of shouldAutoWrapTabs), so its inner rows get the gap.
- computeLineageRows never returns overlapping rows: tabs whose spans
  overlap are one row. gapUnder returns null when there is no real gap, and
  a route with no gap is not drawn. A final pass drops any route with a
  segment inside a tab, so no arrangement can draw a line through a tab
  (a layout without the room loses lines instead). Pinned with the squeezed
  rects measured live.

At 1920px, where the clusters fit on one line, nothing changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-08 21:36:04 +02:00
parent 382d7dd406
commit e308e99f05
6 changed files with 230 additions and 11 deletions
+16
View File
@@ -78,6 +78,22 @@ describe('tab overflow layout policy', () => {
// A single overflowing tab must not wrap (need at least 2 to form a second row).
expect(helper.shouldAutoWrapTabs({ ...base, tabCount: 1, scrollWidth: 1400, clientWidth: 760 })).toBe(false);
});
it('wraps a strip whose case cluster already wraps inside itself, though nothing overflows', () => {
// A case wider than the whole strip is capped at its width and wraps in its
// box, so the strip has rows that scrollWidth never shows. Without the wrap,
// the lineage routing room (row gap, spine channel) is never reserved.
const helper = loadTabOverflowHelper();
const fits = { manualTwoRows: false, tabCount: 12, scrollWidth: 800, clientWidth: 800 };
expect(helper.shouldAutoWrapTabs({ ...fits, deviceType: 'desktop', innerWrap: true })).toBe(true);
expect(helper.shouldAutoWrapTabs({ ...fits, deviceType: 'desktop', innerWrap: false })).toBe(false);
// The other guards still win.
expect(helper.shouldAutoWrapTabs({ ...fits, deviceType: 'tablet', innerWrap: true })).toBe(false);
expect(helper.shouldAutoWrapTabs({ ...fits, deviceType: 'desktop', manualTwoRows: true, innerWrap: true })).toBe(
false
);
});
});
// Issue #257: the phone tab strip scrolls horizontally, so the active tab can