feat(theseus/chrome): adaptive toolbar — drops search, collapses Theseus, folds extension dock

A ResizeObserver on .bar sets data-responsive to one of four levels
based on width, and CSS reacts:

  0 (>=1050px) wide     — everything visible (default)
  1 ( >=820px) tight    — auto-hide the search box, URL min-width drops
  2 ( >=620px) narrow   — Theseus button collapses to just the gear
  3 (      <) xnarrow   — extension dock collapses to a single 🧩 button
                          that opens a dropdown listing every registered
                          panel (icon + name), click to open the sidebar

Puzzle dropdown reuses the same open/close pattern as the existing
bookmarks menu — click outside to dismiss, click a row to open (or
close if it's already the active panel). User's explicit
Settings > Address bar size / Search box size still win when the
toolbar is wide enough for them; responsive collapse only forces
extra hides at the tighter widths.

Verified on a running instance across 5 widths (1200/900/700/500/400):
each breakpoint flips exactly the elements it should.
This commit is contained in:
Local Dev 2026-09-06 21:45:43 +02:00
parent 354606f3c1
commit f36e1db17f

View file

@ -154,6 +154,44 @@
.bar[data-searchsize="hidden"] .searchbox { display: none; }
.bar[data-searchsize="compact"] .searchbox { width: 180px; }
.bar[data-searchsize="wide"] .searchbox { width: 400px; }
/* Adaptive toolbar. A ResizeObserver on .bar sets data-responsive:
"0" wide — everything visible (default)
"1" tight — auto-hide the search box, min-width for URL bar drops
"2" narrow — Theseus button collapses to just the gear icon
"3" xnarrow — extension dock collapses to a single 🧩 puzzle button
that opens a dropdown of every registered addon panel
User's explicit data-searchsize wins over auto-hide at level 1: if the
user picked "wide" or "normal" they still get it, but "normal" at very
narrow widths yields to the responsive collapse. */
.bar[data-responsive="1"] .searchbox,
.bar[data-responsive="2"] .searchbox,
.bar[data-responsive="3"] .searchbox { display: none; }
.bar[data-responsive="2"] .logo span:not(.gear),
.bar[data-responsive="3"] .logo span:not(.gear) { display: none; }
.bar[data-responsive="2"] .logo,
.bar[data-responsive="3"] .logo { padding: 4px 8px; }
.bar[data-responsive="1"] .urlwrap,
.bar[data-responsive="2"] .urlwrap,
.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; }
.extmore { display: none; }
.bar[data-responsive="3"] .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); }
.extmore:hover { background: var(--line2); color: var(--ink); }
.extmore.active { background: rgba(214,255,61,.12); color: var(--acid); }
.extpop { position: fixed; z-index: 9999; background: var(--surface, #1c222c);
border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 12px 34px #000c;
padding: 4px; min-width: 180px; }
.extpop .epit { display: flex; align-items: center; gap: 10px; padding: 7px 10px;
border-radius: 6px; cursor: pointer; font-size: 13px; color: var(--ink); }
.extpop .epit:hover { background: var(--hover, #ffffff10); }
.extpop .epit.active { background: rgba(214,255,61,.10); color: var(--acid); }
.extpop .epit .ic { width: 16px; text-align: center; font-size: 15px; line-height: 1; }
.extpop .epit .lbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.urlwrap:focus-within { border-color: #4b7bec; }
/* Site security shield — always present. Colour communicates state:
neutral (default) for home / resolving, green when the connection is
@ -312,6 +350,7 @@
</button>
<span class="extdock" id="extdock" hidden>
<span id="extbuttons"></span>
<button class="extmore" id="extmore" title="Extensions" aria-label="Extensions">🧩</button>
</span>
<span class="upchip" id="upchip" hidden><button class="upcore" id="upDownload" title=""></button><button class="updismiss" id="upDismiss" title="Not now"></button></span>
<button class="tor" id="tor" title="Route traffic through Tor">🧅 Tor: Off</button>
@ -468,10 +507,72 @@
else { T.openSidebar && T.openSidebar(pid); }
};
});
// Reflect active state on the collapsed .extmore button too.
$("extmore").classList.toggle("active", sidebarOpen && !!activePanelId);
}
T.sidebarState && T.sidebarState().then(renderSidebarState);
T.onSidebarState && T.onSidebarState(renderSidebarState);
// Collapsed extensions dropdown — opens under the .extmore button at
// responsive level 3, lists every registered panel with icon + name.
function closeExtPop() { const p = document.getElementById("extpop"); if (p) p.remove(); }
$("extmore").onclick = (ev) => {
ev.stopPropagation();
if (document.getElementById("extpop")) return closeExtPop();
const rect = $("extmore").getBoundingClientRect();
const pop = document.createElement("div");
pop.id = "extpop"; pop.className = "extpop";
pop.innerHTML = lastPanels.map((p) => {
const isActive = sidebarOpen && p.panelId === activePanelId;
return `<div class="epit${isActive ? " active" : ""}" data-panel="${p.panelId.replace(/"/g,"&quot;")}"><span class="ic">${String(p.icon || "•").replace(/</g,"&lt;")}</span><span class="lbl">${(p.title || "Extension").replace(/</g,"&lt;")}</span></div>`;
}).join("") || `<div class="epit" style="opacity:.6;cursor:default">No extensions installed</div>`;
document.body.appendChild(pop);
const r = pop.getBoundingClientRect();
pop.style.left = Math.max(4, Math.min(rect.left, window.innerWidth - r.width - 4)) + "px";
pop.style.top = Math.round(rect.bottom + 4) + "px";
pop.querySelectorAll(".epit[data-panel]").forEach((row) => {
row.onclick = () => {
const pid = row.dataset.panel;
closeExtPop();
if (sidebarOpen && pid === activePanelId) { T.closeSidebar && T.closeSidebar(); }
else { T.openSidebar && T.openSidebar(pid); }
};
});
setTimeout(() => {
const off = (e) => { if (!pop.contains(e.target) && e.target !== $("extmore")) { closeExtPop(); document.removeEventListener("mousedown", off); } };
document.addEventListener("mousedown", off);
}, 0);
};
// Responsive breakpoints on .bar width. ResizeObserver reacts to window
// resize, sidebar toggle, and any chrome-height change — all cheap since
// we only touch a single dataset attribute if the level changes.
const barEl = $("bar");
const RESPONSIVE_BREAKS = [
{ w: 1050, level: "0" }, // wide
{ w: 820, level: "1" }, // hide search
{ w: 620, level: "2" }, // theseus icon-only
{ w: 0, level: "3" }, // extensions → dropdown
];
function pickResponsive(w) {
for (const b of RESPONSIVE_BREAKS) if (w >= b.w) return b.level;
return "3";
}
const ro = new ResizeObserver((entries) => {
for (const e of entries) {
const level = pickResponsive(e.contentRect.width);
if (barEl.dataset.responsive !== level) {
barEl.dataset.responsive = level;
// Close the extmore dropdown if we shrank/grew across level 3.
if (level !== "3") closeExtPop();
}
}
});
ro.observe(barEl);
// 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);
// ---- favorites bar (new-tab page only) ----
let current = { url: "", title: "", favicon: null }, bookmarks = [];
function renderBookmarks() {