diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 9824f689..1b070000 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1047,7 +1047,7 @@ Tests: `test/terminal-touch-tap.test.ts`, `test/session-swipe-gesture.test.ts` ( **Ledger.** CSS only, on `.tabs-ledger` inside `@media (min-width: 768px)` and the header host: the flat list on an auto-fill column grid with equal cells, mono type and a 3px status bar (`--ledger-bar`: yellow for a waiting alert, red for an action alert or an error, muted for stopped, exited and web tabs) instead of the dot. ⚠️ It must not change the markup (pinned: the ledger's innerHTML equals classic's); the rail, the sidebar and narrower strips show the plain list. -**Classic, wrapped: the L-shaped strip.** Once a classic desktop strip in the header host wraps (`tabs-auto-wrap`, or the manual `tabs-two-rows`), `_syncTabStripCorner()` (app.js) adds `.tabs-corner`: `#headerRight` goes `position: absolute` over the strip's top-right corner and the strip runs the header's full width, so only the row(s) beside the toolbar stop short of it and every later row uses the width under it. A flex line cannot be shortened on its own, so in that state the strip is `display: block` with `inline-flex` tabs flowing around a `::before` float sized from the toolbar's box (`--tab-corner-width` / `--tab-corner-height`); the old `row-gap` (lineage's `--lineage-row-gap` included) becomes the tabs' bottom margin and the bottom padding shrinks by the same amount, so row spacing and lineage routing room are unchanged. ⚠️ `updateTabOverflowMode()` removes `.tabs-corner` BEFORE it measures the wrap (the corner takes the toolbar out of the header's flow, which widens the strip), so the wrap is still judged against the one-row layout and an unwrapped strip looks exactly as before. ⚠️ Flex-only rules do not apply to the strip in that state. ⚠️ A strip that already scrolls (past `--tab-strip-max-height`) keeps the plain wrap, checked on the plain strip before the class goes on: the float scrolls with the tabs while the toolbar stays put, and toggling the class on a scrolled strip lets scroll anchoring move it; a strip pushed past the max-height by the float itself falls back too. A first tab too wide for the room beside the toolbar (the whole first line would drop below it) is trimmed to the room instead (`tab-corner-fit`, `max-width: var(--tab-corner-first-max)`, its name ellipsized): a selected tab is wider (gear and close show), so a fallback here made the whole layout follow the selection. Only a room under `TAB_CORNER_MIN_FIRST_TAB` keeps the plain wrap. ⚠️ The first tab is measured with `tab-corner-fit` cleared, or a room that grew would read the last trim as its width. A `ResizeObserver` on the toolbar re-decides wrap and corner and redraws the connection lines when a reading or the header divider resizes it without a tab render. Scope: classic arrangement, desktop, header host only; state/case/ledger, the sidebar and the vertical rail never get the class (`applySessionListLayout()` drops it when the strip moves). Tests: `test/tab-strip-corner.browser.test.ts` (browser suite). +**Classic, wrapped: the L-shaped strip.** Once a classic desktop strip in the header host wraps (`tabs-auto-wrap`, or the manual `tabs-two-rows`), `_syncTabStripCorner()` (app.js) adds `.tabs-corner`: `#headerRight` goes `position: absolute` over the strip's top-right corner and the strip runs the header's full width, so only the row(s) beside the toolbar stop short of it and every later row uses the width under it. A flex line cannot be shortened on its own, so in that state the strip is `display: block` with `inline-flex` tabs flowing around a `::before` float sized from the toolbar's box (`--tab-corner-width` / `--tab-corner-height`); the old `row-gap` (lineage's `--lineage-row-gap` included) becomes the tabs' bottom margin and the bottom padding shrinks by the same amount, so row spacing and lineage routing room are unchanged. ⚠️ `updateTabOverflowMode()` removes `.tabs-corner` BEFORE it measures the wrap (the corner takes the toolbar out of the header's flow, which widens the strip), so the wrap is still judged against the one-row layout and an unwrapped strip looks exactly as before. ⚠️ Flex-only rules do not apply to the strip in that state. ⚠️ A strip that already scrolls (past `--tab-strip-max-height`) keeps the plain wrap, checked on the plain strip before the class goes on: the float scrolls with the tabs while the toolbar stays put, and toggling the class on a scrolled strip lets scroll anchoring move it; a strip pushed past the max-height by the float itself falls back too. A first tab too wide for the room beside the toolbar (the whole first line would drop below it) is trimmed to the room instead (`tab-corner-fit`, `max-width: var(--tab-corner-first-max)`, its name ellipsized): a selected tab is wider (gear and close show), so a fallback here made the whole layout follow the selection. Only a room under `TAB_CORNER_MIN_FIRST_TAB` keeps the plain wrap. ⚠️ The cap goes on the first tab on every pass, never only once it measures too wide: `selectSession()` widens a newly selected tab at once and this sync is debounced, so a cap set afterwards leaves the first row below the toolbar until it runs. It changes nothing on a tab narrower than the room. A `ResizeObserver` on the toolbar re-decides wrap and corner and redraws the connection lines when a reading or the header divider resizes it without a tab render. Scope: classic arrangement, desktop, header host only; state/case/ledger, the sidebar and the vertical rail never get the class (`applySessionListLayout()` drops it when the strip moves). Tests: `test/tab-strip-corner.browser.test.ts` (browser suite). With `classic` nothing is left behind: no headings, no breaks, no inline order, no boxes, no name split, no arrangement class. A stale cached mobile-overview.js degrades every grouping to the flat strip. Tests: `test/tab-triage.test.ts`, `test/tab-clusters.test.ts`. diff --git a/src/web/public/app.js b/src/web/public/app.js index 3f588494..d55b0079 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -7073,8 +7073,6 @@ class CodemanApp { const contentTop = strip.top + parseFloat(style.paddingTop); const contentLeft = strip.left + container.clientLeft + parseFloat(style.paddingLeft); const contentRight = strip.left + container.clientLeft + container.clientWidth - parseFloat(style.paddingRight); - // A first tab that cannot fit beside the toolbar would drop the whole first - // line below it and leave an empty band: keep the plain strip instead. // The first tab must fit beside the toolbar, or the whole first line drops // below it and leaves an empty band. A selected tab is wider (gear and // close show), so falling back to the plain strip here made the layout @@ -7090,17 +7088,14 @@ class CodemanApp { return; } const firstTab = container.querySelector(':scope > .session-tab'); - // Cleared first, the first tab's included: the last pass's trim would - // otherwise be measured as its width, and a room that grew would untrim a - // tab still too wide for it. - for (const tab of container.querySelectorAll(':scope > .tab-corner-fit')) tab.classList.remove('tab-corner-fit'); - // A selection change slides the gear and close open or shut (a width - // transition), and this runs in the same render: finish the first tab's - // slide, or it is judged by the width it is leaving. - for (const anim of firstTab?.getAnimations?.({ subtree: true }) ?? []) { - if (/^(width|padding)/.test(anim.transitionProperty ?? '')) anim.finish(); + // Capped every time, not only once it measures too wide: selectSession() + // widens a newly selected tab at once and this pass is debounced, so a cap + // set only here afterwards would leave the first row below the toolbar until + // it runs. The cap changes nothing on a tab narrower than the room. + for (const tab of container.querySelectorAll(':scope > .tab-corner-fit')) { + if (tab !== firstTab) tab.classList.remove('tab-corner-fit'); } - if (firstTab && firstTab.offsetWidth > room) firstTab.classList.add('tab-corner-fit'); + firstTab?.classList.add('tab-corner-fit'); if (container.style.getPropertyValue('--tab-corner-first-max') !== `${room}px`) { container.style.setProperty('--tab-corner-first-max', `${room}px`); } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 4d6284a6..49c1652f 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -651,9 +651,9 @@ body { margin: 0 2px var(--lineage-row-gap, 4px) 0; } -/* A first tab wider than the room beside the toolbar (a selected one shows its - gear and close) has its name trimmed to fit, so the layout never depends on - which tab is selected. */ +/* The first tab is capped to the room beside the toolbar: one wider than that (a + selected one shows its gear and close) has its name trimmed to fit, so the + layout never depends on which tab is selected. */ .session-tabs-host > .session-tabs.tabs-corner > .session-tab.tab-corner-fit { max-width: var(--tab-corner-first-max, none); } diff --git a/test/tab-strip-corner.browser.test.ts b/test/tab-strip-corner.browser.test.ts index ffb3f9aa..e70bc4a3 100644 --- a/test/tab-strip-corner.browser.test.ts +++ b/test/tab-strip-corner.browser.test.ts @@ -200,14 +200,22 @@ describe('L-shaped header tab strip (real Chromium)', () => { expect(await select(3)).toEqual({ cornered: true, overlapping: [], - first: { besideToolbar: true, trimmed: false }, + first: { besideToolbar: true, trimmed: true }, }); expect(await select(1)).toEqual({ cornered: true, overlapping: [], first: { besideToolbar: true, trimmed: true } }); expect(await select(3)).toEqual({ cornered: true, overlapping: [], - first: { besideToolbar: true, trimmed: false }, + first: { besideToolbar: true, trimmed: true }, }); + // selectSession() widens the tab at once and the corner sync is debounced: the + // cap has to be on already, or the first row drops below the toolbar until then. + await p.evaluate(() => { + document + .querySelectorAll('#sessionTabs > .session-tab') + .forEach((tab, i) => tab.classList.toggle('active', i === 0)); + }); + expect(await firstTab()).toEqual({ besideToolbar: true, trimmed: true }); await p.close(); }); @@ -240,7 +248,9 @@ describe('L-shaped header tab strip (real Chromium)', () => { }); it('keeps the plain wrap when the toolbar leaves no usable room beside it', async () => { - await open({ tabs: 40 }); + // Few enough that the strip does not scroll: at 40 the scroll fallback turns the + // corner off before the room check is reached, and this would pass at any threshold. + await open({ tabs: 8 }); await p.evaluate(() => { const head = document.querySelector('.header')!.getBoundingClientRect(); const right = document.getElementById('headerRight')!;