diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index 318b030b..c3be2c22 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -810,12 +810,15 @@ function elementById(id) { }); const barFits = () => { const bar = actions.closest(".tab-bar"); - if (!bar) return true; - const identity = bar.querySelector(".header-main"); - const nav = bar.querySelector(".tab-bar-nav"); - const gutters = 48; - const needed = (identity?.offsetWidth ?? 0) + (nav?.scrollWidth ?? 0) + actions.scrollWidth + gutters; - return needed <= bar.clientWidth; + const nav = bar?.querySelector(".tab-bar-nav"); + if (!bar || !nav) return true; + const barRect = bar.getBoundingClientRect(); + const actionsRect = actions.getBoundingClientRect(); + if (actionsRect.right > barRect.right + 1) return false; + const tabs = Array.from(nav.querySelectorAll(".tab")).filter((tab) => tab.offsetParent !== null); + if (!tabs.length) return true; + const tabsRight = Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right)); + return tabsRight <= actionsRect.left - 1; }; const reflowOverflow = () => { const nav = document.querySelector(".tab-bar-nav"); @@ -841,6 +844,18 @@ function elementById(id) { }; reflowOverflow(); window.addEventListener("resize", reflowOverflow); + if (typeof ResizeObserver !== "undefined") { + const bar = actions.closest(".tab-bar"); + const observer = new ResizeObserver(() => { + reflowOverflow(); + }); + if (bar) observer.observe(bar); + observer.observe(actions); + } + document.fonts?.ready.then(() => { + reflowOverflow(); + }).catch(() => { + }); } function installMobileMore() { const nav = document.querySelector(".tab-bar-nav"); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts index 4e7f0591..d59be76a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts @@ -400,29 +400,32 @@ function elementById(id: string): T { }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeMenu(); }); - // Measured against the bar, not the actions container: the actions are - // sized by their content, so their own scrollWidth never exceeds their - // clientWidth. A viewport-width threshold is not enough either — how much - // fits depends on the rig name and the translated labels, so a bar that is - // wide enough on one rig clips a control on another. - // `bar.scrollWidth > bar.clientWidth` is true even when nothing is clipped, - // so it cannot be the test. What actually matters is that the controls stay - // inside the bar and the page tabs are not squeezed into a scroller: seeing - // every tab beats keeping the style picker inline. - // Compare natural widths against the space available. Rendered widths - // cannot answer this: the nav has min-width 0 and scrolls, so it always - // shrinks to the leftover space and always reports "scrolling", while the - // bar reports overflow even when nothing is clipped. scrollWidth on a - // scroll container is its unconstrained content width, which is what a fit - // test needs. + // What "fits" means, measured rather than predicted. Two things have to + // hold: the controls stay inside the bar, and no tab reaches them. The + // tabs are the test rather than the nav's own box because the nav may + // shrink below its content — its box gets smaller while the tabs inside + // keep their width and slide under the controls, so the container reports + // nothing wrong while destinations become unclickable. + // + // This was an arithmetic estimate — identity + nav.scrollWidth + + // actions.scrollWidth + a 48px allowance for the gaps — which under-counts + // whatever the allowance does not cover. On a platform whose fonts run + // wider than this machine's it declared a fit that overlapped by 9px, and + // nothing degraded because nothing thought anything was wrong. Reading + // the geometry costs a synchronous layout per step, at most five per pass, + // and cannot disagree with what the operator sees. const barFits = () => { const bar = actions.closest(".tab-bar"); - if (!bar) return true; - const identity = bar.querySelector(".header-main"); - const nav = bar.querySelector(".tab-bar-nav"); - const gutters = 48; - const needed = (identity?.offsetWidth ?? 0) + (nav?.scrollWidth ?? 0) + actions.scrollWidth + gutters; - return needed <= bar.clientWidth; + const nav = bar?.querySelector(".tab-bar-nav"); + if (!bar || !nav) return true; + const barRect = bar.getBoundingClientRect(); + const actionsRect = actions.getBoundingClientRect(); + if (actionsRect.right > barRect.right + 1) return false; + const tabs = Array.from(nav.querySelectorAll(".tab")) + .filter((tab) => tab.offsetParent !== null); + if (!tabs.length) return true; + const tabsRight = Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right)); + return tabsRight <= actionsRect.left - 1; }; const reflowOverflow = () => { const nav = document.querySelector(".tab-bar-nav"); @@ -458,6 +461,17 @@ function elementById(id: string): T { }; reflowOverflow(); window.addEventListener("resize", reflowOverflow); + // A resize is not the only thing that changes what fits: the rig name + // arrives from the server, the style picker fills in, a web font swaps in + // wider metrics. Each changes the bar's content without touching the + // window, and the strip stayed as it was through all of them. + if (typeof ResizeObserver !== "undefined") { + const bar = actions.closest(".tab-bar"); + const observer = new ResizeObserver(() => { reflowOverflow(); }); + if (bar) observer.observe(bar); + observer.observe(actions); + } + document.fonts?.ready.then(() => { reflowOverflow(); }).catch(() => {}); } function installMobileMore() { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/browser-smoke.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/browser-smoke.mjs index b723e48c..7f0e2d21 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/browser-smoke.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/browser-smoke.mjs @@ -275,37 +275,70 @@ try { assert.ok(menu.height > 40 && menu.width > 80, `menu rendered ${menu.width}x${menu.height}`); assert.ok(menu.onTop, "menu is painted underneath the page"); - // Wider text than this machine renders. The checks above passed on macOS - // while CI, whose system font is wider, put the tab strip into the controls: - // the bar had run out of moves and the tabs kept their full width anyway. - // Every piece of text in the bar is scaled, not just the tabs — the identity - // block is what runs out of room first at 1100px, where the bookmark gutters - // make the card narrower than it is at 900px. - const scale = 1.9; - await page.addStyleTag({ content: ` + // Wider text than this machine renders. This suite passed on macOS twice + // while CI, whose system font is wider, put the tab strip into the controls — + // the second time by 9px at 1440px with no scaling at all, because the fit + // test was an arithmetic estimate whose fixed allowance for the gaps did not + // cover them at those metrics. A single scale factor cannot stand in for + // another platform's fonts, so sweep: somewhere in this range is whatever CI + // renders, and the strip has to hold at every step of it. + const applyTextScale = (scale) => page.addStyleTag({ content: ` .tab-bar .title { font-size: ${1.05 * scale}rem !important; } .tab-bar .subtitle { font-size: ${0.78 * scale}rem !important; } .tab-bar .tab { font-size: ${0.95 * scale}rem !important; } .tab-bar select, .tab-bar button { font-size: ${0.95 * scale}rem !important; } ` }); - for (const width of [1440, 1280, 1100, 900]) { - await page.setViewportSize({ width, height: 900 }); - await page.waitForTimeout(250); - const crowded = await page.evaluate(() => { - const nav = document.querySelector(".tab-bar-nav"); - const actions = document.querySelector(".top-bar-actions"); - const tabs = [...nav.querySelectorAll(".tab")].filter((tab) => tab.offsetParent !== null); - return { - overlap: Math.round(Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right)) - - actions.getBoundingClientRect().left), - iconsOnly: nav.classList.contains("nav-icons-only"), - }; - }); - assert.ok(crowded.overlap <= 0, - `with wider text the tab strip overlaps the controls by ${crowded.overlap}px at ${width}px`); - assert.equal(crowded.iconsOnly, true, `the strip kept its labels at ${width}px with no room for them`); + const measureBar = () => page.evaluate(() => { + const nav = document.querySelector(".tab-bar-nav"); + const actions = document.querySelector(".top-bar-actions"); + const tabs = [...nav.querySelectorAll(".tab")].filter((tab) => tab.offsetParent !== null); + return { + overlap: Math.round(Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right)) + - actions.getBoundingClientRect().left), + iconsOnly: nav.classList.contains("nav-icons-only"), + }; + }); + + for (const scale of [1.0, 1.15, 1.3, 1.5, 1.75, 2.0]) { + await applyTextScale(scale); + // 1100px is the narrowest bar in the app: the bookmark gutters take 9.5rem + // a side above that width, leaving less room than 900px has. + for (const width of [1440, 1280, 1100, 900]) { + await page.setViewportSize({ width, height: 900 }); + await page.waitForTimeout(120); + const crowded = await measureBar(); + assert.ok(crowded.overlap <= 0, + `at ${scale}x text the tab strip overlaps the controls by ${crowded.overlap}px at ${width}px`); + } } + // A station name long enough that the bar cannot hold it, which is the rung + // below icons: the identity has to give, not the strip. + await page.evaluate(() => { + document.getElementById("rig-subtitle").textContent = + "Rig: Shack SDR — RTL-SDR v4 on the attic dipole, north-west"; + }); + await applyTextScale(1.6); + for (const width of [1440, 1100]) { + await page.setViewportSize({ width, height: 900 }); + await page.waitForTimeout(200); + const crowded = await measureBar(); + assert.ok(crowded.overlap <= 0, + `with a long station name the tab strip overlaps the controls by ${crowded.overlap}px at ${width}px`); + } + + // ...and when the text changes under a bar that is not resized. The rig name + // arrives from the server, a web font swaps in: neither is a window resize, + // and the strip used to sit there as it was. + await page.setViewportSize({ width: 1440, height: 900 }); + await page.waitForTimeout(200); + await applyTextScale(2.4); + await page.waitForTimeout(400); + const unresized = await measureBar(); + assert.ok(unresized.overlap <= 0, + `text grew without a resize and the strip overlaps by ${unresized.overlap}px`); + assert.equal(unresized.iconsOnly, true, "the strip kept its labels with no room for them"); + } finally { await browser.close(); await fixture.close();