Compare commits

..
Author SHA1 Message Date
sjgandClaude Opus 5 41ddecf5a7 [fix](trx-frontend-http): label the overflow tab Tools and hide its glyph
CI / test (pull_request) Successful in 8m17s
CI / lint (push) Successful in 2m18s
CI / lint (pull_request) Successful in 2m16s
CI / frontend (pull_request) Successful in 3m6s
CI / reuse (pull_request) Successful in 3s
CI / test (push) Successful in 7m25s
CI / frontend (push) Successful in 2m11s
CI / reuse (push) Successful in 3s
The button rendered as "•••More": the dots span carried no styling at
all, so the glyph sat flush against the label instead of behaving like
the icon it is.  Every other tab hides its icon while labels are shown
and swaps to it when they are not; the dots now follow the same rule, so
the button reads "Tools" beside the other labels and becomes the glyph
alone in the icon band.

"More" also said nothing about the destinations behind it.  The menu
holds Statistics, Recorder, Settings and About, so name it Tools and give
the button an aria-label that spells that out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 23:36:41 +02:00
sjgandClaude Opus 5 b06c37affa [fix](trx-frontend-http): lift the header menus out of the header
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 8m21s
CI / frontend (pull_request) Successful in 3m5s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m36s
CI / frontend (push) Successful in 2m9s
CI / reuse (push) Successful in 3s
Fixed positioning escaped the clipping, but not the stacking: the header
carries `z-index: 2`, which makes it a stacking context, so whatever
z-index a menu inside it carries is confined below level 2.  The spectrum
overlays paint as high as 9600, so both dropdowns opened underneath them.

Reparent each menu to the body when it opens.  Leaving that subtree is
the only way out of an ancestor's stacking context, and the menus are
already positioned in viewport coordinates, so nothing else about them
changes.  The outside-click test now considers the menu as well as its
wrapper, since the two are no longer nested.

Verified by hit testing rather than by inspecting z-index:
elementFromPoint at the open menu's centre returns the menu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 23:28:39 +02:00
sjgandClaude Opus 5 b4912f5879 [fix](trx-frontend-http): render the header menus above the page
CI / lint (push) Successful in 2m17s
CI / test (push) Successful in 7m23s
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 8m13s
CI / frontend (pull_request) Successful in 2m59s
CI / reuse (pull_request) Successful in 3s
CI / frontend (push) Successful in 2m10s
CI / reuse (push) Successful in 3s
Both header dropdowns were laid out inside the bar rather than over the
page.  The navigation menu opened as an 18px sliver positioned above its
own button, and the overflow menu did not appear at all.

Two causes.  The tab strip kept `overflow-x: auto` from when it scrolled,
which clips an absolutely positioned descendant — and the strip is what
the navigation menu anchors to.  The strip no longer scrolls, since the
occasional destinations moved behind More, so the property and the edge
fade that went with it are both gone.

Anchoring in fixed coordinates at open time addresses the general case:
an absolutely positioned menu is clipped by any scrolling ancestor and
trapped inside whatever stacking context its ancestors create, so it can
be squashed inside the bar or painted underneath page content.  Fixed
coordinates answer to the viewport, and the menu flips above its button
near the bottom edge.

Clearing `right` when setting `left` keeps the menus at their natural
width: the stylesheet pins them to the right of their anchor, and leaving
that in place stretched them across the bar — 845px for a four-item list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 19:18:47 +02:00
3 changed files with 79 additions and 25 deletions
@@ -748,6 +748,25 @@ function elementById(id) {
}
}
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
function anchorMenu(button, menu) {
const rect = button.getBoundingClientRect();
if (menu.parentElement !== document.body) document.body.appendChild(menu);
menu.style.position = "fixed";
menu.style.zIndex = "9700";
menu.style.top = "0px";
menu.style.bottom = "auto";
menu.style.right = "auto";
const width = menu.offsetWidth || 200;
const height = menu.offsetHeight || 0;
menu.style.left = `${String(Math.round(Math.min(Math.max(8, rect.right - width), window.innerWidth - width - 8)))}px`;
const below = rect.bottom + 8;
if (below + height <= window.innerHeight - 8) {
menu.style.top = `${String(Math.round(below))}px`;
} else {
menu.style.top = "auto";
menu.style.bottom = `${String(Math.round(window.innerHeight - rect.top + 8))}px`;
}
}
function installTopBarOverflow() {
const actions = document.querySelector(".top-bar-actions");
if (!actions || document.getElementById("top-bar-more")) return;
@@ -777,9 +796,14 @@ function elementById(id) {
button.addEventListener("click", () => {
const open = menu.classList.toggle("is-open");
button.setAttribute("aria-expanded", String(open));
if (open) anchorMenu(button, menu);
});
document.addEventListener("click", (event) => {
if (!(event.target instanceof Node) || !wrap.contains(event.target)) closeMenu();
if (!(event.target instanceof Node)) {
closeMenu();
return;
}
if (!wrap.contains(event.target) && !menu.contains(event.target)) closeMenu();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeMenu();
@@ -819,7 +843,8 @@ function elementById(id) {
more.id = "mobile-more-btn";
more.className = "tab mobile-more-btn";
more.type = "button";
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">More</span>';
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">Tools</span>';
more.setAttribute("aria-label", "Tools and settings");
more.setAttribute("aria-haspopup", "menu");
more.setAttribute("aria-expanded", "false");
const menu = document.createElement("div");
@@ -851,7 +876,10 @@ function elementById(id) {
more.addEventListener("click", () => {
const open = menu.classList.toggle("is-open");
more.setAttribute("aria-expanded", String(open));
if (open) menu.querySelector('[role="menuitem"]')?.focus();
if (open) {
anchorMenu(more, menu);
menu.querySelector('[role="menuitem"]')?.focus();
}
});
document.addEventListener("click", (event) => {
if (!(event.target instanceof Node) || !menu.contains(event.target) && !more.contains(event.target)) closeMore();
@@ -1531,31 +1531,17 @@ small { color: var(--text-muted); }
align-items: center;
gap: 0.2rem;
min-width: 0;
/* 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. */
/* Never wraps and never scrolls: the occasional destinations live behind
* More, so four tabs plus More always fit. overflow-x here would clip the
* dropdown this element anchors. */
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: thin;
}
/* Fade the trailing edge so a tab scrolled out of view reads as "there is more"
* rather than as a label cut mid-word. A mask rather than a colour-matched
* cover gradient: the card is transparent, so a cover would have to track the
* page background across both themes and all nine styles. Only the trailing
* edge is faded, so the first tab stays crisp when the strip is at rest. */
.tab-bar-nav {
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 2rem), transparent 100%);
mask-image: linear-gradient(to right, #000 calc(100% - 2rem), transparent 100%);
}
.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; }
/* Icons before scrolling: every tab already carries one, and four icons plus
* More always fit, so the strip never has to hide a destination. */
@media (max-width: 1180px) and (min-width: 701px) {
.tab-bar-nav .tab .tab-label { display: none; }
.tab-bar-nav .tab .tab-icon, .tab-bar-nav .tab .tab-more-icon { display: block; }
.tab-bar-nav .tab { padding: 0.5rem 0.6rem; }
}
.tab {
@@ -1579,6 +1565,7 @@ small { color: var(--text-muted); }
flex-shrink: 0;
}
.tab-label { display: block; }
.tab-more-icon { display: none; }
.about-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
.about-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 0.5rem; padding: 0; overflow: hidden; }
.about-card-title { display: flex; align-items: center; gap: 0.5rem; padding: 0.6rem 0.75rem; font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-heading); border-bottom: 1px solid var(--border); background: color-mix(in srgb, var(--card-bg) 50%, var(--bg)); }
@@ -3292,7 +3279,7 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
color: var(--text);
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
}
.tab-icon { display: block; }
.tab-icon, .tab-more-icon { display: block; }
/* Shorten long tab labels to keep bottom nav compact */
.tab[data-tab="bookmarks"] .tab-label { font-size: 0.6rem; }
.tab[data-tab="digital-modes"] .tab-label { font-size: 0.6rem; }
@@ -326,6 +326,39 @@ function elementById<T extends HTMLElement>(id: string): T {
// Audio, record and the rig picker are the operating controls and stay.
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
// Anchored at paint time in fixed coordinates. An absolutely positioned
// dropdown is clipped by any scrolling ancestor and trapped inside the
// stacking context its ancestors create, so it can end up squashed inside
// the bar or painted underneath page content. Fixed positioning answers to
// the viewport instead, and the menu flips above its button near the bottom
// edge.
function anchorMenu(button: HTMLElement, menu: HTMLElement) {
const rect = button.getBoundingClientRect();
// Reparent to the body. Fixed positioning escapes clipping but not
// stacking: the header sets `z-index: 2`, which makes it a stacking
// context, so any z-index the menu carries is confined below every panel
// that paints above level 2 — the spectrum overlays reach 9600. Only
// leaving that subtree lets the menu sit above the page.
if (menu.parentElement !== document.body) document.body.appendChild(menu);
menu.style.position = "fixed";
menu.style.zIndex = "9700";
menu.style.top = "0px";
menu.style.bottom = "auto";
// The stylesheet pins these menus to the right of their anchor; leaving
// that in place while setting a left would stretch them across the bar.
menu.style.right = "auto";
const width = menu.offsetWidth || 200;
const height = menu.offsetHeight || 0;
menu.style.left = `${String(Math.round(Math.min(Math.max(8, rect.right - width), window.innerWidth - width - 8)))}px`;
const below = rect.bottom + 8;
if (below + height <= window.innerHeight - 8) {
menu.style.top = `${String(Math.round(below))}px`;
} else {
menu.style.top = "auto";
menu.style.bottom = `${String(Math.round(window.innerHeight - rect.top + 8))}px`;
}
}
function installTopBarOverflow() {
const actions = document.querySelector<HTMLElement>(".top-bar-actions");
if (!actions || document.getElementById("top-bar-more")) return;
@@ -356,9 +389,11 @@ function elementById<T extends HTMLElement>(id: string): T {
button.addEventListener("click", () => {
const open = menu.classList.toggle("is-open");
button.setAttribute("aria-expanded", String(open));
if (open) anchorMenu(button, menu);
});
document.addEventListener("click", (event) => {
if (!(event.target instanceof Node) || !wrap.contains(event.target)) closeMenu();
if (!(event.target instanceof Node)) { closeMenu(); return; }
if (!wrap.contains(event.target) && !menu.contains(event.target)) closeMenu();
});
document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeMenu(); });
@@ -413,7 +448,8 @@ function elementById<T extends HTMLElement>(id: string): T {
more.id = "mobile-more-btn";
more.className = "tab mobile-more-btn";
more.type = "button";
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">More</span>';
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">Tools</span>';
more.setAttribute("aria-label", "Tools and settings");
more.setAttribute("aria-haspopup", "menu");
more.setAttribute("aria-expanded", "false");
const menu = document.createElement("div");
@@ -445,7 +481,10 @@ function elementById<T extends HTMLElement>(id: string): T {
more.addEventListener("click", () => {
const open = menu.classList.toggle("is-open");
more.setAttribute("aria-expanded", String(open));
if (open) menu.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
if (open) {
anchorMenu(more, menu);
menu.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
}
});
document.addEventListener("click", (event) => {
if (!(event.target instanceof Node) || (!menu.contains(event.target) && !more.contains(event.target))) closeMore();