Ship Theseus 0.3.3 e016d665 (fix tab menu clip + always-on bookmarks + real tab groups)

Setup    e016d66543976b7a4df5ac16033319d59cb0f418e54d7e838ef16de274525a83
Portable b9881a62c39ecfe22644611f5aa16a48f32f97f9ec5751b5888ccbd870338c3e

Three 0.3.2 misses fixed:

Tab context menu was clipped by the chrome view's fixed height — the
menu rendered with position:fixed inside a WebContentsView whose
bottom edge stops at CHROME_H, so items below the tab strip weren't
visible. New growChromeForMenu() measures every open menu's bounding
box and calls T.setChromeHeight(needed) so main.js grows the chrome
view to fit; closeAllMenus() calls syncHeight() to restore.

Bookmarks bar was hidden on every tab except Home, so Add-to-
Bookmarks had no on-screen feedback. Now always visible.

Tab groups now actually cluster. Assigning a color to a tab moves it
adjacent to its group-mates in the tabs array (main-side splice, no
render-only trick). The chrome renderer inserts a colored group chip
before the first tab of each group; clicking the chip toggles a
per-group collapsed state (in-memory, resets on relaunch). Collapsed
groups hide their member tabs and the chip shows the member count.
Expanded groups keep the per-tab top color stripe from 0.3.2 so the
cluster reads as one visual block.

Deployed: scp + sia-upload, verified LIVE 0.3.3 on dl.silentmode.st.
This commit is contained in:
Local Dev 2026-08-31 21:29:14 +02:00
parent 5ea4515085
commit 3bf41f92ae
4 changed files with 100 additions and 16 deletions

View file

@ -39,13 +39,24 @@
even when tabs are next to each other. */
.tab .gdot { width: 8px; height: 8px; flex: none; border-radius: 50%; }
.tab.grp { box-shadow: inset 0 2px 0 var(--gc, transparent); }
.tab.g-red, .gdot.g-red { --gc: #f6768a; } .gdot.g-red { background: #f6768a; }
.tab.g-orange, .gdot.g-orange { --gc: #ffa96a; } .gdot.g-orange { background: #ffa96a; }
.tab.g-yellow, .gdot.g-yellow { --gc: #ffd44f; } .gdot.g-yellow { background: #ffd44f; }
.tab.g-green, .gdot.g-green { --gc: #4fd1a5; } .gdot.g-green { background: #4fd1a5; }
.tab.g-cyan, .gdot.g-cyan { --gc: #4fd1e5; } .gdot.g-cyan { background: #4fd1e5; }
.tab.g-blue, .gdot.g-blue { --gc: #4b7bec; } .gdot.g-blue { background: #4b7bec; }
.tab.g-purple, .gdot.g-purple { --gc: #b39ddb; } .gdot.g-purple { background: #b39ddb; }
/* Group chip that appears before the first tab of a group. Clicking
collapses the group (hides its member tabs) and shows a count. */
.gchip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px;
background: var(--surface); border: 1px solid var(--line2); border-bottom: none;
border-radius: 8px 8px 0 0; cursor: pointer; height: 28px;
font: 12px/1 system-ui, sans-serif; color: var(--mut);
box-shadow: inset 0 2px 0 var(--gc, transparent); }
.gchip:hover { color: var(--ink); }
.gchip .gcdot { width: 10px; height: 10px; border-radius: 50%; background: var(--gc); flex: none; }
.gchip .gccnt { color: var(--ink); font-weight: 600; }
.gchip.collapsed { padding: 4px 10px; }
.tab.g-red, .gdot.g-red, .gcdot.g-red { --gc: #f6768a; } .gdot.g-red { background: #f6768a; }
.tab.g-orange, .gdot.g-orange, .gcdot.g-orange { --gc: #ffa96a; } .gdot.g-orange { background: #ffa96a; }
.tab.g-yellow, .gdot.g-yellow, .gcdot.g-yellow { --gc: #ffd44f; } .gdot.g-yellow { background: #ffd44f; }
.tab.g-green, .gdot.g-green, .gcdot.g-green { --gc: #4fd1a5; } .gdot.g-green { background: #4fd1a5; }
.tab.g-cyan, .gdot.g-cyan, .gcdot.g-cyan { --gc: #4fd1e5; } .gdot.g-cyan { background: #4fd1e5; }
.tab.g-blue, .gdot.g-blue, .gcdot.g-blue { --gc: #4b7bec; } .gdot.g-blue { background: #4b7bec; }
.tab.g-purple, .gdot.g-purple, .gcdot.g-purple { --gc: #b39ddb; } .gdot.g-purple { background: #b39ddb; }
/* Submenu popover for the tab context menu (Group → color). */
.ctxmenu .mi.sub { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.ctxmenu .mi.sub::after { content: "▸"; color: var(--dim); font-size: 11px; }
@ -573,17 +584,39 @@
current.title = active ? active.title : "";
updateStar();
const box = $("tabs");
// Render tabs with group awareness. For every group, emit a chip BEFORE
// the group's first tab. If the group is collapsed, tabs inside it are
// hidden and the chip shows the count as "[● 3]"; expanded chips show
// just the color dot.
const collapsed = new Set(d.collapsedGroups || []);
const groupsSeen = new Set();
const groupsCount = {};
for (const t of d.tabs) if (t.group) groupsCount[t.group] = (groupsCount[t.group] || 0) + 1;
box.innerHTML = d.tabs.map((t) => {
let out = "";
// Chip appears once, at the FIRST tab of the group in render order.
if (t.group && !groupsSeen.has(t.group)) {
groupsSeen.add(t.group);
const isCollapsed = collapsed.has(t.group);
out += `<button class="gchip g-${t.group}${isCollapsed ? " collapsed" : ""}" data-group="${t.group}" title="${isCollapsed ? "Expand" : "Collapse"} ${t.group} group"><span class="gcdot g-${t.group}"></span>${isCollapsed ? `<span class="gccnt">${groupsCount[t.group]}</span>` : ""}</button>`;
}
// Skip rendering the tab itself if its group is collapsed.
if (t.group && collapsed.has(t.group)) return out;
// Loading spinner takes the icon slot while a page is loading, then
// hands it back to the favicon once page-favicon-updated fires.
const icon = t.loading
? '<span class="spin"></span>'
: (t.favicon ? `<img class="fav" src="${String(t.favicon).replace(/"/g,"&quot;")}" onerror="this.remove()">` : '');
const groupDot = t.group ? `<span class="gdot g-${t.group}" title="Group: ${t.group}"></span>` : "";
const mute = t.muted ? `<span class="mute" title="Muted">🔇</span>` : "";
return `<div class="tab ${t.active ? "active" : ""}${t.group ? " grp g-" + t.group : ""}" data-id="${t.id}" draggable="true">${groupDot}${icon}<span class="t">${(t.title||"New Tab").replace(/</g,"&lt;")}</span>${mute}<span class="x" data-close="${t.id}"></span></div>`;
out += `<div class="tab ${t.active ? "active" : ""}${t.group ? " grp g-" + t.group : ""}" data-id="${t.id}" draggable="true">${icon}<span class="t">${(t.title||"New Tab").replace(/</g,"&lt;")}</span>${mute}<span class="x" data-close="${t.id}"></span></div>`;
return out;
}).join("") +
`<span class="newtab" id="newtab">+</span>`;
// Group chips toggle collapsed state.
box.querySelectorAll(".gchip").forEach((chip) => chip.onclick = (ev) => {
ev.stopPropagation();
T.tabGroupToggle && T.tabGroupToggle(chip.dataset.group);
});
box.querySelectorAll(".tab").forEach((el) => el.onclick = (e) => {
if (e.target.dataset.close) T.closeTab(Number(e.target.dataset.close));
else T.switchTab(Number(el.dataset.id));
@ -639,7 +672,12 @@
{ id: "cyan", label: "Cyan" }, { id: "blue", label: "Blue" },
{ id: "purple", label: "Purple" },
];
function closeAllMenus() { document.querySelectorAll(".ctxmenu").forEach((m) => m.remove()); }
function closeAllMenus() {
document.querySelectorAll(".ctxmenu").forEach((m) => m.remove());
// Menus were rendered outside the chrome's normal flow (position:fixed),
// so closing them means the natural chrome height wins again.
syncHeight();
}
function openTabContextMenu(x, y, t) {
closeAllMenus();
const m = document.createElement("div");
@ -670,10 +708,23 @@
const sep = document.createElement("div"); sep.className = "sep"; m.appendChild(sep);
m.appendChild(item("Close", "danger", () => T.closeTab(t.id)));
document.body.appendChild(m);
// Clamp inside viewport
// Clamp horizontally to the viewport. VERTICALLY: the chrome view has a
// fixed height (main.js CHROME_H), which clips anything below that line.
// Grow the chrome view so the menu is fully visible — restored on close.
const r = m.getBoundingClientRect();
if (r.right > window.innerWidth) m.style.left = Math.max(4, window.innerWidth - r.width - 4) + "px";
if (r.bottom > window.innerHeight) m.style.top = Math.max(4, window.innerHeight - r.height - 4) + "px";
growChromeForMenu();
}
function growChromeForMenu() {
// Wait a frame so all newly-added menus contribute to the bounding box.
requestAnimationFrame(() => {
let needed = document.body.scrollHeight;
for (const m of document.querySelectorAll(".ctxmenu")) {
const r = m.getBoundingClientRect();
needed = Math.max(needed, Math.ceil(r.bottom) + 4);
}
T.setChromeHeight(needed);
});
}
function openGroupSubmenu(x, y, t) {
document.querySelectorAll(".ctxmenu.sub2").forEach((m) => m.remove());
@ -694,7 +745,7 @@
document.body.appendChild(m);
const r = m.getBoundingClientRect();
if (r.right > window.innerWidth) m.style.left = Math.max(4, x - r.width - 6) + "px";
if (r.bottom > window.innerHeight) m.style.top = Math.max(4, window.innerHeight - r.height - 4) + "px";
growChromeForMenu();
}
document.addEventListener("click", (e) => {
// Close any open menus on outside click.
@ -710,7 +761,10 @@
else if (d.host && !$("url").value) $("url").value = d.host;
}
// Favorites bar shows only on the new-tab / home page (hides once a page loads).
$("bookmarks").hidden = !(!d || d.kind === "home");
// Bookmarks bar was originally home-only; users adding a bookmark from
// a real tab saw nothing visible. Keep it visible on every tab so the
// add / remove has an immediate on-screen effect.
$("bookmarks").hidden = false;
syncHeight();
});

31
main.js
View file

@ -1669,6 +1669,7 @@ function emitTabs() {
const wc = t?.view.webContents;
chrome?.webContents.send("tabs", {
tabs: tabs.map((x) => ({ id: x.id, title: x.title || "New Tab", active: x.id === activeId, loading: !!x.loading, favicon: x.favicon || null, muted: !!x.muted, group: x.group || null, url: x.url || "" })),
collapsedGroups: [...tabGroupCollapsed],
url: t?.url || "",
loading: !!t?.loading,
canBack: wc ? wc.navigationHistory.canGoBack() : false,
@ -2126,10 +2127,38 @@ ipcMain.handle("tab-group", (_e, id, color) => {
const t = tabById(id); if (!t) return null;
// color: null | "red" | "orange" | "yellow" | "green" | "cyan" | "blue" | "purple"
const allowed = new Set(["red","orange","yellow","green","cyan","blue","purple"]);
t.group = allowed.has(color) ? color : null;
const next = allowed.has(color) ? color : null;
t.group = next;
// Cluster: move this tab so all same-group tabs sit contiguously. Place
// it right after the LAST existing tab of that group; if there are no
// other members yet, leave it in place. When a tab is removed from a
// group (color === null) we don't reorder — the visual break is enough.
if (next) {
const idx = tabs.indexOf(t);
let insertAfter = -1;
for (let i = 0; i < tabs.length; i++) {
if (i !== idx && tabs[i].group === next) insertAfter = i;
}
if (insertAfter !== -1) {
tabs.splice(idx, 1);
const dst = insertAfter > idx ? insertAfter : insertAfter + 1;
tabs.splice(dst, 0, t);
}
}
emitTabs();
return t.group;
});
// Groups get a collapsed/expanded state, per-color, in-memory only (resets
// on relaunch). Flipping this doesn't touch tabs — the renderer just hides
// tabs whose group is collapsed and shows the group chip in their place.
const tabGroupCollapsed = new Set(); // colors currently collapsed
ipcMain.handle("tab-group-toggle", (_e, color) => {
if (!color) return false;
if (tabGroupCollapsed.has(color)) tabGroupCollapsed.delete(color); else tabGroupCollapsed.add(color);
// Piggy-back on emitTabs so the chrome renderer receives the change.
emitTabs();
return tabGroupCollapsed.has(color);
});
ipcMain.handle("tab-bookmark", (_e, id) => {
const t = tabById(id); if (!t) return false;
const url = t.url; const title = t.title || url;

View file

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

View file

@ -45,6 +45,7 @@ contextBridge.exposeInMainWorld("theseus", {
tabDuplicate: (id) => ipcRenderer.invoke("tab-duplicate", id),
tabMute: (id, on) => ipcRenderer.invoke("tab-mute", id, on),
tabGroup: (id, color) => ipcRenderer.invoke("tab-group", id, color),
tabGroupToggle: (color) => ipcRenderer.invoke("tab-group-toggle", color),
tabBookmark: (id) => ipcRenderer.invoke("tab-bookmark", id),
onAddressPicked: (cb) => ipcRenderer.on("address-picked", (_e, url) => cb(url)),
onBcnrOffer: (cb) => ipcRenderer.on("bcnr-offer", (_e, d) => cb(d)),