From eee3630f043e47522d44cf9c14c04d3780e6b78c Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sun, 2 Aug 2026 18:45:43 +0200 Subject: [PATCH] [feat](trx-frontend-http): compact single-row top bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header's height depended on the viewport width, and not even monotonically: 112px at 1440, 169px at 1100, 131px at 900, 246px at 720. Both control groups wrapped, so every width produced a different ragged block — eight page tabs across four rows at 1100px, and action controls across three. Four different control heights (32, 34, 45 and 54px) sat in the same row, the 54px one being the rig picker with its summary stacked underneath, and on narrow viewports the icon buttons stretched to fill half the row, rendering a play triangle centred in a 249px box. Lay both groups out as one row that never wraps. Controls are a uniform 2rem and no longer stretch, the rig summary sits inline beside its select, and the page tabs scroll instead of wrapping. Secondary controls — layout, style and theme — move into an overflow menu when the bar cannot hold them, leaving audio, record and the rig picker inline. Deciding when they no longer fit needs natural widths, not rendered ones: the nav has min-width 0 and scrolls, so it always shrinks to the leftover space and always reports scrolling, and the bar reports overflow even when nothing is clipped. scrollWidth on the scroll container is its unconstrained content width, which is what the fit test compares against the space available. Measured after the change: 72px at 1440, 1280, 1100, 900 and 480, every control 32px, nothing clipped at any width. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz Signed-off-by: Stan Grams --- .../assets/web/generated/app.js | 66 ++++++++++++++ .../trx-frontend-http/assets/web/style.css | 90 +++++++++++++++---- .../trx-frontend-http/frontend/src/ui-core.ts | 85 ++++++++++++++++++ 3 files changed, 225 insertions(+), 16 deletions(-) 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 98c621c6..b851bc7d 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 @@ -747,6 +747,71 @@ function elementById(id) { api.applyLayout(savedLayoutName(), { persist: false }); } } + const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"]; + function installTopBarOverflow() { + const actions = document.querySelector(".top-bar-actions"); + if (!actions || document.getElementById("top-bar-more")) return; + const wrap = document.createElement("div"); + wrap.id = "top-bar-more"; + wrap.className = "top-bar-more"; + const button = document.createElement("button"); + button.type = "button"; + button.id = "top-bar-more-btn"; + button.className = "header-bar-btn top-bar-more-btn"; + button.textContent = "⋯"; + button.setAttribute("aria-haspopup", "menu"); + button.setAttribute("aria-expanded", "false"); + button.setAttribute("aria-label", "More controls"); + button.title = "More controls"; + const menu = document.createElement("div"); + menu.id = "top-bar-more-menu"; + menu.className = "top-bar-more-menu"; + menu.setAttribute("role", "menu"); + button.setAttribute("aria-controls", menu.id); + wrap.append(button, menu); + actions.appendChild(wrap); + const closeMenu = () => { + menu.classList.remove("is-open"); + button.setAttribute("aria-expanded", "false"); + }; + button.addEventListener("click", () => { + const open = menu.classList.toggle("is-open"); + button.setAttribute("aria-expanded", String(open)); + }); + document.addEventListener("click", (event) => { + if (!(event.target instanceof Node) || !wrap.contains(event.target)) closeMenu(); + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") closeMenu(); + }); + 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 reflowOverflow = () => { + overflowOrder.forEach((selector) => { + const element = menu.querySelector(selector); + if (element) actions.insertBefore(element, wrap); + }); + wrap.hidden = true; + for (const selector of overflowOrder) { + if (barFits()) break; + const element = actions.querySelector(selector); + if (!element) continue; + wrap.hidden = false; + menu.appendChild(element); + } + wrap.hidden = menu.children.length === 0; + if (wrap.hidden) closeMenu(); + }; + reflowOverflow(); + window.addEventListener("resize", reflowOverflow); + } function installMobileMore() { const nav = document.querySelector(".tab-bar-nav"); if (!nav || document.getElementById("mobile-more-btn")) return; @@ -853,6 +918,7 @@ function elementById(id) { api.init = function init() { ensureLiveRegions(); installLayoutControls(); + installTopBarOverflow(); installMobileMore(); installDecoderPicker(); installDecoderBadges(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index 480ba033..813976ba 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -1229,12 +1229,20 @@ small { color: var(--text-muted); } gap: 0; flex-shrink: 0; } +/* One row, always. Controls that do not fit are moved into the overflow menu + * by ui-core rather than wrapping: wrapping made the header's height depend on + * the viewport width in a way that was not even monotonic (112px at 1440, + * 169px at 1100, 131px at 900), which is what made the bar feel unstable. */ .top-bar-actions { display: flex; - align-items: flex-start; - gap: 0.45rem 0.6rem; + align-items: center; + gap: 0.4rem; min-width: 0; - flex-wrap: wrap; + flex-wrap: nowrap; +} +/* Every control in the bar is the same height and none of them stretch. */ +.top-bar-actions > * { + flex: 0 0 auto; } .header-bar-btn.header-audio-btn { width: 2rem; @@ -1403,15 +1411,19 @@ small { color: var(--text-muted); } gap: 0.8rem; padding: 0.9rem 0.2rem 0; } +/* Inline, not stacked: the summary used to sit under the select, making this + * the only 54px control in a bar of 32px ones. */ .header-rig-switch { - display: grid; - grid-template-columns: minmax(0, 1fr); - align-items: start; - gap: 0.35rem; + display: flex; + align-items: center; + gap: 0.4rem; + min-width: 0; } .header-rig-summary { display: block; - max-width: 20rem; + max-width: 12rem; + flex: 0 1 auto; + min-width: 0; color: var(--text-muted); font-size: var(--fs-xs); line-height: 1.35; @@ -1531,8 +1543,18 @@ small { color: var(--text-muted); } align-items: center; gap: 0.2rem; min-width: 0; - flex-wrap: wrap; + /* Scroll rather than wrap: eight labels wrapped into a ragged block (four + * rows at 1100px) and made the header's height depend on the viewport. */ + flex-wrap: nowrap; + overflow-x: auto; + scrollbar-width: thin; } +.tab-bar-nav::-webkit-scrollbar { height: 3px; } +.tab-bar-nav::-webkit-scrollbar-thumb { + border-radius: 3px; + background: color-mix(in srgb, var(--border-light) 70%, transparent); +} +.tab-bar-nav .tab { flex: 0 0 auto; } .tab { background: transparent; border: none; @@ -2964,7 +2986,10 @@ button.is-active { text-transform: uppercase; overflow: visible; } -.operator-layout-picker::before { content: "Layout"; } +/* The prefix made the least-used control the widest in the bar; the + * select carries its own aria-label and title instead. */ +.operator-layout-picker { padding-left: 0; } +.operator-layout-picker select { max-width: 8rem; } .operator-layout-picker select { width: auto; min-height: 2rem; @@ -3282,24 +3307,26 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay { .toast-region { bottom: calc(5.7rem + env(safe-area-inset-bottom)); } .top-bar-actions { width: 100%; - justify-content: flex-start; - flex-wrap: wrap; - gap: 0.45rem; + justify-content: flex-end; + flex-wrap: nowrap; + gap: 0.4rem; } .operator-layout-picker { max-width: 100%; } .operator-layout-picker select { max-width: 10rem; } .header-rig-switch, .header-style-pick { - flex: 1 1 12rem; + flex: 0 0 auto; min-width: 0; } .header-rig-switch select, .header-style-pick select { - width: 100%; + width: auto; min-width: 0; } + /* Rig hardware/feature detail is on the left subtitle and the About tab. */ + .header-rig-summary { display: none; } .header-bar-btn { - flex: 1 1 calc(50% - 0.3rem); + flex: 0 0 auto; min-width: 0; } #tab-main, @@ -5379,3 +5406,34 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay { font-size: max(0.75rem, 12px); } } + + +/* --- Top bar overflow menu ------------------------------------------------ */ +.top-bar-more { position: relative; } +.top-bar-more[hidden] { display: none; } +.top-bar-more-btn { + width: 2rem; + height: 2rem; + min-height: 2rem; + padding: 0; + font-size: 1rem; + line-height: 1; +} +.top-bar-more-menu { + position: absolute; + top: calc(100% + 0.35rem); + right: 0; + z-index: 40; + display: none; + flex-direction: column; + gap: 0.4rem; + min-width: 12rem; + padding: 0.5rem; + border: 1px solid var(--border-light); + border-radius: var(--radius-md); + background: var(--surface); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28); +} +.top-bar-more-menu.is-open { display: flex; } +.top-bar-more-menu > * { width: 100%; } +.top-bar-more-menu select { width: 100%; max-width: none; } 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 5b660105..c1da0358 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 @@ -322,6 +322,90 @@ function elementById(id: string): T { } } + // Secondary controls, in the order they leave the bar when it gets tight. + // Audio, record and the rig picker are the operating controls and stay. + const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"]; + + function installTopBarOverflow() { + const actions = document.querySelector(".top-bar-actions"); + if (!actions || document.getElementById("top-bar-more")) return; + const wrap = document.createElement("div"); + wrap.id = "top-bar-more"; + wrap.className = "top-bar-more"; + const button = document.createElement("button"); + button.type = "button"; + button.id = "top-bar-more-btn"; + button.className = "header-bar-btn top-bar-more-btn"; + button.textContent = "⋯"; + button.setAttribute("aria-haspopup", "menu"); + button.setAttribute("aria-expanded", "false"); + button.setAttribute("aria-label", "More controls"); + button.title = "More controls"; + const menu = document.createElement("div"); + menu.id = "top-bar-more-menu"; + menu.className = "top-bar-more-menu"; + menu.setAttribute("role", "menu"); + button.setAttribute("aria-controls", menu.id); + wrap.append(button, menu); + actions.appendChild(wrap); + + const closeMenu = () => { + menu.classList.remove("is-open"); + button.setAttribute("aria-expanded", "false"); + }; + button.addEventListener("click", () => { + const open = menu.classList.toggle("is-open"); + button.setAttribute("aria-expanded", String(open)); + }); + document.addEventListener("click", (event) => { + if (!(event.target instanceof Node) || !wrap.contains(event.target)) closeMenu(); + }); + 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. + 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 reflowOverflow = () => { + overflowOrder.forEach((selector) => { + const element = menu.querySelector(selector); + if (element) actions.insertBefore(element, wrap); + }); + wrap.hidden = true; + for (const selector of overflowOrder) { + if (barFits()) break; + const element = actions.querySelector(selector); + if (!element) continue; + wrap.hidden = false; + menu.appendChild(element); + } + wrap.hidden = menu.children.length === 0; + if (wrap.hidden) closeMenu(); + }; + reflowOverflow(); + window.addEventListener("resize", reflowOverflow); + } + function installMobileMore() { const nav = document.querySelector(".tab-bar-nav"); if (!nav || document.getElementById("mobile-more-btn")) return; @@ -426,6 +510,7 @@ function elementById(id: string): T { api.init = function init() { ensureLiveRegions(); installLayoutControls(); + installTopBarOverflow(); installMobileMore(); installDecoderPicker(); installDecoderBadges();