feat(theseus/chrome): page zoom, 80/20 address/search ratio, collapsed dock renders icon images

- Per-tab page zoom on Chrome's ladder (25–500 %) via setZoomFactor, so
  Chromium keys it per host: every tab on a site shares the level and it
  persists across navigations and restarts. Ctrl +/=/numpad+ in,
  Ctrl -/numpad- out, Ctrl 0 reset, Ctrl+wheel via zoom-changed. A
  percentage chip appears in the address bar when a tab isn't at 100 %;
  clicking it resets. Settings and add-on tabs never zoom.
- Address bar / search bar drag ratio floor lowered from 30 % to 20 %,
  so the split runs 80/20 to 20/80 (pixel floors still apply).
- The collapsed extension-dock button and its dropdown printed a data:
  URI icon as text ("data:image/svg+xml…"). One addonIconHtml() renderer
  now serves the dock buttons, the collapsed button and the dropdown.
This commit is contained in:
Local Dev 2026-09-09 23:04:22 +02:00
parent 7b8539fb8d
commit 56eff58fda
3 changed files with 70 additions and 16 deletions

View file

@ -248,7 +248,12 @@
its width and pushed the registry button and the star out past the
bar's right edge. */
#url { flex: 1 1 0; min-width: 0; }
.secbadge, .pwchip, .star, .regbtn { flex: none; }
.secbadge, .pwchip, .star, .regbtn, .zoomchip { flex: none; }
/* Zoom indicator — only shown when the active tab isn't at 100 %.
Click resets (Ctrl+0); Ctrl +/- and Ctrl+wheel step the level. */
.zoomchip { border: none; background: transparent; color: var(--mut); cursor: pointer; padding: 2px 5px;
border-radius: 6px; font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums; }
.zoomchip:hover { background: var(--line2); color: var(--ink); }
/* Ariadne's Thread button at the END of the address bar. One icon in
place of the old BCDN/ICANN chips: tinted acid when the page is served
from BCDN, blue for ICANN, neutral otherwise. A small caret marks a
@ -410,6 +415,7 @@
</svg>
</button>
<input id="url" placeholder="Ask a search engine or enter web address" spellcheck="false">
<button class="zoomchip" id="zoomchip" title="Reset zoom (Ctrl+0)" hidden></button>
<button class="regbtn" id="reg" title="Ariadne's Thread" hidden>
<!-- Ariadne's thread: a spiral of semicircles (radii 1 → 5,
centres alternating 8 / 9) unwinding into a loose tail. -->
@ -573,6 +579,16 @@
// progress). Click a live button → open the sidebar on that panel; if
// the sidebar was already open on that panel, collapse it.
const extDock = $("extdock");
// Add-on icon → HTML. Text by default (an emoji like 🛡, or "•"). An
// add-on that wants a branded mark sets manifest.icon to a data: URI
// (svg or png) and we render it as an <img> in the same slot. Anything
// else is HTML-escaped and stays text. Used by the dock buttons, the
// collapsed dock button and the collapsed dropdown alike.
function addonIconHtml(icon, size = 18) {
const s = String(icon || "•");
if (/^data:image\//i.test(s)) return `<img src="${s.replace(/"/g,"&quot;")}" alt="" style="width:${size}px;height:${size}px;display:block;margin:0 auto">`;
return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
const extButtons = $("extbuttons");
// Flat list of dock buttons — an addon that registers a sidebar-panel and
// an addon that declares a toolbar-menu both get one entry here. panels
@ -611,13 +627,7 @@
// URI (svg or png) and we render it as an <img> instead — same size
// slot, no layout change. Anything else is HTML-escaped and stays
// text so the emoji case keeps working.
let iconHtml;
if (/^data:image\//i.test(b.icon)) {
iconHtml = `<img src="${b.icon.replace(/"/g,"&quot;")}" alt="" style="width:18px;height:18px;display:block;margin:0 auto">`;
} else {
iconHtml = b.icon.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
return `<button class="extbtn${cls}" ${dataAttr} title="${b.title.replace(/"/g,"&quot;")}">${iconHtml}</button>`;
return `<button class="extbtn${cls}" ${dataAttr} title="${b.title.replace(/"/g,"&quot;")}">${addonIconHtml(b.icon)}</button>`;
}).join("");
extButtons.querySelectorAll(".extbtn").forEach((btn) => {
btn.onclick = () => {
@ -635,7 +645,9 @@
// to 🧩 if nothing is registered). Clicking it opens a dropdown of
// every registered surface so the user can pick.
const first = lastButtons[0];
$("extmore").textContent = first ? first.icon : "🧩";
// Same renderer as the full-size buttons — a data: URI icon used to be
// dumped here as text ("data:image/svg+xml…") once the dock collapsed.
$("extmore").innerHTML = first ? addonIconHtml(first.icon) : "🧩";
$("extmore").title = first ? "Extensions — " + first.title : "Extensions";
$("extmore").classList.toggle("active", (sidebarOpen && !!activePanelId) || !!openMenuAddonId);
}
@ -697,10 +709,10 @@
const rows = [];
for (const p of lastPanels) {
const isActive = sidebarOpen && p.panelId === activePanelId;
rows.push(`<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>`);
rows.push(`<div class="epit${isActive ? " active" : ""}" data-panel="${p.panelId.replace(/"/g,"&quot;")}"><span class="ic">${addonIconHtml(p.icon, 16)}</span><span class="lbl">${(p.title || "Extension").replace(/</g,"&lt;")}</span></div>`);
}
for (const m of lastMenus) {
rows.push(`<div class="epit" data-menu="${m.addonId.replace(/"/g,"&quot;")}"><span class="ic">${String(m.icon || "•").replace(/</g,"&lt;")}</span><span class="lbl">${(m.title || m.addonId).replace(/</g,"&lt;")}</span></div>`);
rows.push(`<div class="epit" data-menu="${m.addonId.replace(/"/g,"&quot;")}"><span class="ic">${addonIconHtml(m.icon, 16)}</span><span class="lbl">${(m.title || m.addonId).replace(/</g,"&lt;")}</span></div>`);
}
pop.innerHTML = rows.join("") || `<div class="epit" style="opacity:.6;cursor:default">No extensions installed</div>`;
document.body.appendChild(pop);
@ -1122,8 +1134,11 @@
});
// ---- tabs ----
$("zoomchip").onclick = () => T.zoomReset && T.zoomReset();
T.onTabs((d) => {
$("back").disabled = !d.canBack; $("fwd").disabled = !d.canForward;
const zc = $("zoomchip"), z = Number(d.zoom) || 100;
zc.hidden = z === 100; zc.textContent = z + "%";
// loading indicator: progress bar + reload↔stop button
$("loadbar").classList.toggle("on", !!d.loading);
// Any tabs event that fires while no menu is open is a good moment to
@ -1493,12 +1508,11 @@
const urlsearchEl = $("urlsearch");
// Absolute minimums track the CSS min-widths so the drag can't push either
// bar below the size where its content stops fitting. On top of that we
// enforce a 30 % share floor for both bars: the URL bar can never take more
// than 70 % of the combined budget, and the search bar can never be less
// than 30 %. Combined with the CSS min-widths, the tighter of the two rules
// wins at any given container width.
// enforce a 20 % share floor for both bars: the ratio can run from 80/20
// to 20/80 (URL bar / search bar). Combined with the CSS min-widths, the
// tighter of the two rules wins at any given container width.
const MIN_URL_PX = 220, MIN_SEARCH_PX = 180, HANDLE_W = 6, GAP = 6;
const MIN_SHARE = 0.30;
const MIN_SHARE = 0.20;
let dragging = false, startX = 0, startW = 0, lastW = 0;
handleEl.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;

37
main.js
View file

@ -2415,9 +2415,35 @@ function emitTabs() {
loading: !!t?.loading,
canBack: wc ? wc.navigationHistory.canGoBack() : false,
canForward: wc ? wc.navigationHistory.canGoForward() : false,
zoom: zoomPercent(t),
});
}
function setLoading(tab, on) { if (tab && tab.loading !== on) { tab.loading = on; emitTabs(); } }
// ---- page zoom ----
// Chrome's preset ladder. Zoom is applied with setZoomFactor, which
// Chromium keys per host for the session — so every tab on the same site
// shares the level, and navigating back to a site restores it, exactly
// like Chrome. Settings and add-on tabs never zoom.
const ZOOM_STEPS = [25, 33, 50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300, 400, 500];
function zoomPercent(t) {
if (!t || t.settings || t.addonId) return 100;
try { return Math.round(t.view.webContents.getZoomFactor() * 100); } catch { return 100; }
}
function zoomStep(t, dir) {
if (!t || t.settings || t.addonId) return;
const cur = zoomPercent(t);
const next = dir > 0
? (ZOOM_STEPS.find((z) => z > cur) ?? ZOOM_STEPS[ZOOM_STEPS.length - 1])
: ([...ZOOM_STEPS].reverse().find((z) => z < cur) ?? ZOOM_STEPS[0]);
zoomSet(t, next);
}
function zoomSet(t, percent) {
if (!t || t.settings || t.addonId) return;
try { t.view.webContents.setZoomFactor(Math.max(25, Math.min(500, percent)) / 100); } catch {}
emitTabs();
}
ipcMain.handle("zoom-step", (_e, dir) => zoomStep(activeTab(), Number(dir) > 0 ? 1 : -1));
ipcMain.handle("zoom-reset", () => zoomSet(activeTab(), 100));
// Pull the tab's real URL from webContents after Electron navigates, so in-page
// clicks (subpages of a BCNR site, subdomain hops, cross-origin redirects) update
// the address bar. Without this, t.url is only refreshed on programmatic loads —
@ -2564,6 +2590,9 @@ function createTab(initial, opts = {}) {
});
wc.on("did-navigate", () => { refreshTabUrl(tab); emitTabs(); historyAdd(tab.url, tab.title); });
wc.on("did-navigate-in-page", () => { refreshTabUrl(tab); emitTabs(); historyAdd(tab.url, tab.title); });
// Ctrl+wheel / pinch: Chromium only reports the intent on Windows and
// Linux, the zoom itself is up to us.
wc.on("zoom-changed", (_e, dir) => zoomStep(tab, dir === "in" ? 1 : -1));
wc.on("did-start-loading", () => setLoading(tab, true));
wc.on("did-stop-loading", () => setLoading(tab, false));
// Failed loads: NAME_NOT_RESOLVED, CONNECTION_REFUSED, cert errors, etc.
@ -4831,6 +4860,14 @@ app.on("web-contents-created", (_event, wc) => {
toggleSidebar();
return e.preventDefault();
}
// Zoom: Ctrl + / Ctrl = / numpad + zoom in, Ctrl - / numpad - zoom out,
// Ctrl 0 reset. Always targets the active tab, whichever view has focus.
if (input.control && !input.alt) {
const k = input.key, code = input.code;
if (k === "+" || k === "=" || code === "NumpadAdd") { zoomStep(activeTab(), 1); return e.preventDefault(); }
if (k === "-" || k === "_" || code === "NumpadSubtract") { zoomStep(activeTab(), -1); return e.preventDefault(); }
if ((k === "0" || code === "Numpad0") && !input.shift) { zoomSet(activeTab(), 100); return e.preventDefault(); }
}
// Find-in-page: Ctrl+F opens the find bar in chrome. Chrome renderer
// owns the UI (input, next/prev, count, close); it drives the active
// tab's webContents.findInPage via IPC (find-in-page / find-stop).

View file

@ -90,6 +90,9 @@ contextBridge.exposeInMainWorld("theseus", {
// Ariadne's Thread menu behind the registry button at the end of the
// address bar (BCDN/ICANN switch, remembered choices, policy, settings).
registryMenuPopup: (rect) => ipcRenderer.invoke("registry-menu-popup", rect),
// Page zoom on the active tab: step +1 / -1 along Chrome's ladder, or reset to 100 %.
zoomStep: (dir) => ipcRenderer.invoke("zoom-step", dir),
zoomReset: () => ipcRenderer.invoke("zoom-reset"),
// Chrome listens so it can drop the "active" tint when the native menu
// dismisses (Esc, outside click, item picked — main fires all three).
onToolbarMenuClosed: (cb) => ipcRenderer.on("toolbar-menu-closed", () => cb()),