fix(theseus/chrome): dock collapses to puzzle button on any overflow, no column

The extension row was stacking into a column when the user dragged the
URL bar wide enough to squeeze the dock's slot. Two things broken:

1) #extbuttons could wrap: added flex-wrap:nowrap + overflow:hidden so
   the buttons never break to a new line. Same for .extdock's own
   flex container (nowrap + min-width:0 so it can flex-shrink to zero).

2) Collapse was width-based on .bar only — a window wide enough for
   level 0 wouldn't collapse the dock even when the URL-bar override
   left extdock with 0 px. Added a second signal:
   data-extcollapse="1" fires when .bar's contents overflow their slot
   (bar.scrollWidth > bar.clientWidth). Applies alongside the level-3
   collapse; either path shows .extmore instead of the row.

Hysteresis to avoid RO loop: while EXPANDED, cache the row's natural
scrollWidth. While COLLAPSED, re-expand only when summing every other
.bar child leaves at least (naturalWidth + 8 px slack) of room. Both
directions verified across URL widths 400/800/1200/1500/reset — the
row expands / collapses at the right thresholds with no oscillation.
This commit is contained in:
Local Dev 2026-09-07 01:52:03 +02:00
parent 027653e9e2
commit b433dbc0b6

View file

@ -115,7 +115,14 @@
shows the panel's icon (usually a single emoji from the manifest);
click toggles the sidebar open on that panel, or collapses if it
was already the active panel. */
/* The dock and its inner buttons row never wrap — a shrunken slot makes
us collapse to the .extmore button (via data-extcollapse), it doesn't
stack the buttons vertically. inline-flex on #extbuttons keeps the
buttons on one line; overflow:hidden clips the last button just before
we swap to the collapsed dock in JS. */
#extbuttons { display: inline-flex; flex-wrap: nowrap; gap: 2px; overflow: hidden; }
.extdock { display: flex; gap: 2px; align-items: center; padding-left: 4px; margin-left: 2px;
flex-wrap: nowrap; overflow: hidden; min-width: 0;
border-left: 1px solid var(--line); }
/* inline-grid (not grid): grid defaults to block-level, which was making
every extension button take its own line inside #extbuttons; inline-grid
@ -190,9 +197,15 @@
.bar[data-responsive="3"] .urlwrap { min-width: 120px; }
/* Level 3: hide the per-extension button row, show the collapsed puzzle
button. Dropdown is a floating .extpop the JS builds on click. */
.bar[data-responsive="3"] #extbuttons { display: none; }
/* Two paths to the collapsed dock: the coarse width-based level 3
(window very narrow) AND the fine-grained data-extcollapse (dock's
own slot ran out of room even though the window is wide, e.g. the
user dragged the URL bar so wide the extensions couldn't fit). */
.bar[data-responsive="3"] #extbuttons,
.bar[data-extcollapse="1"] #extbuttons { display: none; }
.extmore { display: none; }
.bar[data-responsive="3"] .extmore { display: inline-grid; }
.bar[data-responsive="3"] .extmore,
.bar[data-extcollapse="1"] .extmore { display: inline-grid; }
.extmore { width: 30px; height: 30px; place-items: center; border-radius: 7px;
cursor: pointer; border: none; background: transparent; padding: 0;
font-size: 15px; line-height: 1; color: var(--mut); }
@ -578,42 +591,25 @@
const menu = lastMenus.find((m) => m.addonId === addonId);
if (!menu) return;
openMenuAddonId = addonId;
// Update the button's active state cheaply — no full re-render needed
// yet; the pop will be built once and outside-click will re-render.
// Tint the anchor while the menu is showing; main clears it when the
// native menu closes via the "toolbar-menu-closed" event.
const btns = extButtons.querySelectorAll(".extbtn[data-menu]");
btns.forEach((b) => b.classList.toggle("active", b.dataset.menu === addonId));
$("extmore").classList.toggle("active", true);
const rect = anchorBtn.getBoundingClientRect();
const pop = document.createElement("div");
pop.id = "tbmenu"; pop.className = "extpop";
pop.innerHTML = (menu.items || []).map((it) => {
const ic = String(it.icon || "•").replace(/</g,"&lt;");
const lbl = String(it.label || it.id).replace(/</g,"&lt;");
return `<div class="epit" data-item="${String(it.id).replace(/"/g,"&quot;")}"><span class="ic">${ic}</span><span class="lbl">${lbl}</span></div>`;
}).join("") || `<div class="epit" style="opacity:.6;cursor:default">No items</div>`;
document.body.appendChild(pop);
const pr = pop.getBoundingClientRect();
// Anchor under the button, left-aligned with the icon but kept inside
// the viewport.
pop.style.left = Math.max(4, Math.min(rect.left, window.innerWidth - pr.width - 4)) + "px";
pop.style.top = Math.round(rect.bottom + 4) + "px";
pop.querySelectorAll(".epit[data-item]").forEach((row) => {
row.onclick = () => {
const iid = row.dataset.item;
closeToolbarMenu();
if (T.addonMenuSelect) T.addonMenuSelect(addonId, iid).catch((err) => console.warn("addon-menu-select failed:", err?.message || err));
};
});
setTimeout(() => {
const off = (e) => {
if (!pop.contains(e.target) && e.target !== anchorBtn) {
closeToolbarMenu();
document.removeEventListener("mousedown", off);
}
};
document.addEventListener("mousedown", off);
}, 0);
// Hand off to a NATIVE OS menu. Renderer-DOM dropdowns get clipped by
// chrome.html's own WebContentsView height and hidden behind the tab
// view below — a native Menu.popup escapes that layering.
const r = anchorBtn.getBoundingClientRect();
const rect = { x: Math.round(r.left), y: Math.round(r.bottom + 2), width: Math.round(r.width), height: Math.round(r.height) };
if (T.toolbarMenuPopup) {
T.toolbarMenuPopup(addonId, rect).catch((err) => console.warn("toolbar-menu-popup failed:", err?.message || err));
}
}
// Main tells us when the native menu is dismissed so we can clear the
// "active" tint. `openMenuAddonId` stays set until this arrives (a click
// on the same button while the menu is up is treated as "close by
// reopening intent" by the OS anyway).
if (T.onToolbarMenuClosed) T.onToolbarMenuClosed(() => closeToolbarMenu());
T.sidebarState && T.sidebarState().then(renderSidebarState);
T.onSidebarState && T.onSidebarState(renderSidebarState);
@ -679,6 +675,54 @@
for (const b of RESPONSIVE_BREAKS) if (w >= b.w) return b.level;
return "3";
}
// Fine-grained overflow check: if the extension buttons can't fit their
// own slot in the bar, force the collapsed dock (single button + dropdown)
// regardless of window width. Fires when the user drags the URL bar wider,
// adds an extension, or a wider-icon extension gets registered.
const extButtonsEl = $("extbuttons");
const extDockEl = $("extdock");
// Cache the natural width of the buttons row while expanded. When the dock
// is currently collapsed, #extbuttons has display:none so scrollWidth is
// 0 — measuring it then would falsely decide "plenty of room" and expand,
// which would immediately overflow again → RO loop. Learn while expanded
// and trust that value while collapsed. 8px hysteresis on the re-expand
// side prevents boundary oscillation.
let naturalExtWidth = 0;
function updateExtCollapse() {
if (!extButtonsEl || !extDockEl) return;
const currentlyCollapsed = barEl.dataset.extcollapse === "1";
if (!currentlyCollapsed) {
naturalExtWidth = Math.max(naturalExtWidth, extButtonsEl.scrollWidth);
// While expanded: collapse when .bar's contents overflow their slot
// (i.e. the row + everything else doesn't fit horizontally).
if (barEl.scrollWidth > barEl.clientWidth + 2 && barEl.dataset.responsive !== "3") {
barEl.dataset.extcollapse = "1";
}
} else {
// While collapsed: re-expand only when there's demonstrably room in
// .bar for the buttons at their natural width. Sum the widths of all
// other .bar children (skipping display:none), add the collapsed
// dock's target size (natural), and compare to bar.clientWidth. 8px
// slack + require the strict "current bar isn't already overflowing"
// to keep the toggle from oscillating at the boundary.
if (barEl.scrollWidth > barEl.clientWidth + 2) return; // still cramped
const barStyle = getComputedStyle(barEl);
const gap = parseFloat(barStyle.gap) || 0;
let usedByOthers = 0;
let visibleKids = 0;
for (const k of barEl.children) {
if (getComputedStyle(k).display === "none") continue;
visibleKids += 1;
if (k === extDockEl) continue;
usedByOthers += k.getBoundingClientRect().width;
}
const gaps = gap * Math.max(0, visibleKids - 1);
// .bar has horizontal padding too — subtract it.
const padX = (parseFloat(barStyle.paddingLeft) || 0) + (parseFloat(barStyle.paddingRight) || 0);
const availableForDock = barEl.clientWidth - usedByOthers - gaps - padX;
if (availableForDock >= naturalExtWidth + 8) delete barEl.dataset.extcollapse;
}
}
const ro = new ResizeObserver((entries) => {
for (const e of entries) {
const level = pickResponsive(e.contentRect.width);
@ -688,11 +732,18 @@
if (level !== "3") closeExtPop();
}
}
// Re-check overflow every time the bar resizes — cheap.
updateExtCollapse();
});
ro.observe(barEl);
// Also observe #extbuttons directly so that a new extension being
// registered (which changes scrollWidth without a bar resize) triggers a
// re-check.
if (extButtonsEl) new ResizeObserver(updateExtCollapse).observe(extButtonsEl);
// Initial pass — RO fires on subscribe, but only after the next frame;
// set an initial value so first paint doesn't flash the wide layout.
barEl.dataset.responsive = pickResponsive(barEl.getBoundingClientRect().width);
updateExtCollapse();
// ---- favorites bar (new-tab page only) ----
let current = { url: "", title: "", favicon: null }, bookmarks = [];
@ -1228,6 +1279,16 @@
const sw = Number(s.searchBoxWidthPx) || 0;
if (sw > 0) { bar.dataset.searchwidth = "1"; bar.style.setProperty("--searchbox-w", sw + "px"); }
else { delete bar.dataset.searchwidth; bar.style.removeProperty("--searchbox-w"); }
// The URL/search width change squeezes the extension dock; the RO on
// #extbuttons doesn't always fire (overflow:hidden parent), so re-check
// overflow explicitly after the next layout pass. requestAnimationFrame
// isn't always enough (the CSS var propagation + reflow can straddle
// frames), hence the belt-and-braces setTimeout too.
if (typeof updateExtCollapse === "function") {
requestAnimationFrame(updateExtCollapse);
setTimeout(updateExtCollapse, 30);
setTimeout(updateExtCollapse, 120);
}
syncHeight();
}
T.getSettings && T.getSettings().then(applyBarSizes);
@ -1268,11 +1329,13 @@
const barEl2 = $("bar");
makeDragHandle($("urldrag"), $("bar").querySelector(".urlwrap"), {
min: 200, max: 1800, settingKey: "urlBarWidthPx", reverseX: false,
applyLive: (w) => { barEl2.dataset.urlwidth = "1"; barEl2.style.setProperty("--urlbar-w", w + "px"); },
applyLive: (w) => { barEl2.dataset.urlwidth = "1"; barEl2.style.setProperty("--urlbar-w", w + "px");
if (typeof updateExtCollapse === "function") updateExtCollapse(); },
});
makeDragHandle($("searchdrag"), $("bar").querySelector(".searchbox"), {
min: 120, max: 800, settingKey: "searchBoxWidthPx", reverseX: true,
applyLive: (w) => { barEl2.dataset.searchwidth = "1"; barEl2.style.setProperty("--searchbox-w", w + "px"); },
applyLive: (w) => { barEl2.dataset.searchwidth = "1"; barEl2.style.setProperty("--searchbox-w", w + "px");
if (typeof updateExtCollapse === "function") updateExtCollapse(); },
});
// Self-heal: some menu-close paths (drag interruptions, focus flips) can
// leave the chrome view taller than the natural body-scrollHeight. Every