From b714e31adcaace89cf1c07aac37f7d540462b61f Mon Sep 17 00:00:00 2001 From: Local Dev Date: Wed, 2 Sep 2026 04:39:02 +0200 Subject: [PATCH] Ship Theseus 0.3.5 935d637a (fix chrome-gap + tab groups: drag-drop + collapsed popover) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setup 935d637ae19ea821f7e89b0f9a802b4e774b6d1ae3254a70f3e5f17f89424177 Portable bb6bea253d6f32f86e8fdd152cf4c969cf311c991329d5e94fce31215ecf7f00 Chrome-view height gap: any tabs event that fires while no menu/popover is open now normalises the chrome-view height via syncHeight(). A leaked menu-close path could previously leave the strip inflated; the next tabs update guarantees it shrinks back to the natural body-scrollHeight. Tab-group drag-and-drop: group chips are now valid drop targets in the same drag session as tab reorder. Dragging any tab onto a chip and dropping assigns that tab to the chip's group (which also auto-clusters it via the existing tab-group handler in main). Chip highlights acid green while a valid drop hovers. Collapsed group vertical popover: click a collapsed group chip and a floating panel opens below it, listing every tab in the group. Each row shows the favicon + title + a ✕ to close that tab. Clicking a row switches to the tab AND expands the group so the newly-active tab appears in the strip (tabGroupToggle). Popover closes on outside click or Escape; the outside-click filter also ignores clicks inside .gchip so opening the popover doesn't immediately close it. growChromeForMenu() and closeAllMenus() now include .grouppop in their overlay queries so the popover contributes to chrome-view sizing and gets cleaned up alongside the ctxmenus. Deployed: scp + sia-upload of both trees. Verified LIVE 0.3.5. --- chrome.html | 114 +++++++++++++++++++++++++++++++++++++++++++++++---- package.json | 2 +- 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/chrome.html b/chrome.html index 109ce4b..4708acf 100644 --- a/chrome.html +++ b/chrome.html @@ -50,6 +50,28 @@ .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; } + /* Drag hover state — a tab being dragged onto a group chip. */ + .gchip.droptarget { background: rgba(214,255,61,.15); border-color: var(--acid); } + /* Vertical popover: click a collapsed chip → list its member tabs. */ + .grouppop { position: fixed; z-index: 9999; background: var(--surface, #1c222c); border: 1px solid var(--line2); + border-radius: 10px; box-shadow: 0 12px 34px #000c; padding: 4px; min-width: 220px; max-width: 360px; + font-size: 13px; color: var(--ink); } + .grouppop .gph { padding: 6px 10px 4px; font-size: 11px; color: var(--dim); letter-spacing: .08em; text-transform: uppercase; + display: flex; align-items: center; gap: 8px; } + .grouppop .gph .gcdot { width: 8px; height: 8px; border-radius: 50%; } + .grouppop .gpit { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 6px; cursor: pointer; + white-space: nowrap; overflow: hidden; } + .grouppop .gpit:hover { background: var(--hover, #ffffff10); } + .grouppop .gpit.active { background: rgba(214,255,61,.08); color: var(--acid); } + .grouppop .gpit .gfav { width: 14px; height: 14px; flex: none; object-fit: contain; border-radius: 2px; } + .grouppop .gpit .gt { overflow: hidden; text-overflow: ellipsis; flex: 1; } + .grouppop .gpit .gx { opacity: .5; padding: 0 4px; font-size: 12px; } + .grouppop .gpit:hover .gx { opacity: .9; } + .grouppop .gpit .gx:hover { color: #f6768a; opacity: 1; } + @media (prefers-color-scheme: light) { + .grouppop { background: #ffffff; border-color: rgba(0,0,0,.15); } + .grouppop .gpit:hover { background: rgba(0,0,0,.05); } + } .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; } @@ -595,6 +617,11 @@ $("back").disabled = !d.canBack; $("fwd").disabled = !d.canForward; // 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 + // reset the chrome view height to the natural body-scrollHeight — this + // eliminates the "chrome stays huge after a menu action" gap that could + // otherwise linger if a menu-close path missed syncHeight(). + if (!document.querySelector(".ctxmenu, .grouppop")) syncHeight(); const rb = $("reload"); if (d.loading) { rb.innerHTML = STOP_SVG; rb.title = "Stop"; rb.onclick = () => T.stop(); } else { rb.innerHTML = RELOAD_SVG; rb.title = "Reload (Shift-click: hard reload)"; rb.onclick = (ev) => T.reload(ev.shiftKey); } @@ -632,10 +659,36 @@ 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); + // dragId is shared between tab-reorder handlers and group-chip drop + // handlers; declared here so both scopes see the same identity. + let dragId = null; + // Group chip clicks: + // collapsed chip → show a vertical popover listing the group's tabs + // expanded chip → toggle to collapsed + // Also acts as a drop target: drag any tab onto a chip to assign it + // to that group. + box.querySelectorAll(".gchip").forEach((chip) => { + chip.onclick = (ev) => { + ev.stopPropagation(); + const color = chip.dataset.group; + if (chip.classList.contains("collapsed")) { + openGroupPopover(chip, color, d); + } else { + T.tabGroupToggle && T.tabGroupToggle(color); + } + }; + chip.addEventListener("dragover", (ev) => { + if (dragId == null) return; + ev.preventDefault(); ev.dataTransfer.dropEffect = "move"; + chip.classList.add("droptarget"); + }); + chip.addEventListener("dragleave", () => chip.classList.remove("droptarget")); + chip.addEventListener("drop", (ev) => { + ev.preventDefault(); + chip.classList.remove("droptarget"); + if (dragId != null) T.tabGroup(dragId, chip.dataset.group); + dragId = null; + }); }); box.querySelectorAll(".tab").forEach((el) => el.onclick = (e) => { if (e.target.dataset.close) T.closeTab(Number(e.target.dataset.close)); @@ -652,7 +705,7 @@ })); // 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). - let dragId = null; + // dragId already declared above so group-chip drop targets share it. box.querySelectorAll(".tab").forEach((row) => { row.addEventListener("dragstart", (e) => { dragId = Number(row.dataset.id); @@ -693,11 +746,54 @@ { id: "purple", label: "Purple" }, ]; function closeAllMenus() { - document.querySelectorAll(".ctxmenu").forEach((m) => m.remove()); + document.querySelectorAll(".ctxmenu, .grouppop").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(); } + // Vertical dropdown of a collapsed group's tabs. Click a row → switch to + // that tab (and expand the group so the newly-active tab is visible in + // the strip). The X on a row closes just that tab. Popover closes on + // outside click via the shared document listener at the bottom. + function openGroupPopover(chip, color, d) { + closeAllMenus(); + const rect = chip.getBoundingClientRect(); + const pop = document.createElement("div"); + pop.className = "grouppop"; + pop.dataset.group = color; + const groupTabs = d.tabs.filter((t) => t.group === color); + const header = document.createElement("div"); + header.className = "gph"; + header.innerHTML = '' + color + ' group · ' + groupTabs.length + ' tab' + (groupTabs.length === 1 ? '' : 's') + ''; + pop.appendChild(header); + for (const t of groupTabs) { + const row = document.createElement("div"); + row.className = "gpit" + (t.active ? " active" : ""); + const fav = t.favicon + ? '' + : ''; + row.innerHTML = fav + '' + String(t.title || "New Tab").replace(/✕'; + row.addEventListener("click", (ev) => { + if (ev.target.dataset.close) { + ev.stopPropagation(); + T.closeTab(Number(ev.target.dataset.close)); + // Rebuild popover after close (next onTabs update will refresh anyway). + return; + } + // Switch to this tab and expand the group so it's visible in the strip. + T.switchTab(t.id); + if (T.tabGroupToggle) T.tabGroupToggle(color); // was collapsed, now expand + closeAllMenus(); + }); + pop.appendChild(row); + } + pop.style.left = Math.round(rect.left) + "px"; + pop.style.top = Math.round(rect.bottom + 4) + "px"; + document.body.appendChild(pop); + const r = pop.getBoundingClientRect(); + if (r.right > window.innerWidth) pop.style.left = Math.max(4, window.innerWidth - r.width - 4) + "px"; + growChromeForMenu(); + } function openTabContextMenu(x, y, t) { closeAllMenus(); const m = document.createElement("div"); @@ -736,10 +832,10 @@ growChromeForMenu(); } function growChromeForMenu() { - // Wait a frame so all newly-added menus contribute to the bounding box. + // Wait a frame so all newly-added overlays contribute to the bounding box. requestAnimationFrame(() => { let needed = document.body.scrollHeight; - for (const m of document.querySelectorAll(".ctxmenu")) { + for (const m of document.querySelectorAll(".ctxmenu, .grouppop")) { const r = m.getBoundingClientRect(); needed = Math.max(needed, Math.ceil(r.bottom) + 4); } @@ -769,7 +865,7 @@ } document.addEventListener("click", (e) => { // Close any open menus on outside click. - if (!e.target.closest(".ctxmenu")) closeAllMenus(); + if (!e.target.closest(".ctxmenu, .grouppop, .gchip")) closeAllMenus(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeAllMenus(); }); diff --git a/package.json b/package.json index bbaa846..a1a7b6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "theseus-navigator", - "version": "0.3.4", + "version": "0.3.5", "description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.", "author": "Silent Mode", "main": "main.js",