diff --git a/chrome.html b/chrome.html
index e793e961..9aae14a7 100644
--- a/chrome.html
+++ b/chrome.html
@@ -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
? `
`
: ``;
- return `
${fav}${(b.title||b.url).replace(/✕
`;
+ const short = bookmarkShortLabel(b.title, b.url);
+ return `${fav}${short.replace(/✕
`;
}).join("")
: `No saved pages yet — click ☆ or right-click here to add one.`;
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).
diff --git a/main.js b/main.js
index f397d77d..65309d42 100644
--- a/main.js
+++ b/main.js
@@ -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) };
diff --git a/package.json b/package.json
index 9a17f7e8..5ce7c41d 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/preload.js b/preload.js
index 782bee8b..387480cc 100644
--- a/preload.js
+++ b/preload.js
@@ -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()),