Compare commits

..
Author SHA1 Message Date
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 58 additions and 20 deletions
@@ -748,6 +748,24 @@ function elementById(id) {
} }
} }
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"]; const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
function anchorMenu(button, menu) {
const rect = button.getBoundingClientRect();
menu.style.position = "fixed";
menu.style.zIndex = "200";
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() { function installTopBarOverflow() {
const actions = document.querySelector(".top-bar-actions"); const actions = document.querySelector(".top-bar-actions");
if (!actions || document.getElementById("top-bar-more")) return; if (!actions || document.getElementById("top-bar-more")) return;
@@ -777,6 +795,7 @@ function elementById(id) {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const open = menu.classList.toggle("is-open"); const open = menu.classList.toggle("is-open");
button.setAttribute("aria-expanded", String(open)); button.setAttribute("aria-expanded", String(open));
if (open) anchorMenu(button, menu);
}); });
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
if (!(event.target instanceof Node) || !wrap.contains(event.target)) closeMenu(); if (!(event.target instanceof Node) || !wrap.contains(event.target)) closeMenu();
@@ -851,7 +870,10 @@ function elementById(id) {
more.addEventListener("click", () => { more.addEventListener("click", () => {
const open = menu.classList.toggle("is-open"); const open = menu.classList.toggle("is-open");
more.setAttribute("aria-expanded", String(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) => { document.addEventListener("click", (event) => {
if (!(event.target instanceof Node) || !menu.contains(event.target) && !more.contains(event.target)) closeMore(); if (!(event.target instanceof Node) || !menu.contains(event.target) && !more.contains(event.target)) closeMore();
@@ -1531,25 +1531,10 @@ small { color: var(--text-muted); }
align-items: center; align-items: center;
gap: 0.2rem; gap: 0.2rem;
min-width: 0; min-width: 0;
/* Scroll rather than wrap: eight labels wrapped into a ragged block (four /* Never wraps and never scrolls: the occasional destinations live behind
* rows at 1100px) and made the header's height depend on the viewport. */ * More, so four tabs plus More always fit. overflow-x here would clip the
* dropdown this element anchors. */
flex-wrap: nowrap; 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; } .tab-bar-nav .tab { flex: 0 0 auto; }
/* Icons before scrolling: every tab already carries one, and four icons plus /* Icons before scrolling: every tab already carries one, and four icons plus
@@ -326,6 +326,33 @@ function elementById<T extends HTMLElement>(id: string): T {
// Audio, record and the rig picker are the operating controls and stay. // Audio, record and the rig picker are the operating controls and stay.
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"]; 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();
menu.style.position = "fixed";
menu.style.zIndex = "200";
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() { function installTopBarOverflow() {
const actions = document.querySelector<HTMLElement>(".top-bar-actions"); const actions = document.querySelector<HTMLElement>(".top-bar-actions");
if (!actions || document.getElementById("top-bar-more")) return; if (!actions || document.getElementById("top-bar-more")) return;
@@ -356,6 +383,7 @@ function elementById<T extends HTMLElement>(id: string): T {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const open = menu.classList.toggle("is-open"); const open = menu.classList.toggle("is-open");
button.setAttribute("aria-expanded", String(open)); button.setAttribute("aria-expanded", String(open));
if (open) anchorMenu(button, menu);
}); });
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
if (!(event.target instanceof Node) || !wrap.contains(event.target)) closeMenu(); if (!(event.target instanceof Node) || !wrap.contains(event.target)) closeMenu();
@@ -445,7 +473,10 @@ function elementById<T extends HTMLElement>(id: string): T {
more.addEventListener("click", () => { more.addEventListener("click", () => {
const open = menu.classList.toggle("is-open"); const open = menu.classList.toggle("is-open");
more.setAttribute("aria-expanded", String(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) => { document.addEventListener("click", (event) => {
if (!(event.target instanceof Node) || (!menu.contains(event.target) && !more.contains(event.target))) closeMore(); if (!(event.target instanceof Node) || (!menu.contains(event.target) && !more.contains(event.target))) closeMore();