[fix](trx-frontend-http): measure whether the tab bar fits, and watch for it
CI / lint (push) Successful in 2m20s
CI / test (push) Successful in 8m25s
CI / frontend (push) Successful in 3m36s
CI / reuse (push) Successful in 3s

CI put the tab strip 9px into the controls at 1440px with no scaling at
all, on a bar that had every degradation step available to it and used
none of them.  It used none because nothing thought anything was wrong:
the fit test was an arithmetic estimate — identity + nav.scrollWidth +
actions.scrollWidth + a 48px allowance for the gaps — and on a platform
whose fonts run wider than the one it was written on, that allowance no
longer covered what it stands for.  An estimate that says "fits" stops
the ladder before its first rung.

It reads the geometry now: the controls have to stay inside the bar, and
no tab may reach them.  That is the same measurement the test makes, so
the two cannot disagree about any platform's metrics.  The tabs are the
subject rather than the nav's box because the nav shrinks below its
content — the box gets smaller while the tabs keep their width and slide
underneath the controls.

A second fault turned up while probing this: the strip only reflowed on
window resize.  The rig name arriving from the server, the style picker
filling in, a font swapping in wider metrics — each changes what fits
without touching the window, and the bar sat there as it was through all
of them.  A ResizeObserver on the bar and the controls covers those, and
document.fonts.ready covers the swap.

The guard sweeps text scales and adds a station name too long for the
bar, but it should be said plainly: it passes against the old code too.
Nothing here reproduces on this machine — a 4px viewport sweep from 1080
to 1500, three wide font stacks and scales from 1.0 to 3.0 all failed to
make the old estimate lie.  What is fixed is the mechanism that could.

Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
sjg
2026-08-04 20:32:38 +02:00
parent c527f20a88
commit 84bdf2593c
3 changed files with 113 additions and 51 deletions
@@ -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");
@@ -400,29 +400,32 @@ function elementById<T extends HTMLElement>(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<HTMLElement>(".tab-bar");
if (!bar) return true;
const identity = bar.querySelector<HTMLElement>(".header-main");
const nav = bar.querySelector<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".tab-bar-nav");
@@ -458,6 +461,17 @@ function elementById<T extends HTMLElement>(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<HTMLElement>(".tab-bar");
const observer = new ResizeObserver(() => { reflowOverflow(); });
if (bar) observer.observe(bar);
observer.observe(actions);
}
document.fonts?.ready.then(() => { reflowOverflow(); }).catch(() => {});
}
function installMobileMore() {
@@ -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();