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')}
+
+