From 5bb5bdd41197e4bd9864d93c287668d1807793e9 Mon Sep 17 00:00:00 2001 From: Vivek Jain Date: Mon, 4 May 2026 18:43:57 -0700 Subject: [PATCH] feat: add Chrome tab groups support, pointer-event drag-and-drop, and duplicate detection - Render Chrome native tab groups as dedicated cards in the dashboard, with per-group duplicate detection and a "Close N duplicates" action - Default to Tab groups view when named Chrome groups are present - Replace HTML5 DnD with Pointer Events API for reliable drag-and-drop in Chrome extension new-tab pages; supports dragging group cards, domain cards, and individual tab chips onto group cards to merge tabs - Highlight duplicate tabs with amber left-stripe and badge; show duplicate count in card header and a close-duplicates CTA button - Add `tabGroups` permission to manifest for chrome.tabGroups API Co-Authored-By: Claude Sonnet 4.6 --- extension/app.js | 538 +++++++++++++++++++++++++++++++++++++++- extension/index.html | 8 +- extension/manifest.json | 4 +- extension/style.css | 108 +++++++- 4 files changed, 642 insertions(+), 16 deletions(-) diff --git a/extension/app.js b/extension/app.js index b2ecb78c..57626c57 100644 --- a/extension/app.js +++ b/extension/app.js @@ -45,6 +45,7 @@ async function fetchOpenTabs() { title: t.title, windowId: t.windowId, active: t.active, + groupId: t.groupId ?? -1, // Flag Tab Out's own pages so we can detect duplicate new tabs isTabOut: t.url === newtabUrl || t.url === 'chrome://newtab/', })); @@ -708,6 +709,241 @@ const ICONS = { ---------------------------------------------------------------- */ let domainGroups = []; +// Maps Chrome's tab group color names to hex values +const TAB_GROUP_COLORS = { + grey: '#9aa0a6', + blue: '#1a73e8', + red: '#d93025', + yellow: '#f9ab00', + green: '#1e8e3e', + pink: '#e52592', + purple: '#8430ce', + cyan: '#007b83', + orange: '#fa903e', +}; + +// Current view: 'domain' | 'tabgroup' +let currentView = 'domain'; + +// Drag-and-drop state (tab group view only) +let dragState = null; // { type, ... } describing what is being dragged +let currentDropTarget = null; // card element currently highlighted as a drop zone +let dragClone = null; // floating clone element following the cursor +let dragOffsetX = 0; // cursor offset within the dragged card +let dragOffsetY = 0; +let dragSourceCard = null; // the card being dragged (gets .dragging class) + +// Populated when in tab group view; used by close-tabgroup-tabs handler +let tabGroupGroups = []; + +// Domain sub-groups for ungrouped tabs shown below named groups in tab group view +let ungroupedDomainGroups = []; + +// Real tabs from the last render; used by rerenderMissions() on toggle +let lastRealTabs = []; + + +/* ---------------------------------------------------------------- + TAB GROUP VIEW — fetch Chrome tab groups and map tabs into them + ---------------------------------------------------------------- */ + +/** + * buildDomainGroupsFrom(tabs) + * + * Groups an arbitrary list of tabs by hostname, sorted by tab count. + * Used to render domain sub-cards inside the Ungrouped section. + */ +function buildDomainGroupsFrom(tabs) { + const groupMap = {}; + for (const tab of tabs) { + let hostname; + if (tab.url && tab.url.startsWith('file://')) { + hostname = 'local-files'; + } else { + try { hostname = new URL(tab.url).hostname; } catch { continue; } + } + if (!hostname) continue; + if (!groupMap[hostname]) groupMap[hostname] = { domain: hostname, tabs: [] }; + groupMap[hostname].tabs.push(tab); + } + return Object.values(groupMap).sort((a, b) => b.tabs.length - a.tabs.length); +} + +async function fetchAndBuildTabGroups() { + const realTabs = getRealTabs(); + + const groupIds = [...new Set( + realTabs.filter(t => t.groupId !== -1).map(t => t.groupId) + )]; + + const groups = []; + for (const gid of groupIds) { + try { + const meta = await chrome.tabGroups.get(gid); + groups.push({ + id: gid, + title: meta.title || '', + color: meta.color || 'grey', + tabs: realTabs.filter(t => t.groupId === gid), + }); + } catch { /* group may have vanished */ } + } + + // Build domain sub-groups for ungrouped tabs (shown below named groups) + const ungrouped = realTabs.filter(t => t.groupId === -1); + ungroupedDomainGroups = buildDomainGroupsFrom(ungrouped); + + return groups; +} + + +/* ---------------------------------------------------------------- + TAB GROUP CARD RENDERER + ---------------------------------------------------------------- */ + +function renderTabGroupCard(group) { + const tabs = group.tabs || []; + const tabCount = tabs.length; + const isUngrouped = group.id === -1; + const colorHex = TAB_GROUP_COLORS[group.color] || '#9aa0a6'; + const stableId = 'tg-' + group.id; + const groupLabel = isUngrouped ? 'Ungrouped' : (group.title || 'Unnamed group'); + + const urlCounts = {}; + for (const tab of tabs) urlCounts[tab.url] = (urlCounts[tab.url] || 0) + 1; + const dupeUrls = Object.entries(urlCounts).filter(([, c]) => c > 1); + const hasDupes = dupeUrls.length > 0; + const totalExtras = dupeUrls.reduce((s, [, c]) => s + c - 1, 0); + + const tabBadge = ` + ${ICONS.tabs} + ${tabCount} tab${tabCount !== 1 ? 's' : ''} open + `; + + const dupeBadge = hasDupes + ? ` + ${totalExtras} duplicate${totalExtras !== 1 ? 's' : ''} + ` + : ''; + + const colorDot = isUngrouped + ? '' + : ``; + + // Deduplicate for display + const seen = new Set(); + const uniqueTabs = []; + for (const tab of tabs) { + if (!seen.has(tab.url)) { seen.add(tab.url); uniqueTabs.push(tab); } + } + + const visibleTabs = uniqueTabs.slice(0, 8); + const extraCount = uniqueTabs.length - visibleTabs.length; + + const pageChips = visibleTabs.map(tab => { + const label = cleanTitle(smartTitle(stripTitleNoise(tab.title || ''), tab.url), ''); + const count = urlCounts[tab.url]; + const dupeTag = count > 1 ? ` (${count}x)` : ''; + const chipClass = count > 1 ? ' chip-has-dupes' : ''; + const safeUrl = (tab.url || '').replace(/"/g, '"'); + const safeTitle = label.replace(/"/g, '"'); + let domain = ''; + try { domain = new URL(tab.url).hostname; } catch {} + const faviconUrl = domain ? `https://www.google.com/s2/favicons?domain=${domain}&sz=16` : ''; + return `
+ ${faviconUrl ? `` : ''} + ${label}${dupeTag} +
+ + +
+
`; + }).join('') + (extraCount > 0 ? buildOverflowChips(uniqueTabs.slice(8), urlCounts) : ''); + + // Ungrouped section has no close-all button (no group to close) + let actionsHtml = isUngrouped ? '' : ` + `; + + if (hasDupes) { + const dupeUrlsEncoded = dupeUrls.map(([url]) => encodeURIComponent(url)).join(','); + actionsHtml += ` + `; + } + + const colorStyle = isUngrouped ? '' : ` style="--tg-color:${colorHex}"`; + const barClass = hasDupes ? 'has-amber-bar' : 'has-neutral-bar'; + + return ` +
+
+
+
+ ${colorDot} + ${groupLabel} + ${tabBadge} + ${dupeBadge} +
+
${pageChips}
+
${actionsHtml}
+
+
+
${tabCount}
+
tabs
+
+
`; +} + + +/* ---------------------------------------------------------------- + MISSIONS GRID RE-RENDERER — swaps content on view toggle without + re-fetching tabs + ---------------------------------------------------------------- */ + +function rerenderMissions() { + const missionsEl = document.getElementById('openTabsMissions'); + const countEl = document.getElementById('openTabsSectionCount'); + if (!missionsEl) return; + + // Enable grab cursors and drop zones only in tab group view + missionsEl.classList.toggle('dnd-active', currentView === 'tabgroup'); + + if (currentView === 'tabgroup') { + if (tabGroupGroups.length === 0 && ungroupedDomainGroups.length === 0) { + missionsEl.innerHTML = ` +
+
No tab groups
+
Right-click a tab in Chrome to create one.
+
`; + if (countEl) countEl.textContent = '0 groups'; + return; + } + if (countEl) { + countEl.innerHTML = `${tabGroupGroups.length} group${tabGroupGroups.length !== 1 ? 's' : ''}  ·  `; + } + let html = tabGroupGroups.map(g => renderTabGroupCard(g)).join(''); + if (ungroupedDomainGroups.length > 0) { + html += `
Ungrouped
`; + html += ungroupedDomainGroups.map(g => renderDomainCard(g)).join(''); + } + missionsEl.innerHTML = html; + attachDragSources(); + } else { + if (countEl) { + countEl.innerHTML = `${domainGroups.length} domain${domainGroups.length !== 1 ? 's' : ''}  ·  `; + } + missionsEl.innerHTML = domainGroups.map(g => renderDomainCard(g)).join(''); + } +} + /* ---------------------------------------------------------------- HELPER: filter out browser-internal pages @@ -897,6 +1133,49 @@ function renderDomainCard(group) { } +/* ---------------------------------------------------------------- + COUNT REFRESH HELPERS + ---------------------------------------------------------------- */ + +/** + * updateCardCounts(card) + * + * After a single chip is removed from a card, refreshes the tab-count + * badge and the "Close all N tabs" button text to match the new count. + */ +function updateCardCounts(card) { + if (!card) return; + const remaining = card.querySelectorAll('.page-chip[data-action="focus-tab"]').length; + + const badge = card.querySelector('.open-tabs-badge'); + if (badge) badge.innerHTML = `${ICONS.tabs} ${remaining} tab${remaining !== 1 ? 's' : ''} open`; + + const closeBtn = card.querySelector('[data-action="close-domain-tabs"],[data-action="close-tabgroup-tabs"]'); + if (closeBtn) closeBtn.innerHTML = `${ICONS.close} Close all ${remaining} tab${remaining !== 1 ? 's' : ''}`; +} + +/** + * updateSectionCount() + * + * Refreshes the section-header count ("N domains · Close all X tabs") + * after tabs are closed individually. Reads live DOM card count and + * the up-to-date openTabs array. + */ +function updateSectionCount() { + const countEl = document.getElementById('openTabsSectionCount'); + if (!countEl) return; + const realCount = getRealTabs().length; + + if (currentView === 'tabgroup') { + const n = document.querySelectorAll('#openTabsMissions .tab-group-card:not(.closing)').length; + countEl.innerHTML = `${n} group${n !== 1 ? 's' : ''}  ·  `; + } else { + const n = document.querySelectorAll('#openTabsMissions .mission-card:not(.closing)').length; + countEl.innerHTML = `${n} domain${n !== 1 ? 's' : ''}  ·  `; + } +} + + /* ---------------------------------------------------------------- SAVED FOR LATER — Render Checklist Column ---------------------------------------------------------------- */ @@ -1029,6 +1308,7 @@ async function renderStaticDashboard() { // --- Fetch tabs --- await fetchOpenTabs(); const realTabs = getRealTabs(); + lastRealTabs = realTabs; // --- Group tabs by domain --- // Landing pages (Gmail inbox, Twitter home, etc.) get their own special group @@ -1142,19 +1422,31 @@ async function renderStaticDashboard() { return b.tabs.length - a.tabs.length; }); - // --- Render domain cards --- + // --- Fetch Chrome tab groups --- + tabGroupGroups = await fetchAndBuildTabGroups(); + + // --- Render cards (domain or tab group view) --- const openTabsSection = document.getElementById('openTabsSection'); const openTabsMissionsEl = document.getElementById('openTabsMissions'); const openTabsSectionCount = document.getElementById('openTabsSectionCount'); const openTabsSectionTitle = document.getElementById('openTabsSectionTitle'); + const viewToggle = document.getElementById('viewToggle'); if (domainGroups.length > 0 && openTabsSection) { if (openTabsSectionTitle) openTabsSectionTitle.textContent = 'Open tabs'; - openTabsSectionCount.innerHTML = `${domainGroups.length} domain${domainGroups.length !== 1 ? 's' : ''}  ·  `; - openTabsMissionsEl.innerHTML = domainGroups.map(g => renderDomainCard(g)).join(''); + // Only show the toggle when there is at least one named Chrome tab group + const hasNamedGroups = tabGroupGroups.some(g => g.id !== -1); + if (viewToggle) viewToggle.style.display = hasNamedGroups ? 'flex' : 'none'; + // Default to tab groups view when groups exist + if (hasNamedGroups) currentView = 'tabgroup'; + document.querySelectorAll('.view-btn').forEach(b => + b.classList.toggle('active', b.dataset.view === currentView) + ); + rerenderMissions(); openTabsSection.style.display = 'block'; } else if (openTabsSection) { openTabsSection.style.display = 'none'; + if (viewToggle) viewToggle.style.display = 'none'; } // --- Footer stats --- @@ -1245,9 +1537,7 @@ document.addEventListener('click', async (e) => { chip.style.transform = 'scale(0.8)'; setTimeout(() => { chip.remove(); - // If the card now has no tabs, remove it too - const parentCard = document.querySelector('.mission-card:has(.mission-pages:empty)'); - if (parentCard) animateCardOut(parentCard); + updateCardCounts(card); // refresh badge + close-all button on the affected card document.querySelectorAll('.mission-card').forEach(c => { if (c.querySelectorAll('.page-chip[data-action="focus-tab"]').length === 0) { animateCardOut(c); @@ -1259,6 +1549,7 @@ document.addEventListener('click', async (e) => { // Update footer const statTabs = document.getElementById('statTabs'); if (statTabs) statTabs.textContent = openTabs.length; + updateSectionCount(); showToast('Tab closed'); return; @@ -1343,9 +1634,9 @@ document.addEventListener('click', async (e) => { // ---- Close all tabs in a domain group ---- if (action === 'close-domain-tabs') { const domainId = actionEl.dataset.domainId; - const group = domainGroups.find(g => { - return 'domain-' + g.domain.replace(/[^a-z0-9]/g, '-') === domainId; - }); + const stableIdFor = g => 'domain-' + g.domain.replace(/[^a-z0-9]/g, '-'); + const group = domainGroups.find(g => stableIdFor(g) === domainId) + || ungroupedDomainGroups.find(g => stableIdFor(g) === domainId); if (!group) return; const urls = group.tabs.map(t => t.url); @@ -1412,6 +1703,32 @@ document.addEventListener('click', async (e) => { return; } + // ---- Close all tabs in a Chrome tab group ---- + if (action === 'close-tabgroup-tabs') { + const groupId = parseInt(actionEl.dataset.tabgroupId); + const group = tabGroupGroups.find(g => g.id === groupId); + if (!group) return; + + const ids = group.tabs.map(t => t.id); + if (ids.length > 0) await chrome.tabs.remove(ids); + await fetchOpenTabs(); + + if (card) { + playCloseSound(); + animateCardOut(card); + } + + const idx = tabGroupGroups.indexOf(group); + if (idx !== -1) tabGroupGroups.splice(idx, 1); + + const groupLabel = group.title || 'Unnamed group'; + showToast(`Closed ${ids.length} tab${ids.length !== 1 ? 's' : ''} from ${groupLabel}`); + + const statTabs = document.getElementById('statTabs'); + if (statTabs) statTabs.textContent = openTabs.length; + return; + } + // ---- Close ALL open tabs ---- if (action === 'close-all-open-tabs') { const allUrls = openTabs @@ -1433,6 +1750,19 @@ document.addEventListener('click', async (e) => { } }); +// ---- View toggle — switch between Domains and Tab groups ---- +document.addEventListener('click', (e) => { + const btn = e.target.closest('.view-btn'); + if (!btn) return; + const view = btn.dataset.view; + if (!view || view === currentView) return; + currentView = view; + document.querySelectorAll('.view-btn').forEach(b => + b.classList.toggle('active', b.dataset.view === currentView) + ); + rerenderMissions(); +}); + // ---- Archive toggle — expand/collapse the archive section ---- document.addEventListener('click', (e) => { const toggle = e.target.closest('#archiveToggle'); @@ -1476,6 +1806,196 @@ document.addEventListener('input', async (e) => { }); +/* ---------------------------------------------------------------- + DRAG AND DROP — move groups and ungrouped-domain cards into named + Chrome tab groups (tab group view only) + + Uses pointer events instead of HTML5 DnD for reliability in Chrome + extension new-tab pages. + + Drag sources (card headers only): + 'group' — header of a named group card (merges all its tabs into target) + 'domain' — header of an ungrouped domain card (moves all tabs into target) + + Drop target detection: elementFromPoint while the clone is hidden. + ---------------------------------------------------------------- */ + +function startPointerDrag(e, card, state) { + if (dragState) return; + e.preventDefault(); // prevent text selection + + const rect = card.getBoundingClientRect(); + dragOffsetX = e.clientX - rect.left; + dragOffsetY = e.clientY - rect.top; + dragState = state; + dragSourceCard = card; + + // Floating clone follows the cursor + dragClone = card.cloneNode(true); + dragClone.style.cssText = + `position:fixed;width:${rect.width}px;opacity:0.85;pointer-events:none;` + + `z-index:9999;border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,0.2);` + + `transition:none;left:${e.clientX - dragOffsetX}px;top:${e.clientY - dragOffsetY}px`; + document.body.appendChild(dragClone); + + card.classList.add('dragging'); + + document.addEventListener('pointermove', onPointerMove); + document.addEventListener('pointerup', onPointerUp); + document.addEventListener('pointercancel', cleanupPointerDrag); +} + +function onPointerMove(e) { + if (!dragClone || !dragState) return; + + dragClone.style.left = (e.clientX - dragOffsetX) + 'px'; + dragClone.style.top = (e.clientY - dragOffsetY) + 'px'; + + // Temporarily hide clone so elementFromPoint finds the element beneath it + dragClone.style.display = 'none'; + const elemBelow = document.elementFromPoint(e.clientX, e.clientY); + dragClone.style.display = ''; + + const targetCard = elemBelow + ? elemBelow.closest('#openTabsMissions .tab-group-card:not(.dragging)') + : null; + + if (currentDropTarget !== targetCard) { + currentDropTarget?.classList.remove('drag-over'); + currentDropTarget = targetCard || null; + currentDropTarget?.classList.add('drag-over'); + } +} + +async function onPointerUp() { + const targetCard = currentDropTarget; + + cleanupPointerDrag(); + + targetCard?.classList.remove('drag-over'); + currentDropTarget = null; + + if (!targetCard || !dragState) { dragState = null; return; } + + const targetGroupId = parseInt((targetCard.dataset.tabgroupId || '').replace('tg-', '')); + if (isNaN(targetGroupId) || targetGroupId === -1) { dragState = null; return; } + + const saved = dragState; + dragState = null; + + try { + if (saved.type === 'group') { + if (saved.groupId === targetGroupId) return; + const src = tabGroupGroups.find(g => g.id === saved.groupId); + if (src) await chrome.tabs.group({ tabIds: src.tabs.map(t => t.id), groupId: targetGroupId }); + + } else if (saved.type === 'domain') { + const src = ungroupedDomainGroups.find(g => + 'domain-' + g.domain.replace(/[^a-z0-9]/g, '-') === saved.stableId + ); + if (src) await chrome.tabs.group({ tabIds: src.tabs.map(t => t.id), groupId: targetGroupId }); + + } else if (saved.type === 'tab') { + const tab = openTabs.find(t => t.url === saved.url); + if (tab) await chrome.tabs.group({ tabIds: [tab.id], groupId: targetGroupId }); + } + } catch { + showToast('Could not move tabs — try again'); + } + + await renderDashboard(); +} + +function cleanupPointerDrag() { + document.removeEventListener('pointermove', onPointerMove); + document.removeEventListener('pointerup', onPointerUp); + document.removeEventListener('pointercancel', cleanupPointerDrag); + + dragClone?.remove(); + dragClone = null; + dragSourceCard?.classList.remove('dragging'); + dragSourceCard = null; +} + +function attachDragSources() { + if (currentView !== 'tabgroup') return; + + // Named group cards — drag header to merge into another group + document.querySelectorAll('#openTabsMissions .tab-group-card').forEach(card => { + const groupId = parseInt((card.dataset.tabgroupId || '').replace('tg-', '')); + if (isNaN(groupId) || groupId === -1) return; + const top = card.querySelector('.mission-top'); + if (!top) return; + top.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return; + startPointerDrag(e, card, { type: 'group', groupId }); + }); + }); + + // Ungrouped domain cards — drag header to assign to a named group + document.querySelectorAll('#openTabsMissions .domain-card').forEach(card => { + const stableId = card.dataset.domainId; + if (!stableId) return; + const top = card.querySelector('.mission-top'); + if (!top) return; + top.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return; + startPointerDrag(e, card, { type: 'domain', stableId }); + }); + }); + + // Individual tab chips — named group cards AND ungrouped domain cards. + // Use a move threshold so normal clicks (focus tab) still work. + document.querySelectorAll('#openTabsMissions .page-chip[data-tab-url]').forEach(chip => { + const tabUrl = chip.dataset.tabUrl; + if (!tabUrl) return; + + chip.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return; + // Don't start from close/save buttons + if (e.target.closest('.chip-actions')) return; + + const startX = e.clientX; + const startY = e.clientY; + + function onEarlyMove(ev) { + if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < 6) return; + cancel(); // remove provisional listeners before starting real drag + ev.preventDefault(); + + const rect = chip.getBoundingClientRect(); + dragOffsetX = ev.clientX - rect.left; + dragOffsetY = ev.clientY - rect.top; + dragState = { type: 'tab', url: tabUrl }; + dragSourceCard = chip; + + dragClone = chip.cloneNode(true); + dragClone.style.cssText = + `position:fixed;width:${rect.width}px;opacity:0.85;pointer-events:none;` + + `z-index:9999;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.2);` + + `transition:none;left:${ev.clientX - dragOffsetX}px;top:${ev.clientY - dragOffsetY}px`; + document.body.appendChild(dragClone); + chip.classList.add('dragging'); + + document.addEventListener('pointermove', onPointerMove); + document.addEventListener('pointerup', onPointerUp); + document.addEventListener('pointercancel', cleanupPointerDrag); + } + + function cancel() { + document.removeEventListener('pointermove', onEarlyMove); + document.removeEventListener('pointerup', onEarlyUp); + } + + function onEarlyUp() { cancel(); } + + document.addEventListener('pointermove', onEarlyMove); + document.addEventListener('pointerup', onEarlyUp); + }); + }); +} + + /* ---------------------------------------------------------------- INITIALIZE ---------------------------------------------------------------- */ diff --git a/extension/index.html b/extension/index.html index e944a2a8..2e86d6b8 100644 --- a/extension/index.html +++ b/extension/index.html @@ -59,6 +59,10 @@

Right now

+
@@ -107,7 +111,7 @@

Saved for later

- Tab Out by Zara + Tab Out
@@ -127,7 +131,7 @@

Saved for later

- + diff --git a/extension/manifest.json b/extension/manifest.json index 92191e33..f755abdf 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,8 +2,8 @@ "manifest_version": 3, "name": "Tab Out", "version": "1.0.0", - "description": "Keep tabs on your tabs. New tab page that groups your open tabs by domain and lets you close them with style.", - "permissions": ["tabs", "activeTab", "storage"], + "description": "New tab page that organizes your open tabs by domain and Chrome tab groups, with drag-and-drop merging and duplicate detection.", + "permissions": ["tabs", "activeTab", "storage", "tabGroups"], "chrome_url_overrides": { "newtab": "index.html" }, "background": { "service_worker": "background.js" }, "action": { diff --git a/extension/style.css b/extension/style.css index c7bc0b5d..438a00f1 100644 --- a/extension/style.css +++ b/extension/style.css @@ -184,6 +184,63 @@ header { font-weight: 600; } +/* ---- Drag and drop ---- */ + +/* Grab cursor on card headers — only in tab group view */ +.dnd-active .mission-top { + cursor: grab; + user-select: none; +} + +/* Source element while being dragged */ +.page-chip.dragging { + opacity: 0.35; + pointer-events: none; +} + +.mission-card.dragging { + opacity: 0.35; + pointer-events: none; +} + +/* Valid drop target — amber ring + slight lift */ +.mission-card.drag-over { + border-color: var(--accent-amber); + box-shadow: 0 0 0 2px rgba(200, 113, 58, 0.25), 0 4px 20px var(--shadow); + transform: translateY(-2px); + transition: border-color 0.1s, box-shadow 0.1s, transform 0.1s; +} + +/* ---- View toggle (Domains / Tab groups) ---- */ +.view-toggle { + display: flex; + gap: 2px; + background: var(--warm-gray); + border-radius: 5px; + padding: 2px; + flex-shrink: 0; +} + +.view-btn { + font-family: 'DM Sans', sans-serif; + font-size: 11px; + font-weight: 500; + padding: 3px 10px; + border: none; + border-radius: 3px; + background: transparent; + color: var(--muted); + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.view-btn.active { + background: var(--card-bg); + color: var(--ink); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + /* ---- Section Headers ---- */ .section-header { display: flex; @@ -259,6 +316,41 @@ header { transform: translateY(-1px); } +/* Divider between named tab groups and ungrouped domain cards */ +.tg-ungrouped-divider { + column-span: all; + display: flex; + align-items: center; + gap: 10px; + margin: 16px 0 4px; + font-family: 'Newsreader', serif; + font-size: 14px; + font-style: italic; + font-weight: 400; + color: var(--muted); +} + +.tg-ungrouped-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--warm-gray); +} + +/* Tab group card — top border uses the Chrome group color via CSS variable */ +.tab-group-card::before { + background: var(--tg-color, var(--warm-gray)); +} + +/* Color dot shown next to the group name */ +.tg-color-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + display: inline-block; +} + /* Closing animation — card scales down and fades out */ .mission-card.closing { opacity: 0; @@ -395,15 +487,25 @@ header { overflow: hidden; } -/* Duplicate badge */ +/* Duplicate badge — amber pill */ .chip-dupe-badge { + display: inline-flex; + align-items: center; + flex-shrink: 0; color: var(--accent-amber); + font-size: 10px; font-weight: 600; + background: rgba(200, 113, 58, 0.13); + border: 1px solid rgba(200, 113, 58, 0.3); + border-radius: 4px; + padding: 1px 5px; + white-space: nowrap; } -/* Duplicate border highlight */ +/* Duplicate row — amber left stripe + light amber wash */ .chip-has-dupes { - border-color: rgba(200, 113, 58, 0.25); + background: rgba(200, 113, 58, 0.06); + box-shadow: inset 3px 0 0 var(--accent-amber); } /* Action buttons grouped on the right */