Skip to content

feat(tabs): L-shaped header strip, wrapped rows run under the toolbar - #721

Merged
Ark0N merged 2 commits into
Ark0N:masterfrom
timkjr:feat/tab-strip-l-shape
Oct 28, 2026
Merged

Ark0N merged 2 commits into
Ark0N:masterfrom
timkjr:feat/tab-strip-l-shape

Conversation

@timkjr

@timkjr timkjr commented Oct 27, 2026 •

Copy link
Copy Markdown

What

When the classic header tab strip wraps (tabs-auto-wrap, or the manual two-row setting), every row stops at the toolbar's left edge, because the strip and #headerRight are side-by-side flex items. Only the first row actually sits beside the toolbar; the space under the stats/buttons on every later row stays empty, so with a couple of long tab names each tab ends up on a row of its own.

With this change, once the strip wraps the toolbar sits over the strip's top-right corner and the strip runs the header's full width: only the row(s) beside the toolbar stop short of it, and every later row uses the full width under it.

Screenshots (1280px with a lineage line, and a toolbar narrowed onto two lines):
tab-l-shape-narrow-toolbar
tab-l-shape-1280

How

  • updateTabOverflowMode() is unchanged in what it decides: the corner class is removed before the wrap is measured, so the wrap is still judged against the normal one-row layout (toolbar in flow), and a strip that fits on one row looks exactly as before.
  • _syncTabStripCorner() then adds .tabs-corner to a wrapped classic strip in the header and measures the toolbar's box into --tab-corner-width / --tab-corner-height.
  • CSS: #headerRight goes position: absolute over the corner; the strip becomes display: block with inline-flex tabs flowing around a ::before float of the toolbar's size. A flex line cannot be shortened on its own, which is why the strip leaves flex layout in this one state. The old row-gap (including lineage's --lineage-row-gap) becomes a bottom margin on the tabs, and the strip's bottom padding is reduced by the same amount, so row spacing and the lineage routing room are unchanged.
  • A ResizeObserver on the toolbar re-decides wrap + corner and redraws the connection lines when a reading or the header divider changes the toolbar's size without a tab render.
  • If the toolbar leaves too little room for the first tab beside it (which would push the whole first line below the toolbar and leave an empty band), the strip falls back to the plain wrapped layout, and returns to the L-shape when there is room again.

Scope: classic arrangement, desktop, header host only. The state / case / ledger arrangements lay out their own rows, and the sidebar and vertical rail are untouched (all gated in _syncTabStripCorner; applySessionListLayout also drops the class when moving the strip to the sidebar).

Tests

Checked in a real browser (Playwright, headless Chromium) at 1000 / 1100 / 1280 / 2400 px with lineage lines, a toolbar narrowed until it wraps onto two lines, and a toolbar widened past the first tab: no tab overlaps the toolbar in any case, lineage routes draw, no page errors.

npm run lint, format:check, check:frontend-syntax and the full npm test pass locally. There is no automated test for the layout itself; happy to add a browser test if you want one.

🤖 Generated with Claude Code

A wrapped classic tab strip stopped at the toolbar's left edge on every row,
so the space under the header chips stayed empty and long tabs took a row
each. Once the strip wraps, the toolbar now sits over the strip's top-right
corner and the strip lays its tabs out inline around a float sized to the
toolbar's box (_syncTabStripCorner), so only the rows beside the toolbar stop
short of it and every later row runs the header's full width.

- Wrap is still measured with the toolbar in flow (the corner is dropped
  before the measure), so the single-row strip is unchanged.
- A ResizeObserver on the toolbar re-decides wrap + corner and redraws the
  lineage routes when a reading or the divider changes its size.
- Falls back to the plain strip when the first tab cannot fit beside the
  toolbar (it would otherwise leave an empty band).
- Classic arrangement, desktop, header host only; state/case/ledger, the
  sidebar and the tab rail keep their own layouts.
@Ark0N

Ark0N commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thank you for this, timkjr, and welcome to Codeman. This PR turns a wrapped classic tab strip into an L-shape: the toolbar sits over the strip's top-right corner, only the rows beside it stop short, and every later row uses the full header width. I ran it in Chromium against the real header markup and styles.css at several widths, with lineage, a toolbar narrowed onto two lines and a toolbar too wide for the first tab. It does what the description says, the fallback works, the toolbar observer settles after one pass, and every check is green.

Two things to change before merge:

  1. A strip past its max-height scrolls its tabs under the toolbar (src/web/public/app.js:6999). A wrapped strip scrolls once it reaches --tab-strip-max-height (40vh), and the float scrolls with the tabs while the toolbar stays put. At 1100x500 with 50 tabs, scrolling the strip 60px puts 4 tabs under #headerRight (none on master). With the manual two-rows setting the scroll position survives a re-render, so contentTop (line 7025) sizes the float from the scrolled frame (105px instead of 25px), and scrolling back up leaves an empty band beside the rows until the next render. Keeping the plain wrap when the plain strip already scrolls fixes both. At that point updateTabOverflowMode() has just removed the class, so nothing is toggled. I checked this on a copy, and every other case stays the same:

    if (!on || container.scrollHeight > container.clientHeight + 1) {

    I also tried the same check after .tabs-corner is added. It removes the overlap too, but toggling the class while the strip is scrolled lets scroll anchoring move it (80px to 52px in the two-rows case), so the check belongs before the class goes on. With the guard, the - container.scrollTop on line 7025 is always 0 and can go.

  2. A browser test for the new layout. Yes please to the test you offered. test/header-divider.browser.test.ts shows the pattern: it loads the real <header> from index.html and the real styles.css into Chromium. A test/tab-strip-corner.browser.test.ts should cover these cases: a wrapped classic desktop strip gets .tabs-corner and no tab rect intersects #headerRight; a row below the toolbar runs past its left edge; a first tab too wide for the room falls back to the plain wrap; an unwrapped strip, the state/case/ledger arrangements and a strip outside #sessionTabsHost never get the class; a strip past its max-height falls back (the case above); and narrowing the toolbar with --header-right-width re-sizes the corner. Add it to BROWSER_TEST_GLOBS in config/test-suites.ts, or npm run check:browser-excludes fails.

Also, please add a sentence to the Tab layouts entry in src/web/CLAUDE.md (line 41, which still says "classic is the old strip"). It should say that a wrapped classic desktop strip is L-shaped via _syncTabStripCorner(): .tabs-corner comes off before the wrap is measured, and the strip is display: block in that state, so flex-only rules do not apply to it there.

Once those are in, this is ready to merge.

…test

Review follow-up for the L-shaped header strip:

- _syncTabStripCorner() keeps the plain wrap when the plain strip is
  already past --tab-strip-max-height, checked before .tabs-corner goes
  on: the float scrolled with the tabs while the toolbar stayed put, so a
  scrolled strip put tabs under #headerRight, and a re-render of a
  scrolled two-rows strip sized the float from the scrolled frame. The
  "- scrollTop" in contentTop is gone (always 0 now).
- A strip just under its max-height that passes it once the float is in
  falls back to the plain wrap as well.
- test/tab-strip-corner.browser.test.ts (real Chromium, real header and
  styles.css), listed in BROWSER_TEST_GLOBS.
- src/web/CLAUDE.md Tab layouts entry describes the L-shaped classic strip.
@timkjr

timkjr commented Oct 27, 2026

Copy link
Copy Markdown
Author

Thanks for the careful run-through. All three are in:

  1. Scrolled strip. _syncTabStripCorner() now keeps the plain wrap when the plain strip already scrolls, using your guard, checked before .tabs-corner goes on. The - container.scrollTop in contentTop is gone. I also added one check after the corner is sized: if a plain strip just under --tab-strip-max-height goes over it once the float is in, it drops back to the plain wrap, which fit. The plain strip didn't scroll at that point, so scrollTop is 0 and scroll anchoring has nothing to move. I also updated the .tabs-corner comment in styles.css, since it said the strip scrolls.

  2. Browser test. test/tab-strip-corner.browser.test.ts covers every case on your list: corner on with no tab under #headerRight, a lower row running past the toolbar's left edge, the too-wide first tab, an unwrapped strip, the state/case/ledger arrangements, a strip outside #sessionTabsHost, the max-height fallback (both auto-wrap and a scrolled two-rows strip across a re-render), and --header-right-width re-sizing the corner. Against the previous commit the two max-height cases fail and the rest pass. It's in BROWSER_TEST_GLOBS, and check:browser-excludes passes.

  3. src/web/CLAUDE.md. The Tab layouts entry now says a wrapped classic desktop strip is L-shaped via _syncTabStripCorner(), that .tabs-corner comes off before the wrap is measured, and that the strip is display: block in that state, so flex-only rules don't apply to it there.

@Ark0N

Ark0N commented Oct 27, 2026

Copy link
Copy Markdown
Owner

Thank you, timkjr, for the quick and thorough follow-up. To recap for anyone reading along: this PR makes a wrapped classic tab strip L-shaped, so the toolbar sits over the strip's top-right corner and every row below it uses the full header width.

All three points from the last review are in, and I checked each one:

  • The scrolled-strip guard runs on the plain strip before .tabs-corner goes on, and the second check after the float is sized falls back cleanly. A strip past --tab-strip-max-height keeps the plain wrap, and no tab ends up under the toolbar when it scrolls.
  • test/tab-strip-corner.browser.test.ts covers every case on the list and is in BROWSER_TEST_GLOBS. It passes here alongside header-divider.browser.test.ts and the other tab browser tests, and run against your first commit it fails exactly the two max-height cases, so it pins the guard.
  • The src/web/CLAUDE.md Tab layouts entry matches the code.

I also drove the toolbar observer directly (a toolbar width change with no tab render re-sized the corner on its own), and checked lineage spacing (row gap and bottom room identical to the plain wrap).

Typecheck, lint, format, frontend syntax, the browser-exclude check and the full npm test gate are green locally, and both CI jobs are green.

One thing I will do at merge time: add a paragraph on the L-shape to the Tab layouts section of docs/architecture-invariants.md (the mechanism file src/web/CLAUDE.md links to), so you do not need to push again for it.

This is ready to merge.

@Ark0N
Ark0N merged commit 285aa0b into Ark0N:master Oct 28, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Oct 28, 2026
Add the L-shaped strip to the Tab layouts section of
docs/architecture-invariants.md, the mechanism file src/web/CLAUDE.md
links to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 28, 2026
@Ark0N

Ark0N commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Merged in 1.47.0 (https://github.com/Ark0N/Codeman/releases/tag/codeman%401.47.0). Thank you for the L-shaped strip and for the quick round with the scroll guard and the browser test. At merge I added the paragraph on the L-shape to the Tab layouts section of docs/architecture-invariants.md (cdff4d4), as promised, so the mechanism file matches the note you wrote in src/web/CLAUDE.md.

@timkjr
timkjr deleted the feat/tab-strip-l-shape branch October 28, 2026 15:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants