diff --git a/AGENTS.md b/AGENTS.md index 10ea44c7..c4aac5d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,7 @@ Once the extension is loaded: ## Key Facts - Tab Out is a pure Chrome extension. No server, no Node.js, no npm. +- Chrome opens `index.html` directly as the new tab override so the address bar stays highlighted after Cmd+T for immediate searching. - Saved tabs are stored in `chrome.storage.local` (persists across sessions). - 100% local. No data is sent to any external service. - To update: `cd tab-out && git pull`, then reload the extension in `chrome://extensions`. diff --git a/README.md b/README.md index c95960bd..2e178398 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Tab Out is a Chrome extension that replaces your new tab page with a dashboard o No server. No account. No external API calls. Just a Chrome extension. +Tab Out loads the dashboard directly as Chrome's new tab override, so the address bar stays highlighted after Cmd+T and you can start typing a search immediately. + --- ## Install with a coding agent @@ -28,6 +30,9 @@ The agent will walk you through it. Takes about 1 minute. - **Duplicate detection** flags when you have the same page open twice, with one-click cleanup - **Click any tab to jump to it** across windows, no new tab opened - **Save for later** bookmark tabs to a checklist before closing them +- **Bookmarks Bar mirror** keeps your usual shortcuts available on every new tab +- **Dark mode** follows your system preference by default, with a saved manual toggle +- **Local favicons** use Chrome's built-in favicon cache instead of a third-party favicon service - **Localhost grouping** shows port numbers next to each tab so you can tell your vibe coding projects apart - **Expandable groups** show the first 8 tabs with a clickable "+N more" - **100% local** your data never leaves your machine @@ -60,6 +65,8 @@ You'll see Tab Out. ``` You open a new tab + -> Chrome loads the Tab Out dashboard directly + -> The address bar stays highlighted for immediate search -> Tab Out shows your open tabs grouped by domain -> Homepages (Gmail, X, etc.) get their own group at the top -> Click any tab title to jump to it @@ -76,7 +83,10 @@ Everything runs inside the Chrome extension. No external server, no API calls, n | What | How | |------|-----| | Extension | Chrome Manifest V3 | +| New tab handoff | `index.html` is loaded directly as Chrome's new tab override | | Storage | chrome.storage.local | +| Bookmarks | chrome.bookmarks | +| Favicons | Chrome extension favicon endpoint | | Sound | Web Audio API (synthesized, no files) | | Animations | CSS transitions + JS confetti particles | diff --git a/extension/app.js b/extension/app.js index b2ecb78c..7786d0cf 100644 --- a/extension/app.js +++ b/extension/app.js @@ -10,7 +10,8 @@ 2. Groups tabs by domain with a landing pages category 3. Renders domain cards, banners, and stats 4. Handles all user actions (close tabs, save for later, focus tab) - 5. Stores "Saved for Later" tabs in chrome.storage.local (no server) + 5. Mirrors the Bookmarks Bar folder inside the new-tab page + 6. Stores "Saved for Later" tabs in chrome.storage.local (no server) ================================================================ */ 'use strict'; @@ -19,12 +20,298 @@ /* ---------------------------------------------------------------- CHROME TABS — Direct API Access - Since this page IS the extension's new tab page, it has full + Since this page is loaded directly from the extension, it has full access to chrome.tabs and chrome.storage. No middleman needed. ---------------------------------------------------------------- */ // All open tabs — populated by fetchOpenTabs() let openTabs = []; +let dashboardRenderTimer = null; +let dashboardRenderPromise = null; +let hasRenderedDashboard = false; +let hasLoadedLocalConfig = false; +let lastPointerActivityAt = 0; + +// Theme preference is mirrored to localStorage so theme.js can apply it +// before the stylesheet paints on future new-tab loads. +const THEME_STORAGE_KEY = 'themePreference'; +const THEME_LOCAL_STORAGE_KEY = 'tabOutTheme'; + +function normalizeTheme(theme) { + return theme === 'dark' || theme === 'light' ? theme : null; +} + +function getSystemTheme() { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +function readLocalTheme() { + try { return normalizeTheme(localStorage.getItem(THEME_LOCAL_STORAGE_KEY)); } + catch { return null; } +} + +function writeLocalTheme(theme) { + try { localStorage.setItem(THEME_LOCAL_STORAGE_KEY, theme); } + catch { /* localStorage unavailable; chrome.storage still persists */ } +} + +function updateThemeToggle(theme) { + const toggle = document.getElementById('themeToggle'); + const text = document.getElementById('themeToggleText'); + if (!toggle || !text) return; + + const nextTheme = theme === 'dark' ? 'light' : 'dark'; + toggle.setAttribute('aria-pressed', theme === 'dark' ? 'true' : 'false'); + toggle.setAttribute('aria-label', `Switch to ${nextTheme} mode`); + text.textContent = nextTheme === 'dark' ? 'Dark' : 'Light'; +} + +function applyTheme(theme) { + const resolvedTheme = normalizeTheme(theme) || getSystemTheme(); + document.documentElement.dataset.theme = resolvedTheme; + document.documentElement.style.colorScheme = resolvedTheme; + updateThemeToggle(resolvedTheme); +} + +async function initTheme() { + let storedTheme = readLocalTheme(); + + try { + const result = await chrome.storage.local.get(THEME_STORAGE_KEY); + storedTheme = normalizeTheme(result[THEME_STORAGE_KEY]) || storedTheme; + } catch { + // Fall back to the local mirror or system preference. + } + + if (storedTheme) writeLocalTheme(storedTheme); + applyTheme(storedTheme); + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + mediaQuery.addEventListener('change', () => { + if (!readLocalTheme()) applyTheme(null); + }); +} + +async function toggleTheme() { + const currentTheme = normalizeTheme(document.documentElement.dataset.theme) || getSystemTheme(); + const nextTheme = currentTheme === 'dark' ? 'light' : 'dark'; + + applyTheme(nextTheme); + writeLocalTheme(nextTheme); + + try { + await chrome.storage.local.set({ [THEME_STORAGE_KEY]: nextTheme }); + } catch (err) { + console.warn('[tab-out] Could not persist theme preference:', err); + } + + showToast(`${nextTheme === 'dark' ? 'Dark' : 'Light'} mode`); +} + +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, ch => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[ch]); +} + +function escapeAttr(value) { + return escapeHtml(value); +} + +function faviconUrlForPage(pageUrl, size = 16) { + if (!pageUrl || pageUrl.startsWith('javascript:')) return ''; + + try { + const url = new URL(chrome.runtime.getURL('/_favicon/')); + url.searchParams.set('pageUrl', pageUrl); + url.searchParams.set('size', String(size)); + return url.toString(); + } catch { + return ''; + } +} + +function findBookmarksBarNode(tree) { + const root = tree?.[0]; + const rootChildren = root?.children || []; + return rootChildren.find(node => node.id === '1') || + rootChildren.find(node => /bookmarks bar/i.test(node.title || '')) || + rootChildren[0] || + null; +} + +function collectBookmarkLinks(node, limit = 18, links = []) { + if (!node || links.length >= limit) return links; + + if (node.url) { + if (!node.url.startsWith('javascript:')) links.push(node); + return links; + } + + for (const child of node.children || []) { + collectBookmarkLinks(child, limit, links); + if (links.length >= limit) break; + } + + return links; +} + +function renderBookmarkLink(bookmark, className = 'bookmark-link') { + const title = bookmark.title || bookmark.url || 'Bookmark'; + const faviconUrl = faviconUrlForPage(bookmark.url, 16); + + return ` + + ${faviconUrl ? `` : ''} + ${escapeHtml(title)} + `; +} + +function renderBookmarkItem(node) { + if (node.url) return renderBookmarkLink(node); + + const links = collectBookmarkLinks(node); + if (links.length === 0) return ''; + + return ` +
+ + + ${escapeHtml(node.title || 'Folder')} + +
+ ${links.map(link => renderBookmarkLink(link, 'bookmark-menu-link')).join('')} +
+
`; +} + +function closeBookmarkFolders(exceptFolder = null) { + document.querySelectorAll('.bookmark-folder[open]').forEach(folder => { + if (folder !== exceptFolder) { + folder.open = false; + folder.classList.remove('align-left'); + } + }); +} + +function positionBookmarkFolderMenu(folder) { + if (!folder) return; + + const menu = folder.querySelector('.bookmark-folder-menu'); + if (!menu) return; + + folder.classList.remove('align-left'); + + const folderRect = folder.getBoundingClientRect(); + const menuWidth = Math.min(320, window.innerWidth - 48); + const wouldClipRight = folderRect.left + menuWidth > window.innerWidth - 24; + const isRightSide = folderRect.left > window.innerWidth * 0.58; + + if (wouldClipRight || isRightSide) folder.classList.add('align-left'); +} + +function handleBookmarkFolderClick(summary) { + const folder = summary?.closest('.bookmark-folder'); + if (!folder) return; + + expandBookmarksBar(); + closeBookmarkFolders(folder); + + requestAnimationFrame(() => { + if (folder.open) positionBookmarkFolderMenu(folder); + }); +} + +function updateBookmarksOverflow() { + const bar = document.getElementById('bookmarksBar'); + const list = document.getElementById('bookmarksBarList'); + const toggle = document.getElementById('bookmarksExpandToggle'); + if (!bar || !list || !toggle || bar.hidden) return; + + const wasExpanded = bar.dataset.expanded === 'true'; + const previousExpanded = bar.classList.contains('expanded'); + + bar.classList.remove('expanded'); + bar.classList.add('collapsed'); + const hasOverflow = list.scrollHeight > list.clientHeight + 2; + + bar.classList.toggle('expanded', wasExpanded || previousExpanded); + bar.classList.toggle('collapsed', !(wasExpanded || previousExpanded)); + toggle.hidden = !hasOverflow; + toggle.textContent = wasExpanded || previousExpanded ? 'Less' : 'More'; + toggle.setAttribute('aria-expanded', wasExpanded || previousExpanded ? 'true' : 'false'); +} + +async function renderBookmarksBar() { + const bar = document.getElementById('bookmarksBar'); + const list = document.getElementById('bookmarksBarList'); + if (!bar || !list || !chrome.bookmarks) return; + + try { + const tree = await chrome.bookmarks.getTree(); + const bookmarksBar = findBookmarksBarNode(tree); + const items = (bookmarksBar?.children || []) + .map(renderBookmarkItem) + .filter(Boolean); + + if (items.length === 0) { + bar.hidden = true; + list.innerHTML = ''; + return; + } + + list.innerHTML = items.join(''); + bar.hidden = false; + const isExpanded = bar.dataset.expanded === 'true'; + bar.classList.toggle('expanded', isExpanded); + bar.classList.toggle('collapsed', !isExpanded); + requestAnimationFrame(updateBookmarksOverflow); + } catch (err) { + console.warn('[tab-out] Could not render bookmarks bar:', err); + bar.hidden = true; + } +} + +function toggleBookmarksBar() { + const bar = document.getElementById('bookmarksBar'); + if (!bar) return; + + const isExpanded = bar.dataset.expanded === 'true'; + bar.dataset.expanded = isExpanded ? 'false' : 'true'; + bar.classList.toggle('expanded', !isExpanded); + bar.classList.toggle('collapsed', isExpanded); + updateBookmarksOverflow(); +} + +function expandBookmarksBar() { + const bar = document.getElementById('bookmarksBar'); + if (!bar) return; + + bar.dataset.expanded = 'true'; + bar.classList.add('expanded'); + bar.classList.remove('collapsed'); + updateBookmarksOverflow(); +} + +function loadLocalConfig() { + return new Promise(resolve => { + const script = document.createElement('script'); + script.src = 'config.local.js'; + script.onload = () => { hasLoadedLocalConfig = true; resolve(); }; + script.onerror = () => resolve(); + document.head.appendChild(script); + }); +} + +function setupBrokenImageHandler() { + document.addEventListener('error', (e) => { + if (e.target instanceof HTMLImageElement) e.target.hidden = true; + }, true); +} /** * fetchOpenTabs() @@ -35,7 +322,7 @@ let openTabs = []; async function fetchOpenTabs() { try { const extensionId = chrome.runtime.id; - // The new URL for this page is now index.html (not newtab.html) + // The dashboard is the direct new-tab override so Chrome keeps the omnibox focused. const newtabUrl = `chrome-extension://${extensionId}/index.html`; const tabs = await chrome.tabs.query({}); @@ -45,7 +332,7 @@ async function fetchOpenTabs() { title: t.title, windowId: t.windowId, active: t.active, - // Flag Tab Out's own pages so we can detect duplicate new tabs + // Flag Tab Out's own pages so we can group them like regular tabs isTabOut: t.url === newtabUrl || t.url === 'chrome://newtab/', })); } catch { @@ -169,35 +456,6 @@ async function closeDuplicateTabs(urls, keepOne = true) { await fetchOpenTabs(); } -/** - * closeTabOutDupes() - * - * Closes all duplicate Tab Out new-tab pages except the current one. - */ -async function closeTabOutDupes() { - const extensionId = chrome.runtime.id; - const newtabUrl = `chrome-extension://${extensionId}/index.html`; - - const allTabs = await chrome.tabs.query({}); - const currentWindow = await chrome.windows.getCurrent(); - const tabOutTabs = allTabs.filter(t => - t.url === newtabUrl || t.url === 'chrome://newtab/' - ); - - if (tabOutTabs.length <= 1) return; - - // Keep the active Tab Out tab in the CURRENT window — that's the one the - // user is looking at right now. Falls back to any active one, then the first. - const keep = - tabOutTabs.find(t => t.active && t.windowId === currentWindow.id) || - tabOutTabs.find(t => t.active) || - tabOutTabs[0]; - const toClose = tabOutTabs.filter(t => t.id !== keep.id).map(t => t.id); - if (toClose.length > 0) await chrome.tabs.remove(toClose); - await fetchOpenTabs(); -} - - /* ---------------------------------------------------------------- SAVED FOR LATER — chrome.storage.local @@ -722,7 +980,7 @@ let domainGroups = []; function getRealTabs() { return openTabs.filter(t => { const url = t.url || ''; - return ( + return t.isTabOut || ( !url.startsWith('chrome://') && !url.startsWith('chrome-extension://') && !url.startsWith('about:') && @@ -732,27 +990,6 @@ function getRealTabs() { }); } -/** - * checkTabOutDupes() - * - * Counts how many Tab Out pages are open. If more than 1, - * shows a banner offering to close the extras. - */ -function checkTabOutDupes() { - const tabOutTabs = openTabs.filter(t => t.isTabOut); - const banner = document.getElementById('tabOutDupeBanner'); - const countEl = document.getElementById('tabOutDupeCount'); - if (!banner) return; - - if (tabOutTabs.length > 1) { - if (countEl) countEl.textContent = tabOutTabs.length; - banner.style.display = 'flex'; - } else { - banner.style.display = 'none'; - } -} - - /* ---------------------------------------------------------------- OVERFLOW CHIPS ("+N more" expand button in domain cards) ---------------------------------------------------------------- */ @@ -765,11 +1002,9 @@ function buildOverflowChips(hiddenTabs, urlCounts = {}) { 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` : ''; + const faviconUrl = faviconUrlForPage(tab.url, 16); return `
- ${faviconUrl ? `` : ''} + ${faviconUrl ? `` : ''} ${label}${dupeTag}
`; + openTabsSectionCount.innerHTML = `${domainGroups.length} domain${domainGroups.length !== 1 ? 's' : ''}  ·  `; openTabsMissionsEl.innerHTML = domainGroups.map(g => renderDomainCard(g)).join(''); - openTabsSection.style.display = 'block'; + openTabsSection.hidden = false; } else if (openTabsSection) { - openTabsSection.style.display = 'none'; + openTabsSection.hidden = true; } - // --- Footer stats --- - const statTabs = document.getElementById('statTabs'); - if (statTabs) statTabs.textContent = openTabs.length; - - // --- Check for duplicate Tab Out tabs --- - checkTabOutDupes(); - // --- Render "Saved for Later" column --- await renderDeferredColumn(); + + hasRenderedDashboard = true; + document.body.classList.add('has-rendered-dashboard'); } async function renderDashboard() { await renderStaticDashboard(); } +function scheduleDashboardRender(delay = 100) { + clearTimeout(dashboardRenderTimer); + + dashboardRenderTimer = setTimeout(() => { + const pointerQuietFor = Date.now() - lastPointerActivityAt; + if (pointerQuietFor < 250) { + scheduleDashboardRender(250 - pointerQuietFor); + return; + } + + dashboardRenderPromise = (dashboardRenderPromise || Promise.resolve()) + .then(renderDashboard) + .catch(err => console.warn('[tab-out] Dashboard refresh failed:', err)) + .finally(() => { dashboardRenderPromise = null; }); + }, delay); +} + /* ---------------------------------------------------------------- EVENT HANDLERS — using event delegation @@ -1182,23 +1444,28 @@ async function renderDashboard() { ---------------------------------------------------------------- */ document.addEventListener('click', async (e) => { + const bookmarkSummary = e.target.closest('.bookmark-folder-summary'); + if (bookmarkSummary) { + handleBookmarkFolderClick(bookmarkSummary); + } else if (!e.target.closest('.bookmark-folder')) { + closeBookmarkFolders(); + } + // Walk up the DOM to find the nearest element with data-action const actionEl = e.target.closest('[data-action]'); if (!actionEl) return; const action = actionEl.dataset.action; - // ---- Close duplicate Tab Out tabs ---- - if (action === 'close-tabout-dupes') { - await closeTabOutDupes(); - playCloseSound(); - const banner = document.getElementById('tabOutDupeBanner'); - if (banner) { - banner.style.transition = 'opacity 0.4s'; - banner.style.opacity = '0'; - setTimeout(() => { banner.style.display = 'none'; banner.style.opacity = '1'; }, 400); - } - showToast('Closed extra Tab Out tabs'); + // ---- Toggle dark/light mode ---- + if (action === 'toggle-theme') { + await toggleTheme(); + return; + } + + // ---- Expand/collapse bookmarks bar mirror ---- + if (action === 'toggle-bookmarks') { + toggleBookmarksBar(); return; } @@ -1208,7 +1475,7 @@ document.addEventListener('click', async (e) => { if (action === 'expand-chips') { const overflowContainer = actionEl.parentElement.querySelector('.page-chips-overflow'); if (overflowContainer) { - overflowContainer.style.display = 'contents'; + overflowContainer.hidden = false; actionEl.remove(); } return; @@ -1256,10 +1523,6 @@ document.addEventListener('click', async (e) => { }, 200); } - // Update footer - const statTabs = document.getElementById('statTabs'); - if (statTabs) statTabs.textContent = openTabs.length; - showToast('Tab closed'); return; } @@ -1351,7 +1614,7 @@ document.addEventListener('click', async (e) => { const urls = group.tabs.map(t => t.url); // Landing pages and custom groups (whose domain key isn't a real hostname) // must use exact URL matching to avoid closing unrelated tabs - const useExact = group.domain === '__landing-pages__' || !!group.label; + const useExact = group.domain === '__landing-pages__' || group.domain === '__tab-out__' || !!group.label; if (useExact) { await closeTabsExact(urls); @@ -1368,11 +1631,9 @@ document.addEventListener('click', async (e) => { const idx = domainGroups.indexOf(group); if (idx !== -1) domainGroups.splice(idx, 1); - const groupLabel = group.domain === '__landing-pages__' ? 'Homepages' : (group.label || friendlyDomain(group.domain)); + const groupLabel = group.domain === '__landing-pages__' ? 'Homepages' : group.domain === '__tab-out__' ? 'Tab Out' : (group.label || friendlyDomain(group.domain)); showToast(`Closed ${urls.length} tab${urls.length !== 1 ? 's' : ''} from ${groupLabel}`); - const statTabs = document.getElementById('statTabs'); - if (statTabs) statTabs.textContent = openTabs.length; return; } @@ -1441,7 +1702,7 @@ document.addEventListener('click', (e) => { toggle.classList.toggle('open'); const body = document.getElementById('archiveBody'); if (body) { - body.style.display = body.style.display === 'none' ? 'block' : 'none'; + body.hidden = !body.hidden; } }); @@ -1469,7 +1730,7 @@ document.addEventListener('input', async (e) => { ); archiveList.innerHTML = results.map(item => renderArchiveItem(item)).join('') - || '
No results
'; + || '
No results
'; } catch (err) { console.warn('[tab-out] Archive search failed:', err); } @@ -1479,4 +1740,33 @@ document.addEventListener('input', async (e) => { /* ---------------------------------------------------------------- INITIALIZE ---------------------------------------------------------------- */ -renderDashboard(); +initTheme(); +setupBrokenImageHandler(); +document.addEventListener('pointerdown', () => { lastPointerActivityAt = Date.now(); }, true); +document.addEventListener('pointerup', () => { lastPointerActivityAt = Date.now(); }, true); +document.addEventListener('pointercancel', () => { lastPointerActivityAt = Date.now(); }, true); + +dashboardRenderPromise = loadLocalConfig() + .then(renderDashboard) + .catch(err => console.warn('[tab-out] Initial dashboard render failed:', err)) + .finally(() => { dashboardRenderPromise = null; }); +window.addEventListener('resize', () => { + updateBookmarksOverflow(); + closeBookmarkFolders(); +}); + +chrome.tabs.onCreated.addListener(() => scheduleDashboardRender()); +chrome.tabs.onRemoved.addListener(() => scheduleDashboardRender()); +chrome.tabs.onUpdated.addListener(() => scheduleDashboardRender()); +chrome.tabs.onMoved.addListener(() => scheduleDashboardRender()); +chrome.tabs.onAttached.addListener(() => scheduleDashboardRender()); +chrome.tabs.onDetached.addListener(() => scheduleDashboardRender()); +chrome.tabs.onReplaced.addListener(() => scheduleDashboardRender()); +chrome.windows.onFocusChanged.addListener(() => scheduleDashboardRender()); + +chrome.bookmarks.onCreated.addListener(() => scheduleDashboardRender()); +chrome.bookmarks.onRemoved.addListener(() => scheduleDashboardRender()); +chrome.bookmarks.onChanged.addListener(() => scheduleDashboardRender()); +chrome.bookmarks.onMoved.addListener(() => scheduleDashboardRender()); +chrome.bookmarks.onChildrenReordered.addListener(() => scheduleDashboardRender()); +chrome.bookmarks.onImportEnded.addListener(() => scheduleDashboardRender()); diff --git a/extension/index.html b/extension/index.html index e944a2a8..67e45b6b 100644 --- a/extension/index.html +++ b/extension/index.html @@ -9,6 +9,7 @@ + @@ -26,25 +27,24 @@

+
+ +
- + -