diff --git a/chrome.html b/chrome.html
index 35224c7..bba398a 100644
--- a/chrome.html
+++ b/chrome.html
@@ -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 += ``;
+ }
+ // 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
? ''
: (t.favicon ? `
` : '');
- const groupDot = t.group ? `` : "";
const mute = t.muted ? `🔇` : "";
- return `
${groupDot}${icon}${(t.title||"New Tab").replace(/${mute}✕
`;
+ out += `${icon}${(t.title||"New Tab").replace(/${mute}✕
`;
+ return out;
}).join("") +
`+`;
+ // 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();
});
diff --git a/main.js b/main.js
index b7397ce..75ae665 100644
--- a/main.js
+++ b/main.js
@@ -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;
diff --git a/package.json b/package.json
index d511e84..45243b3 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/preload.js b/preload.js
index f885e08..29fa42a 100644
--- a/preload.js
+++ b/preload.js
@@ -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)),