diff --git a/extension/app.js b/extension/app.js
index b2ecb78c6..57626c578 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}
+
+
+
`;
+}
+
+
+/* ----------------------------------------------------------------
+ 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 e944a2a80..2e86d6b80 100644
--- a/extension/index.html
+++ b/extension/index.html
@@ -59,6 +59,10 @@
@@ -107,7 +111,7 @@ Saved for later
@@ -127,7 +131,7 @@ Saved for later
-
+
diff --git a/extension/manifest.json b/extension/manifest.json
index 92191e337..f755abdfb 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 c7bc0b5d3..438a00f10 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 */