fix(theseus): tab context menu goes native + bigger uniform bookmark chips with title-only labels

Two visible fixes from the same 2026-09-09 screenshot:

Right-click a tab was building a DOM menu and then growing the chrome
view height so it would fit under the tabstrip. That opened a
visible gap between the toolbar and the tab body while the menu was
up. Now the tab context menu goes through a new IPC
"tab-context-menu-popup" (main.js) that pops an OS-native Menu at
the click point, floating above every WebContentsView — no layout
change, no gap. Preload exposes tabContextMenuPopup(id, {x, y});
chrome.html's tab contextmenu handler now calls it directly and the
DOM openTabContextMenu / openGroupSubmenu / growChromeForMenu path
is bypassed for tabs. (The bookmark bar's own ctxmenu still uses
the DOM path — its short 2-3-row menus don't grow chrome enough
to be visible.)

Bookmark chips were too small (130px max-width, 11px text, 22px row).
Now every chip is a fixed 150px × 28px cell so the row reads as a
uniform grid, 12.5px text, 14px favicon. Labels drop the descriptor:
"GAME.X — Bitcoin Cash game platform" renders as "GAME.X". The
trimmer splits on the first em-dash / en-dash / hyphen that's
surrounded by whitespace, so single-word titles and hyphenated
compound names ("Foo-Bar" with no spaces) come through intact. Full
title still shows on hover.
This commit is contained in:
Local Dev 2026-09-09 02:05:46 +02:00
parent e0c829dd10
commit 11ffb91ad6
4 changed files with 125 additions and 17 deletions

View file

@ -297,10 +297,10 @@
padding: 6px 12px; border: 1px solid color-mix(in srgb, var(--acid) 20%, transparent); border-radius: 8px; cursor: pointer; background: rgb(from var(--acid) r g b / .08); font-size: 13px; }
.logo:hover { background: rgb(from var(--acid) r g b / .16); }
.logo .gear { font-size: 12px; opacity: .8; }
/* favorites bar — new-tab page only, single compact row. Chips are
tight so many fit in the row; the whole strip is 22px tall (down
from 26) so the chrome view gains a bit of vertical space. */
.bookmarks { display: flex; align-items: center; gap: 3px; padding: 2px 10px 4px; height: 22px; overflow: hidden; }
/* favorites bar — new-tab page only, single compact row. Chips are a
uniform fixed width so the row reads as a neat grid regardless of the
underlying title length; the strip is 28px tall to fit the roomier chips. */
.bookmarks { display: flex; align-items: center; gap: 6px; padding: 4px 10px 6px; height: 28px; overflow: hidden; }
/* Ctrl+F find-in-page bar. Sits below the address bar (above tabs body),
~32px tall so it's part of the chrome view height like .tordisc / .bcnrbar.
syncHeight in the tabs handler picks it up automatically. */
@ -313,14 +313,17 @@
.findbar .findbtn { border: 1px solid var(--line); background: var(--surface2); color: var(--ink); border-radius: 6px;
padding: 3px 8px; font-size: 13px; cursor: pointer; }
.findbar .findbtn:hover { background: var(--surface); border-color: color-mix(in srgb, var(--acid) 35%, transparent); }
.bm { display: flex; align-items: center; gap: 5px; max-width: 130px; padding: 2px 7px; border-radius: 5px;
background: var(--surface2); border: 1px solid var(--hover); color: var(--mut); font-size: 11px; cursor: grab; white-space: nowrap; }
/* Fixed width (not max-width) so every chip is the same size — the row
reads as a grid. Title-only labels keep this width workable; renderer
trims descriptors after " — ". */
.bm { display: flex; align-items: center; gap: 6px; width: 150px; flex: 0 0 150px; padding: 4px 10px; border-radius: 6px;
background: var(--surface2); border: 1px solid var(--hover); color: var(--mut); font-size: 12.5px; cursor: grab; white-space: nowrap; }
.bm:hover { background: var(--active); color: var(--ink); }
.bm.dragging { opacity: .45; cursor: grabbing; }
.bm.dropbefore { box-shadow: -2px 0 0 var(--acid); }
.bm.dropafter { box-shadow: 2px 0 0 var(--acid); }
.bm .bmfav { width: 12px; height: 12px; flex: none; object-fit: contain; border-radius: 2px; }
.bm .bt { overflow: hidden; text-overflow: ellipsis; max-width: 96px; }
.bm .bmfav { width: 14px; height: 14px; flex: none; object-fit: contain; border-radius: 2px; }
.bm .bt { overflow: hidden; text-overflow: ellipsis; flex: 1; }
/* In-chrome prompt modal (window.prompt is disabled in Electron BrowserViews,
so bookmark rename etc. use this instead). */
.promptmask { position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 10000;
@ -802,6 +805,17 @@
// ---- favorites bar (new-tab page only) ----
let current = { url: "", title: "", favicon: null }, bookmarks = [];
// Site titles often end with a descriptor after " — " or " - ", e.g.
// "GAME.X — Bitcoin Cash game platform". The chip only has room for the
// leading brand token, so trim at the first em-dash / en-dash / hyphen
// that's surrounded by whitespace. The full title still shows on hover
// (tooltip below) so the descriptor isn't actually lost.
function bookmarkShortLabel(title, url) {
const src = (title || url || "").trim();
if (!src) return "";
const cut = src.split(/\s+[—–-]\s+/, 1)[0];
return cut || src;
}
function renderBookmarks() {
const box = $("bookmarks");
box.innerHTML = bookmarks.length
@ -809,7 +823,8 @@
const fav = b.favicon
? `<img class="bmfav" src="${String(b.favicon).replace(/"/g,"&quot;")}" onerror="this.remove()">`
: `<span class="bmfav" style="display:inline-block"></span>`;
return `<div class="bm" draggable="true" data-i="${i}" data-url="${(b.url||"").replace(/"/g,"&quot;")}" title="${((b.title||b.url)+" — "+(b.url||"")).replace(/"/g,"&quot;")}">${fav}<span class="bt">${(b.title||b.url).replace(/</g,"&lt;")}</span><span class="bx" data-x="${i}">✕</span></div>`;
const short = bookmarkShortLabel(b.title, b.url);
return `<div class="bm" draggable="true" data-i="${i}" data-url="${(b.url||"").replace(/"/g,"&quot;")}" title="${((b.title||b.url)+" — "+(b.url||"")).replace(/"/g,"&quot;")}">${fav}<span class="bt">${short.replace(/</g,"&lt;")}</span><span class="bx" data-x="${i}">✕</span></div>`;
}).join("")
: `<span class="bm-empty">No saved pages yet — click ☆ or right-click here to add one.</span>`;
box.querySelectorAll(".bm").forEach((el) => el.onclick = (e) => {
@ -1161,14 +1176,16 @@
if (e.target.dataset.close) T.closeTab(Number(e.target.dataset.close));
else T.switchTab(Number(el.dataset.id));
});
// Right-click a tab → floating menu (Reload / Duplicate / Group / Add to
// Bookmarks / Mute / Close). Menu closes on any other click.
// Right-click a tab → native OS menu (Reload / Duplicate / Group / Add
// to Bookmarks / Mute / Close), popped from main.js. Doing this in the
// DOM used to force the chrome view taller than its natural height so
// the menu was visible below the tabstrip — that opened a visible gap
// between the toolbar and the tab body. A native Menu.popup sits above
// every WebContentsView so nothing has to grow.
box.querySelectorAll(".tab").forEach((el) => el.addEventListener("contextmenu", (e) => {
e.preventDefault();
const id = Number(el.dataset.id);
const t = d.tabs.find((x) => x.id === id);
if (!t) return;
openTabContextMenu(e.clientX, e.clientY, t);
if (T.tabContextMenuPopup) T.tabContextMenuPopup(id, { x: e.clientX, y: e.clientY });
}));
// Drag-reorder — HTML5 drag events. Drop-side chosen by whether the pointer
// is on the left or right half of the target tab (matches Chrome UX).

94
main.js
View file

@ -1521,6 +1521,19 @@ function initAddons() {
hostRequire: (name) => require(name),
hostImport: (name) => import(require("node:url").pathToFileURL(require.resolve(name)).href),
openTab: (url) => { if (win) createTab(url); },
// openSettings: routes through the existing "open-settings" IPC handler
// so the same section-hint validation applies. Add-ons hit this when they
// want to point users at Passwords, Extensions, etc.
openSettings: (section) => {
const slug = typeof section === "string" && /^[a-z0-9-]{1,32}$/i.test(section) ? section.toLowerCase() : "";
const ex = tabs.find((t) => t.settings);
if (ex) {
setActive(ex.id);
if (slug) { try { ex.view.webContents.send("focus-section", slug); } catch {} }
return;
}
createTab(null, { settings: true, settingsSection: slug });
},
// open-tab (addon-file variant): open one of the add-on's OWN files in a
// full tab. The path is joined against the resolved add-on folder and
// rejected if the result escapes it — belt-and-braces with the sanity
@ -2429,7 +2442,8 @@ function createTab(initial, opts = {}) {
else setActive(id);
if (opts.settings) {
tab.prov = { host: "", kind: "home" };
wc.loadFile("settings.html");
const settingsOpts = opts.settingsSection ? { hash: opts.settingsSection } : undefined;
wc.loadFile("settings.html", settingsOpts);
if (id === activeId) pushNav(tab.prov);
emitTabs();
} else if (opts.addonFile) {
@ -2725,6 +2739,70 @@ ipcMain.handle("tab-bookmark", (_e, id) => {
saveBookmarks(); emitBookmarks();
return true;
});
// Native tab context-menu popup. Anchored at the (chrome-view-relative)
// point the renderer sends. Prevents the visible "chrome view expands to
// hold a DOM menu" gap between the tabs and the tab content — the OS-owned
// popup floats above every WebContentsView and doesn't affect layout at
// all. Mirrors the DOM-menu shape: Reload / Duplicate / Group (submenu) /
// Add to Bookmarks / Mute / Close.
ipcMain.handle("tab-context-menu-popup", (e, tabId, rect) => {
if (chrome && e.sender !== chrome.webContents) throw new Error("tab-context-menu-popup: untrusted sender");
const t = tabById(tabId);
if (!t) return false;
const colorLabels = [
{ id: "red", label: "Red" }, { id: "orange", label: "Orange" },
{ id: "yellow", label: "Yellow" }, { id: "green", label: "Green" },
{ id: "cyan", label: "Cyan" }, { id: "blue", label: "Blue" },
{ id: "purple", label: "Purple" },
];
const groupSubmenu = [
{ label: "None" + (t.group ? "" : " ✓"), click: () => { t.group = null; emitTabs(); } },
{ type: "separator" },
...colorLabels.map((c) => ({
label: c.label + (t.group === c.id ? " ✓" : ""),
click: () => {
t.group = c.id;
// Cluster tabs of the same colour, matching the "tab-group" IPC.
const idx = tabs.indexOf(t);
let insertAfter = -1;
for (let i = 0; i < tabs.length; i++) {
if (i !== idx && tabs[i].group === c.id) insertAfter = i;
}
if (insertAfter !== -1) {
tabs.splice(idx, 1);
const dst = insertAfter > idx ? insertAfter : insertAfter + 1;
tabs.splice(dst, 0, t);
}
emitTabs();
},
})),
];
const bmDisabled = !t.url;
const template = [
{ label: "Reload", click: () => { try { t.view.webContents.reload(); } catch {} } },
{ label: "Duplicate", click: () => { if (t.url) createTab(t.url); else createTab(); } },
{ label: "Group", submenu: groupSubmenu },
{ label: "Add to Bookmarks", enabled: !bmDisabled, click: () => {
if (bmDisabled) return;
const url = t.url, title = t.title || url;
if (!bookmarks.some((b) => b.url === url)) {
bookmarks.unshift({ url, title, addedAt: Date.now() });
saveBookmarks(); emitBookmarks();
}
} },
{ label: t.muted ? "Unmute" : "Mute", click: () => {
try { t.view.webContents.setAudioMuted(!t.muted); t.muted = !t.muted; emitTabs(); } catch {}
} },
{ type: "separator" },
{ label: "Close", click: () => closeTab(t.id) },
];
const popup = Menu.buildFromTemplate(template);
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };
const x = Math.max(0, Math.round(chromeBounds.x + (rect?.x || 0)));
const y = Math.max(0, Math.round(chromeBounds.y + (rect?.y || 0)));
popup.popup({ window: win, x, y });
return true;
});
ipcMain.handle("move-tab", (_e, id, targetId, place) => {
const src = tabs.findIndex((t) => t.id === id);
const dst = tabs.findIndex((t) => t.id === targetId);
@ -3268,7 +3346,19 @@ ipcMain.handle("reload", (_e, hard) => {
});
ipcMain.handle("stop", () => { const t = activeTab(); try { t?.view.webContents.stop(); } catch {} setLoading(t, false); });
ipcMain.handle("toggle-tor", () => { torState === "off" ? startTor() : stopTor(); });
ipcMain.handle("open-settings", () => { const ex = tabs.find((t) => t.settings); if (ex) return setActive(ex.id); createTab(null, { settings: true }); });
ipcMain.handle("open-settings", (_e, section) => {
// Optional section slug ("passwords", "addons", …) lands directly on
// that sidebar entry when the page loads. settings.html watches for a
// fragment on load and an IPC message when already loaded.
const slug = typeof section === "string" && /^[a-z0-9-]{1,32}$/i.test(section) ? section.toLowerCase() : "";
const ex = tabs.find((t) => t.settings);
if (ex) {
setActive(ex.id);
if (slug) { try { ex.view.webContents.send("focus-section", slug); } catch {} }
return;
}
createTab(null, { settings: true, settingsSection: slug });
});
ipcMain.handle("toggle-site-info", (_e, rect) => {
if (popVisible) return showPopover(false);
if (rect) popPos = { x: Math.round(rect.x), y: Math.round(rect.y) };

View file

@ -1,6 +1,6 @@
{
"name": "theseus-navigator",
"version": "0.3.40",
"version": "0.3.41",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode",
"main": "main.js",

View file

@ -86,6 +86,7 @@ contextBridge.exposeInMainWorld("theseus", {
// then covered by the tab view below it — a native Menu.popup escapes that
// layering entirely. Pass the button's viewport-rect so main can anchor.
toolbarMenuPopup: (addonId, rect) => ipcRenderer.invoke("toolbar-menu-popup", addonId, rect),
tabContextMenuPopup: (tabId, rect) => ipcRenderer.invoke("tab-context-menu-popup", tabId, rect),
// 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()),