diff --git a/electron.vite.config.ts b/electron.vite.config.ts
index a35cbdd..b6bf86f 100644
--- a/electron.vite.config.ts
+++ b/electron.vite.config.ts
@@ -67,10 +67,11 @@ export default defineConfig({
// Minify renderer bundle for production performance.
minify: 'esbuild',
rollupOptions: {
- // Multi-page setup: main browser window UI + floating quick-search window UI.
+ // Multi-page setup: main browser window UI + floating quick-search window + dropdown menu.
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
float: resolve(__dirname, 'src/renderer/float.html'),
+ menu: resolve(__dirname, 'src/renderer/menu.html'),
},
output: {
// Use deterministic renderer entry names for simple diagnostics.
diff --git a/src/main/index.ts b/src/main/index.ts
index 4c1f514..3b381ea 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -1,4 +1,4 @@
-import { app, BrowserView, BrowserWindow, ipcMain, session } from 'electron';
+import { app, BrowserView, BrowserWindow, ipcMain, screen, session } from 'electron';
import Store from 'electron-store';
import path from 'path';
@@ -7,6 +7,8 @@ import {
parseBookmarkUpsertPayload,
parseBrowserBoundsPayload,
parseFloatNavigatePayload,
+ parseMenuActionPayload,
+ parseMenuShowPayload,
parseTabCreatePayload,
parseTabIdPayload,
parseTabNavigatePayload,
@@ -47,8 +49,13 @@ interface PersistedStateSchema {
tabsSession: PersistedTabSession;
}
+const MENU_WIDTH = 220;
+const MENU_HEIGHT = 272;
+
let mainWindow: BrowserWindow | null = null;
let floatWindow: BrowserWindow | null = null;
+let menuWindow: BrowserWindow | null = null;
+let menuWindowReady = false;
let attachedView: BrowserView | null = null;
let nextTabId = 1;
let activeTabId: number | null = null;
@@ -308,6 +315,18 @@ function closeFloatWindow(): void {
windowToClose.close();
}
+function closeMenuWindow(): void {
+ if (!menuWindow || menuWindow.isDestroyed()) {
+ menuWindow = null;
+ return;
+ }
+
+ const windowToClose = menuWindow;
+ menuWindow = null;
+ menuWindowReady = false;
+ windowToClose.close();
+}
+
function detachAttachedView(): void {
if (!mainWindow || !attachedView) {
return;
@@ -653,6 +672,7 @@ function createMainWindow(): void {
mainWindow.on('closed', () => {
destroyAllTabs();
closeFloatWindow();
+ closeMenuWindow();
mainWindow = null;
});
}
@@ -697,6 +717,52 @@ function createFloatWindow(): void {
});
}
+function createMenuWindow(): void {
+ menuWindowReady = false;
+ menuWindow = new BrowserWindow({
+ width: MENU_WIDTH,
+ height: MENU_HEIGHT,
+ frame: false,
+ alwaysOnTop: true,
+ resizable: false,
+ skipTaskbar: true,
+ show: false,
+ backgroundColor: '#1e1812',
+ webPreferences: {
+ preload: path.join(__dirname, '../preload/index.js'),
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ webSecurity: true,
+ allowRunningInsecureContent: false,
+ },
+ });
+
+ if (VITE_DEV_SERVER_URL) {
+ const menuDevUrl = new URL('menu.html', VITE_DEV_SERVER_URL).toString();
+ menuWindow.loadURL(menuDevUrl).catch(error => {
+ console.error('[main] failed to load menu dev URL', error);
+ });
+ } else {
+ menuWindow.loadFile(path.join(RENDERER_DIST, 'menu.html')).catch(error => {
+ console.error('[main] failed to load menu renderer file', error);
+ });
+ }
+
+ menuWindow.webContents.on('did-finish-load', () => {
+ menuWindowReady = true;
+ });
+
+ menuWindow.on('blur', () => {
+ menuWindow?.hide();
+ });
+
+ menuWindow.on('closed', () => {
+ menuWindow = null;
+ menuWindowReady = false;
+ });
+}
+
ipcMain.handle(IPC_CHANNELS.TOGGLE_FLOAT, () => {
if (!floatWindow || floatWindow.isDestroyed()) {
createFloatWindow();
@@ -715,6 +781,68 @@ ipcMain.handle(IPC_CHANNELS.TOGGLE_FLOAT, () => {
}
});
+ipcMain.handle(IPC_CHANNELS.MENU_SHOW, (_event, payload: unknown) => {
+ const safePayload = parseMenuShowPayload(payload);
+ if (!safePayload) {
+ return;
+ }
+
+ if (!menuWindow || menuWindow.isDestroyed()) {
+ createMenuWindow();
+ }
+
+ if (!menuWindow) {
+ return;
+ }
+
+ const display = screen.getDisplayNearestPoint({
+ x: safePayload.screenX,
+ y: safePayload.screenY,
+ });
+ const { workArea } = display;
+
+ const menuX = Math.max(
+ workArea.x,
+ Math.min(safePayload.screenX - MENU_WIDTH, workArea.x + workArea.width - MENU_WIDTH),
+ );
+ const menuY = Math.max(
+ workArea.y,
+ Math.min(safePayload.screenY + 4, workArea.y + workArea.height - MENU_HEIGHT),
+ );
+
+ menuWindow.setPosition(Math.round(menuX), Math.round(menuY));
+
+ const initPayload = {
+ isBookmarkBarVisible: safePayload.isBookmarkBarVisible,
+ theme: safePayload.theme,
+ };
+
+ const sendAndShow = (): void => {
+ menuWindow?.webContents.send(IPC_CHANNELS.MENU_INIT, initPayload);
+ menuWindow?.show();
+ menuWindow?.focus();
+ };
+
+ if (menuWindowReady) {
+ sendAndShow();
+ } else {
+ menuWindow.webContents.once('did-finish-load', sendAndShow);
+ }
+});
+
+ipcMain.handle(IPC_CHANNELS.MENU_ACTION, (_event, payload: unknown) => {
+ const action = parseMenuActionPayload(payload);
+ if (!action) {
+ return;
+ }
+
+ menuWindow?.hide();
+
+ if (mainWindow && !mainWindow.isDestroyed()) {
+ mainWindow.webContents.send(IPC_CHANNELS.MENU_ACTION_RELAY, action);
+ }
+});
+
ipcMain.handle(IPC_CHANNELS.FLOAT_NAVIGATE, (_event, payload: unknown) => {
const safeUrl = parseFloatNavigatePayload(payload);
if (!safeUrl) {
@@ -860,6 +988,7 @@ app.whenReady().then(() => {
createMainWindow();
createFloatWindow();
+ createMenuWindow();
app.on('activate', () => {
if (!mainWindow || mainWindow.isDestroyed()) {
@@ -869,6 +998,10 @@ app.whenReady().then(() => {
if (!floatWindow || floatWindow.isDestroyed()) {
createFloatWindow();
}
+
+ if (!menuWindow || menuWindow.isDestroyed()) {
+ createMenuWindow();
+ }
});
});
@@ -876,6 +1009,7 @@ app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
destroyAllTabs();
closeFloatWindow();
+ closeMenuWindow();
storageLayer?.close();
storageLayer = null;
app.quit();
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 1c12785..e8a53e5 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -7,6 +7,9 @@ import {
parseBrowserBoundsPayload,
parseFloatNavigatePayload,
parseHistorySnapshotsPayload,
+ parseMenuActionPayload,
+ parseMenuInitPayload,
+ parseMenuShowPayload,
parseTabIdPayload,
parseTabNavigatePayload,
parseTabsStateSnapshotPayload,
@@ -16,6 +19,9 @@ import type {
BookmarkUpsertPayload,
BrowserBounds,
HistorySnapshot,
+ MenuAction,
+ MenuInitPayload,
+ MenuShowPayload,
TabsStateSnapshot,
} from '../shared/ipc-contract';
@@ -196,5 +202,53 @@ contextBridge.exposeInMainWorld('orb', {
};
},
+ showMenu: (payload: MenuShowPayload) => {
+ const safePayload = parseMenuShowPayload(payload);
+ if (!safePayload) {
+ return Promise.resolve();
+ }
+
+ return ipcRenderer.invoke(IPC_CHANNELS.MENU_SHOW, safePayload).then(() => undefined);
+ },
+
+ menuAction: (action: MenuAction) => {
+ const safeAction = parseMenuActionPayload(action);
+ if (!safeAction) {
+ return Promise.resolve();
+ }
+
+ return ipcRenderer.invoke(IPC_CHANNELS.MENU_ACTION, safeAction).then(() => undefined);
+ },
+
+ onMenuAction: (callback: (action: MenuAction) => void) => {
+ const handler = (_event: Electron.IpcRendererEvent, payload: unknown): void => {
+ const action = parseMenuActionPayload(payload);
+ if (action) {
+ callback(action);
+ }
+ };
+
+ ipcRenderer.on(IPC_CHANNELS.MENU_ACTION_RELAY, handler);
+
+ return () => {
+ ipcRenderer.removeListener(IPC_CHANNELS.MENU_ACTION_RELAY, handler);
+ };
+ },
+
+ onMenuInit: (callback: (state: MenuInitPayload) => void) => {
+ const handler = (_event: Electron.IpcRendererEvent, payload: unknown): void => {
+ const state = parseMenuInitPayload(payload);
+ if (state) {
+ callback(state);
+ }
+ };
+
+ ipcRenderer.on(IPC_CHANNELS.MENU_INIT, handler);
+
+ return () => {
+ ipcRenderer.removeListener(IPC_CHANNELS.MENU_INIT, handler);
+ };
+ },
+
platform: process.platform,
});
diff --git a/src/renderer/icons.ts b/src/renderer/icons.ts
new file mode 100644
index 0000000..4780d0c
--- /dev/null
+++ b/src/renderer/icons.ts
@@ -0,0 +1,56 @@
+const svg = (content: string, size = 16): string =>
+ ``;
+
+const svgFilled = (content: string, size = 16): string =>
+ ``;
+
+export const ICONS = {
+ back: svg(''),
+ forward: svg(''),
+ reload: svg(
+ '' +
+ '' +
+ '' +
+ '',
+ ),
+ starEmpty: svg(
+ '',
+ ),
+ starFilled: svg(
+ '',
+ ),
+ menu: svgFilled(
+ '',
+ ),
+ float: svg(''),
+ sun: svg(
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '',
+ 14,
+ ),
+ moon: svg('', 14),
+ bookmarks: svg(
+ '',
+ 14,
+ ),
+ history: svg(
+ '',
+ 14,
+ ),
+ plus: svg('', 14),
+ bookmarkBar: svg(
+ '' +
+ '' +
+ '',
+ 14,
+ ),
+ floatSearch: svg('', 14),
+ check: svg('', 14),
+} as const;
diff --git a/src/renderer/index.html b/src/renderer/index.html
index 10435ee..9206b17 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -12,17 +12,14 @@
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/src/renderer/main.ts b/src/renderer/main.ts
index 76a035b..a91fbc5 100644
--- a/src/renderer/main.ts
+++ b/src/renderer/main.ts
@@ -3,15 +3,18 @@ import type {
BookmarkSnapshot,
BrowserBounds,
HistorySnapshot,
+ MenuAction,
TabSnapshot,
TabsStateSnapshot,
} from '../shared/ipc-contract';
+import { MENU_ACTIONS } from '../shared/ipc-contract';
import {
requestNavigateActiveTab,
requestTabClose,
requestTabCloseIfActive,
requestTabCreate,
} from './interaction';
+import { ICONS } from './icons';
import {
getNextTheme,
getThemeToggleMeta,
@@ -66,20 +69,25 @@ const newTabSearch = document.getElementById('new-tab-search') as HTMLInputEleme
const btnBack = document.getElementById('btn-back') as HTMLButtonElement;
const btnForward = document.getElementById('btn-forward') as HTMLButtonElement;
const btnReload = document.getElementById('btn-reload') as HTMLButtonElement;
-const btnTheme = document.getElementById('btn-theme') as HTMLButtonElement;
const btnBookmark = document.getElementById('btn-bookmark') as HTMLButtonElement;
-const btnBookmarkBar = document.getElementById('btn-bookmark-bar') as HTMLButtonElement;
-const btnBookmarks = document.getElementById('btn-bookmarks') as HTMLButtonElement;
-const btnHistory = document.getElementById('btn-history') as HTMLButtonElement;
const btnHistoryClear = document.getElementById('btn-history-clear') as HTMLButtonElement;
const btnFloat = document.getElementById('btn-float') as HTMLButtonElement;
+const btnMenu = document.getElementById('btn-menu') as HTMLButtonElement;
const btnNewTab = document.getElementById('btn-new-tab') as HTMLButtonElement;
const themeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+// Set static SVG icons that never change
+btnBack.innerHTML = ICONS.back;
+btnForward.innerHTML = ICONS.forward;
+btnReload.innerHTML = ICONS.reload;
+btnFloat.innerHTML = ICONS.float;
+btnMenu.innerHTML = ICONS.menu;
+
let unsubscribeOpenUrl: (() => void) | null = null;
let unsubscribeTabsState: (() => void) | null = null;
let unsubscribeBookmarks: (() => void) | null = null;
let unsubscribeHistory: (() => void) | null = null;
+let unsubscribeMenuAction: (() => void) | null = null;
function getStoredBookmarkBarVisibility(): boolean {
try {
@@ -132,8 +140,7 @@ function applyTheme(theme: 'light' | 'dark'): void {
document.documentElement.dataset.theme = theme;
const themeToggleMeta = getThemeToggleMeta(theme);
- btnTheme.textContent = themeToggleMeta.icon;
- btnTheme.title = themeToggleMeta.title;
+ btnFloat.title = `Floating search (Ctrl+Shift+O) — ${themeToggleMeta.title}`;
}
function syncThemeFromEnvironment(): void {
@@ -268,7 +275,7 @@ function renderTabs(): void {
tabElement.dataset.id = String(tab.id);
tabElement.innerHTML = `
${escapeHtml(tab.title || 'New Tab')}
-
+
`;
tabsContainer.appendChild(tabElement);
@@ -291,25 +298,12 @@ function renderBookmarkControls(): void {
const activeBookmark = getActiveBookmark();
btnBookmark.disabled = !activeTab?.url;
- btnBookmark.textContent = activeBookmark ? '★' : '☆';
+ btnBookmark.innerHTML = activeBookmark ? ICONS.starFilled : ICONS.starEmpty;
+ btnBookmark.classList.toggle('text-orb-accent', !!activeBookmark);
+ btnBookmark.classList.toggle('text-orb-text-dim', !activeBookmark);
btnBookmark.title = activeBookmark
- ? 'Remove bookmark from this page (Cmd/Ctrl+D)'
- : 'Save bookmark for this page (Cmd/Ctrl+D)';
-
- btnBookmarkBar.textContent = state.isBookmarkBarVisible ? '▤' : '▥';
- btnBookmarkBar.title = state.isBookmarkBarVisible
- ? 'Hide bookmarks bar (Cmd/Ctrl+Shift+B)'
- : 'Show bookmarks bar (Cmd/Ctrl+Shift+B)';
-
- btnBookmarks.textContent = state.isBookmarksSidebarOpen ? '×' : '☰';
- btnBookmarks.title = state.isBookmarksSidebarOpen
- ? 'Hide bookmarks sidebar'
- : 'Show bookmarks sidebar';
-
- btnHistory.textContent = state.isHistorySidebarOpen ? '×' : 'H';
- btnHistory.title = state.isHistorySidebarOpen
- ? 'Hide history sidebar (Cmd/Ctrl+H)'
- : 'Show history sidebar (Cmd/Ctrl+H)';
+ ? 'Remove bookmark from this page (Ctrl+D)'
+ : 'Save bookmark for this page (Ctrl+D)';
}
function renderBookmarkEditor(): void {
@@ -496,8 +490,6 @@ function navigate(input: string): void {
}
closeBookmarkEditor();
-
- // Main process normalizes this to URL/search and performs navigation safely.
newTabSearch.value = '';
}
@@ -509,6 +501,42 @@ function closeTab(tabId: number): void {
requestTabClose(window.orb, tabId);
}
+function openMenu(): void {
+ const rect = btnMenu.getBoundingClientRect();
+ void window.orb.showMenu({
+ screenX: window.screenX + Math.round(rect.right),
+ screenY: window.screenY + Math.round(rect.bottom),
+ isBookmarkBarVisible: state.isBookmarkBarVisible,
+ theme: getCurrentTheme(),
+ });
+}
+
+function handleMenuAction(action: MenuAction): void {
+ switch (action) {
+ case MENU_ACTIONS.NEW_TAB:
+ requestTabCreate(window.orb);
+ break;
+ case MENU_ACTIONS.TOGGLE_BOOKMARKS:
+ toggleBookmarksSidebar();
+ break;
+ case MENU_ACTIONS.TOGGLE_HISTORY:
+ toggleHistorySidebar();
+ break;
+ case MENU_ACTIONS.TOGGLE_BOOKMARK_BAR:
+ toggleBookmarkBar();
+ break;
+ case MENU_ACTIONS.TOGGLE_THEME: {
+ const nextTheme = getNextTheme(getCurrentTheme());
+ setStoredTheme(nextTheme);
+ applyTheme(nextTheme);
+ break;
+ }
+ case MENU_ACTIONS.OPEN_FLOAT_SEARCH:
+ void window.orb.toggleFloat();
+ break;
+ }
+}
+
btnNewTab.addEventListener('click', () => {
requestTabCreate(window.orb);
});
@@ -525,28 +553,10 @@ btnReload.addEventListener('click', () => {
void window.orb.reload();
});
-btnTheme.addEventListener('click', () => {
- const nextTheme = getNextTheme(getCurrentTheme());
- setStoredTheme(nextTheme);
- applyTheme(nextTheme);
-});
-
btnBookmark.addEventListener('click', () => {
triggerBookmarkAction();
});
-btnBookmarkBar.addEventListener('click', () => {
- toggleBookmarkBar();
-});
-
-btnBookmarks.addEventListener('click', () => {
- toggleBookmarksSidebar();
-});
-
-btnHistory.addEventListener('click', () => {
- toggleHistorySidebar();
-});
-
btnHistoryClear.addEventListener('click', () => {
void window.orb.clearHistory().then(applyHistory);
});
@@ -555,6 +565,10 @@ btnFloat.addEventListener('click', () => {
void window.orb.toggleFloat();
});
+btnMenu.addEventListener('click', () => {
+ openMenu();
+});
+
bookmarkEditorSave.addEventListener('click', () => {
saveBookmarkFromEditor();
});
@@ -732,6 +746,10 @@ unsubscribeHistory = window.orb.onHistoryChanged(nextHistory => {
applyHistory(nextHistory);
});
+unsubscribeMenuAction = window.orb.onMenuAction(action => {
+ handleMenuAction(action);
+});
+
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && state.isBookmarkEditorOpen) {
event.preventDefault();
@@ -807,6 +825,9 @@ window.addEventListener('beforeunload', () => {
unsubscribeHistory?.();
unsubscribeHistory = null;
+
+ unsubscribeMenuAction?.();
+ unsubscribeMenuAction = null;
});
window.orb.getBookmarks().then(initialBookmarks => {
diff --git a/src/renderer/menu.html b/src/renderer/menu.html
new file mode 100644
index 0000000..523cc0e
--- /dev/null
+++ b/src/renderer/menu.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
Orb Menu
+
+
+
+
+
+
diff --git a/src/renderer/menu.ts b/src/renderer/menu.ts
new file mode 100644
index 0000000..1358131
--- /dev/null
+++ b/src/renderer/menu.ts
@@ -0,0 +1,196 @@
+import './styles/tailwind.css';
+import { MENU_ACTIONS } from '../shared/ipc-contract';
+import type { MenuAction, MenuInitPayload } from '../shared/ipc-contract';
+import { ICONS } from './icons';
+import { normalizeTheme, ORB_THEME_STORAGE_KEY, resolveTheme } from './theme';
+
+const menuContainer = document.getElementById('menu') as HTMLDivElement;
+const themeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+
+let menuState: MenuInitPayload = { isBookmarkBarVisible: true, theme: 'dark' };
+
+function getStoredTheme(): string | null {
+ try {
+ return window.localStorage.getItem(ORB_THEME_STORAGE_KEY);
+ } catch {
+ return null;
+ }
+}
+
+function syncTheme(): void {
+ const resolvedTheme = resolveTheme(getStoredTheme(), themeMediaQuery.matches);
+ document.documentElement.dataset.theme = resolvedTheme;
+}
+
+const onSystemThemeChanged = (): void => {
+ if (!normalizeTheme(getStoredTheme())) {
+ syncTheme();
+ }
+};
+
+syncTheme();
+themeMediaQuery.addEventListener('change', onSystemThemeChanged);
+
+interface MenuItem {
+ type: 'action';
+ action: MenuAction;
+ icon: string;
+ label: string;
+ shortcut?: string;
+ checked?: boolean;
+}
+
+interface MenuSeparator {
+ type: 'separator';
+}
+
+type MenuEntry = MenuItem | MenuSeparator;
+
+function buildMenuEntries(): MenuEntry[] {
+ const { isBookmarkBarVisible, theme } = menuState;
+ const themeIcon = theme === 'dark' ? ICONS.sun : ICONS.moon;
+ const themeLabel = theme === 'dark' ? 'Light Mode' : 'Dark Mode';
+
+ return [
+ {
+ type: 'action',
+ action: MENU_ACTIONS.NEW_TAB,
+ icon: ICONS.plus,
+ label: 'New Tab',
+ shortcut: 'Ctrl+T',
+ },
+ { type: 'separator' },
+ {
+ type: 'action',
+ action: MENU_ACTIONS.TOGGLE_BOOKMARKS,
+ icon: ICONS.bookmarks,
+ label: 'Bookmarks',
+ },
+ {
+ type: 'action',
+ action: MENU_ACTIONS.TOGGLE_HISTORY,
+ icon: ICONS.history,
+ label: 'History',
+ shortcut: 'Ctrl+H',
+ },
+ { type: 'separator' },
+ {
+ type: 'action',
+ action: MENU_ACTIONS.TOGGLE_BOOKMARK_BAR,
+ icon: ICONS.bookmarkBar,
+ label: 'Bookmarks Bar',
+ checked: isBookmarkBarVisible,
+ },
+ { type: 'separator' },
+ {
+ type: 'action',
+ action: MENU_ACTIONS.TOGGLE_THEME,
+ icon: themeIcon,
+ label: themeLabel,
+ },
+ { type: 'separator' },
+ {
+ type: 'action',
+ action: MENU_ACTIONS.OPEN_FLOAT_SEARCH,
+ icon: ICONS.floatSearch,
+ label: 'Floating Search',
+ shortcut: 'Ctrl+Shift+O',
+ },
+ ];
+}
+
+function escapeHtml(input: string): string {
+ return input
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function renderMenu(): void {
+ const entries = buildMenuEntries();
+ menuContainer.innerHTML = '';
+
+ entries.forEach(entry => {
+ if (entry.type === 'separator') {
+ const sep = document.createElement('div');
+ sep.className = 'my-1 border-t border-orb-border';
+ menuContainer.appendChild(sep);
+ return;
+ }
+
+ const btn = document.createElement('button');
+ btn.className =
+ 'flex w-full items-center gap-2.5 px-3 py-[7px] text-left text-[13px] text-orb-text' +
+ ' transition-colors hover:bg-orb-surface-2 active:bg-orb-surface-2' +
+ ' border-0 bg-transparent cursor-default';
+ btn.dataset.action = entry.action;
+
+ const iconSpan = document.createElement('span');
+ iconSpan.className = 'flex h-4 w-4 shrink-0 items-center justify-center text-orb-text-dim';
+ iconSpan.innerHTML = entry.icon;
+
+ const labelSpan = document.createElement('span');
+ labelSpan.className = 'flex-1 select-none';
+ labelSpan.textContent = entry.label;
+
+ btn.appendChild(iconSpan);
+ btn.appendChild(labelSpan);
+
+ if (entry.checked !== undefined) {
+ const checkSpan = document.createElement('span');
+ checkSpan.className = 'ml-auto flex h-4 w-4 shrink-0 items-center justify-center text-orb-accent';
+ if (entry.checked) {
+ checkSpan.innerHTML = ICONS.check;
+ }
+ btn.appendChild(checkSpan);
+ } else if (entry.shortcut) {
+ const shortcutSpan = document.createElement('span');
+ shortcutSpan.className = 'ml-auto shrink-0 select-none font-mono text-[11px] text-orb-text-dim';
+ shortcutSpan.textContent = escapeHtml(entry.shortcut);
+ btn.appendChild(shortcutSpan);
+ }
+
+ menuContainer.appendChild(btn);
+ });
+}
+
+menuContainer.addEventListener('click', event => {
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) {
+ return;
+ }
+
+ const btn = target.closest('[data-action]');
+ if (!btn) {
+ return;
+ }
+
+ const action = btn.dataset.action as MenuAction | undefined;
+ if (!action) {
+ return;
+ }
+
+ void window.orb.menuAction(action);
+});
+
+window.orb.onMenuInit(state => {
+ menuState = state;
+ document.documentElement.dataset.theme = state.theme;
+ renderMenu();
+});
+
+window.addEventListener('storage', event => {
+ if (event.key === ORB_THEME_STORAGE_KEY) {
+ syncTheme();
+ }
+});
+
+window.addEventListener('focus', () => {
+ syncTheme();
+});
+
+window.addEventListener('beforeunload', () => {
+ themeMediaQuery.removeEventListener('change', onSystemThemeChanged);
+});
diff --git a/src/renderer/window.d.ts b/src/renderer/window.d.ts
index 4d1a15a..fcda134 100644
--- a/src/renderer/window.d.ts
+++ b/src/renderer/window.d.ts
@@ -5,6 +5,9 @@ import type {
BookmarkUpsertPayload,
BrowserBounds,
HistorySnapshot,
+ MenuAction,
+ MenuInitPayload,
+ MenuShowPayload,
TabsStateSnapshot,
} from '../shared/ipc-contract';
@@ -32,6 +35,10 @@ declare global {
getHistory: () => Promise;
clearHistory: () => Promise;
onHistoryChanged: (callback: (history: HistorySnapshot[]) => void) => () => void;
+ showMenu: (payload: MenuShowPayload) => Promise;
+ menuAction: (action: MenuAction) => Promise;
+ onMenuAction: (callback: (action: MenuAction) => void) => () => void;
+ onMenuInit: (callback: (state: MenuInitPayload) => void) => () => void;
platform: string;
};
}
diff --git a/src/shared/ipc-contract.ts b/src/shared/ipc-contract.ts
index f6c506b..394255e 100644
--- a/src/shared/ipc-contract.ts
+++ b/src/shared/ipc-contract.ts
@@ -40,6 +40,29 @@ export interface HistorySnapshot {
lastVisitedAt: string;
}
+export const MENU_ACTIONS = {
+ NEW_TAB: 'new-tab',
+ TOGGLE_BOOKMARKS: 'toggle-bookmarks',
+ TOGGLE_HISTORY: 'toggle-history',
+ TOGGLE_BOOKMARK_BAR: 'toggle-bookmark-bar',
+ TOGGLE_THEME: 'toggle-theme',
+ OPEN_FLOAT_SEARCH: 'open-float-search',
+} as const;
+
+export type MenuAction = (typeof MENU_ACTIONS)[keyof typeof MENU_ACTIONS];
+
+export interface MenuShowPayload {
+ screenX: number;
+ screenY: number;
+ isBookmarkBarVisible: boolean;
+ theme: 'light' | 'dark';
+}
+
+export interface MenuInitPayload {
+ isBookmarkBarVisible: boolean;
+ theme: 'light' | 'dark';
+}
+
export const IPC_CHANNELS = {
TOGGLE_FLOAT: 'toggle-float',
FLOAT_NAVIGATE: 'float-navigate',
@@ -62,4 +85,8 @@ export const IPC_CHANNELS = {
HISTORY_GET: 'history-get',
HISTORY_CLEAR: 'history-clear',
HISTORY_CHANGED: 'history-changed',
+ MENU_SHOW: 'menu-show',
+ MENU_INIT: 'menu-init',
+ MENU_ACTION: 'menu-action',
+ MENU_ACTION_RELAY: 'menu-action-relay',
} as const;
diff --git a/src/shared/ipc-preload.ts b/src/shared/ipc-preload.ts
index 4631d74..8a1ba48 100644
--- a/src/shared/ipc-preload.ts
+++ b/src/shared/ipc-preload.ts
@@ -4,9 +4,13 @@ import type {
BookmarkUpsertPayload,
BrowserBounds,
HistorySnapshot,
+ MenuAction,
+ MenuInitPayload,
+ MenuShowPayload,
TabSnapshot,
TabsStateSnapshot,
} from './ipc-contract';
+import { MENU_ACTIONS } from './ipc-contract';
function parseStringPayload(payload: unknown, maxLen = 2048): string | null {
if (typeof payload !== 'string') {
@@ -202,3 +206,56 @@ export function parseHistorySnapshotsPayload(payload: unknown): HistorySnapshot[
return payload;
}
+
+const validMenuActions: ReadonlySet = new Set(Object.values(MENU_ACTIONS));
+
+export function parseMenuActionPayload(payload: unknown): MenuAction | null {
+ if (typeof payload !== 'string' || !validMenuActions.has(payload)) {
+ return null;
+ }
+
+ return payload as MenuAction;
+}
+
+export function parseMenuShowPayload(payload: unknown): MenuShowPayload | null {
+ if (!payload || typeof payload !== 'object') {
+ return null;
+ }
+
+ const p = payload as Record;
+ if (typeof p.screenX !== 'number' || typeof p.screenY !== 'number') {
+ return null;
+ }
+
+ if (typeof p.isBookmarkBarVisible !== 'boolean') {
+ return null;
+ }
+
+ if (p.theme !== 'light' && p.theme !== 'dark') {
+ return null;
+ }
+
+ return {
+ screenX: p.screenX,
+ screenY: p.screenY,
+ isBookmarkBarVisible: p.isBookmarkBarVisible,
+ theme: p.theme,
+ };
+}
+
+export function parseMenuInitPayload(payload: unknown): MenuInitPayload | null {
+ if (!payload || typeof payload !== 'object') {
+ return null;
+ }
+
+ const p = payload as Record;
+ if (typeof p.isBookmarkBarVisible !== 'boolean') {
+ return null;
+ }
+
+ if (p.theme !== 'light' && p.theme !== 'dark') {
+ return null;
+ }
+
+ return { isBookmarkBarVisible: p.isBookmarkBarVisible, theme: p.theme };
+}
diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts
index 60aa7b5..5fc493d 100644
--- a/src/shared/ipc.ts
+++ b/src/shared/ipc.ts
@@ -6,14 +6,21 @@ import type {
BookmarkUpsertPayload,
BrowserBounds,
HistorySnapshot,
+ MenuAction,
+ MenuInitPayload,
+ MenuShowPayload,
TabsStateSnapshot,
} from './ipc-contract';
-export { IPC_CHANNELS } from './ipc-contract';
+import { MENU_ACTIONS } from './ipc-contract';
+export { IPC_CHANNELS, MENU_ACTIONS } from './ipc-contract';
export type {
BookmarkSnapshot,
BookmarkUpsertPayload,
BrowserBounds,
HistorySnapshot,
+ MenuAction,
+ MenuInitPayload,
+ MenuShowPayload,
TabSnapshot,
TabsStateSnapshot,
} from './ipc-contract';
@@ -143,3 +150,41 @@ export function parseHistorySnapshotsPayload(payload: unknown): HistorySnapshot[
const parsedPayload = HistorySnapshotsPayloadSchema.safeParse(payload);
return parsedPayload.success ? parsedPayload.data : null;
}
+
+const MenuActionPayloadSchema = z.enum(
+ Object.values(MENU_ACTIONS) as [MenuAction, ...MenuAction[]],
+);
+
+const MenuShowPayloadSchema = z.object({
+ screenX: z.number(),
+ screenY: z.number(),
+ isBookmarkBarVisible: z.boolean(),
+ theme: z.enum(['light', 'dark']),
+});
+
+export function parseMenuActionPayload(payload: unknown): MenuAction | null {
+ const result = MenuActionPayloadSchema.safeParse(payload);
+ return result.success ? result.data : null;
+}
+
+export function parseMenuShowPayload(payload: unknown): MenuShowPayload | null {
+ const result = MenuShowPayloadSchema.safeParse(payload);
+ return result.success ? result.data : null;
+}
+
+export function parseMenuInitPayload(payload: unknown): MenuInitPayload | null {
+ if (!payload || typeof payload !== 'object') {
+ return null;
+ }
+
+ const p = payload as Record;
+ if (typeof p.isBookmarkBarVisible !== 'boolean') {
+ return null;
+ }
+
+ if (p.theme !== 'light' && p.theme !== 'dark') {
+ return null;
+ }
+
+ return { isBookmarkBarVisible: p.isBookmarkBarVisible, theme: p.theme };
+}
diff --git a/tests/renderer-contract.test.ts b/tests/renderer-contract.test.ts
index 6bc54e9..878f369 100644
--- a/tests/renderer-contract.test.ts
+++ b/tests/renderer-contract.test.ts
@@ -20,12 +20,9 @@ describe('renderer contract smoke', () => {
'btn-back',
'btn-forward',
'btn-reload',
- 'btn-theme',
'btn-bookmark',
- 'btn-bookmark-bar',
- 'btn-bookmarks',
- 'btn-history',
'btn-float',
+ 'btn-menu',
'bookmark-bar',
'bookmark-bar-list',
'bookmark-bar-empty',