experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(darkmode): implement 3-tier dark mode system for web pages

Tier 1 (system): Sets nativeTheme.themeSource to dark so prefers-color-scheme: dark
is true for all webviews. Sites with dark mode CSS switch automatically. Zero cost.

Tier 2 (force): Enables Chromium WebContentsForceDark via command-line switch with
CIELAB-based inversion and image skip. Operates in the rendering pipeline. Requires
app restart when toggling.

Tier 3 (per-site Dark Reader): Stubbed as TODO for future implementation.

Setting stored in extension_settings table as darkMode key under core extensionId.
Read early before app.ready via temporary read-only SQLite connection to apply
command-line switches. IPC handlers (darkMode:get/set) for runtime changes.

Settings UI added as Dark Mode section with 3-button selector (Off/System/Force)
and restart notice when Tier 2 changes.

+339 -4
+160 -1
app/settings/settings.js
··· 712 712 return container; 713 713 }; 714 714 715 + // Render dark mode settings for web pages 716 + const renderDarkModeSettings = async () => { 717 + const container = document.createElement('div'); 718 + 719 + // Description 720 + const desc = document.createElement('p'); 721 + desc.className = 'help-text'; 722 + desc.style.marginBottom = '16px'; 723 + desc.textContent = 'Control how dark mode is applied to web pages loaded in Peek.'; 724 + container.appendChild(desc); 725 + 726 + // Load current setting 727 + let currentMode = 'system'; 728 + let tier2Active = false; 729 + 730 + try { 731 + const result = await api.darkMode.get(); 732 + if (result.success && result.data) { 733 + currentMode = result.data.mode; 734 + tier2Active = result.data.tier2Active; 735 + } 736 + } catch (err) { 737 + console.error('[settings] Failed to load dark mode setting:', err); 738 + } 739 + 740 + // Mode selector (button group like color scheme) 741 + const modeSection = document.createElement('div'); 742 + modeSection.className = 'form-section'; 743 + 744 + const modeTitle = document.createElement('h3'); 745 + modeTitle.className = 'form-section-title'; 746 + modeTitle.textContent = 'Dark Mode for Web Pages'; 747 + modeSection.appendChild(modeTitle); 748 + 749 + const modeGroup = document.createElement('div'); 750 + modeGroup.style.cssText = 'display: flex; gap: 8px;'; 751 + 752 + const modes = [ 753 + { value: 'off', label: 'Off', desc: 'Pages render as-is' }, 754 + { value: 'system', label: 'System', desc: 'Sites that support dark mode will use it' }, 755 + { value: 'force', label: 'Force', desc: 'Force dark mode on all sites' }, 756 + ]; 757 + 758 + // Restart notice (hidden by default) 759 + const restartNotice = document.createElement('div'); 760 + restartNotice.className = 'help-text'; 761 + restartNotice.style.cssText = 'margin-top: 8px; color: var(--base09); display: none;'; 762 + restartNotice.textContent = 'Restart required for this change to take full effect.'; 763 + 764 + const updateButtons = (activeValue) => { 765 + modeGroup.querySelectorAll('button').forEach(btn => { 766 + const isActive = btn.dataset.mode === activeValue; 767 + btn.style.background = isActive ? 'var(--base0D)' : 'var(--bg-tertiary)'; 768 + btn.style.borderColor = isActive ? 'var(--base0D)' : 'var(--base03)'; 769 + btn.style.color = isActive ? 'white' : 'var(--text-primary)'; 770 + btn.style.fontWeight = isActive ? '600' : '400'; 771 + }); 772 + }; 773 + 774 + modes.forEach(mode => { 775 + const btn = document.createElement('button'); 776 + btn.textContent = mode.label; 777 + btn.dataset.mode = mode.value; 778 + const isActive = currentMode === mode.value; 779 + btn.style.cssText = ` 780 + padding: 8px 16px; 781 + font-size: 13px; 782 + background: ${isActive ? 'var(--base0D)' : 'var(--bg-tertiary)'}; 783 + border: 2px solid ${isActive ? 'var(--base0D)' : 'var(--base03)'}; 784 + border-radius: 6px; 785 + color: ${isActive ? 'white' : 'var(--text-primary)'}; 786 + cursor: pointer; 787 + flex: 1; 788 + font-weight: ${isActive ? '600' : '400'}; 789 + `; 790 + btn.addEventListener('click', async () => { 791 + try { 792 + const result = await api.darkMode.set(mode.value); 793 + if (result.success) { 794 + currentMode = mode.value; 795 + updateButtons(mode.value); 796 + // Update description 797 + modeDesc.textContent = modes.find(m => m.value === mode.value)?.desc || ''; 798 + // Show restart notice if Tier 2 changed 799 + if (result.data?.restartRequired) { 800 + restartNotice.style.display = 'block'; 801 + } else { 802 + restartNotice.style.display = 'none'; 803 + } 804 + } 805 + } catch (err) { 806 + console.error('[settings] Failed to set dark mode:', err); 807 + } 808 + }); 809 + modeGroup.appendChild(btn); 810 + }); 811 + 812 + modeSection.appendChild(modeGroup); 813 + 814 + // Current mode description 815 + const modeDesc = document.createElement('div'); 816 + modeDesc.className = 'help-text'; 817 + modeDesc.style.marginTop = '8px'; 818 + modeDesc.textContent = modes.find(m => m.value === currentMode)?.desc || ''; 819 + modeSection.appendChild(modeDesc); 820 + 821 + modeSection.appendChild(restartNotice); 822 + 823 + container.appendChild(modeSection); 824 + 825 + // Tier explanation 826 + const tierSection = document.createElement('div'); 827 + tierSection.className = 'form-section'; 828 + tierSection.style.marginTop = '24px'; 829 + 830 + const tierTitle = document.createElement('h3'); 831 + tierTitle.className = 'form-section-title'; 832 + tierTitle.textContent = 'How It Works'; 833 + tierSection.appendChild(tierTitle); 834 + 835 + const tierDesc = document.createElement('div'); 836 + tierDesc.className = 'help-text'; 837 + tierDesc.innerHTML = ` 838 + <p style="margin-bottom: 8px;"><strong>System</strong> sets <code>prefers-color-scheme: dark</code> so sites with built-in dark mode support switch automatically. Zero performance cost.</p> 839 + <p style="margin-bottom: 8px;"><strong>Force</strong> additionally enables Chromium's auto-dark mode, which uses CIELAB-based color inversion in the rendering pipeline. This forces dark mode on all sites, including those without native support. Images are preserved. Changing this requires an app restart.</p> 840 + <p><strong>Per-site dark mode</strong> (via Dark Reader) is planned for a future update.</p> 841 + `; 842 + tierSection.appendChild(tierDesc); 843 + 844 + container.appendChild(tierSection); 845 + 846 + return container; 847 + }; 848 + 715 849 // Render sync settings 716 850 const renderSyncSettings = async () => { 717 851 const container = document.createElement('div'); ··· 2383 2517 2384 2518 contentArea.appendChild(privacySection); 2385 2519 2386 - // Add Sync management section (between Privacy and Themes) 2520 + // Add Dark Mode section (between Privacy and Sync) 2521 + const darkModeNav = document.createElement('a'); 2522 + darkModeNav.className = 'nav-item'; 2523 + darkModeNav.textContent = 'Dark Mode'; 2524 + darkModeNav.dataset.section = 'dark-mode'; 2525 + darkModeNav.addEventListener('click', () => showSection('dark-mode')); 2526 + sidebarNav.appendChild(darkModeNav); 2527 + 2528 + // Create dark mode section with async content 2529 + const darkModeSection = document.createElement('div'); 2530 + darkModeSection.className = 'section'; 2531 + darkModeSection.id = 'section-dark-mode'; 2532 + 2533 + const darkModeTitle = document.createElement('h2'); 2534 + darkModeTitle.className = 'section-title'; 2535 + darkModeTitle.textContent = 'Dark Mode'; 2536 + darkModeSection.appendChild(darkModeTitle); 2537 + 2538 + // Load dark mode content async 2539 + renderDarkModeSettings().then(content => { 2540 + darkModeSection.appendChild(content); 2541 + }); 2542 + 2543 + contentArea.appendChild(darkModeSection); 2544 + 2545 + // Add Sync management section (between Dark Mode and Themes) 2387 2546 const syncNav = document.createElement('a'); 2388 2547 syncNav.className = 'nav-item'; 2389 2548 syncNav.textContent = 'Sync';
+40 -1
backend/electron/entry.ts
··· 8 8 import { app, globalShortcut } from 'electron'; 9 9 import fs from 'node:fs'; 10 10 import path from 'node:path'; 11 + import Database from 'better-sqlite3'; 11 12 import unhandled from 'electron-unhandled'; 12 13 13 14 // Import from local backend modules ··· 531 532 // macOS: handle open-url event (must be registered before app.whenReady) 532 533 registerExternalUrlHandlers(); 533 534 535 + // ***** Dark Mode (early read before app.ready) ***** 536 + // Read dark mode setting from database before app.ready so we can: 537 + // 1. Set nativeTheme.themeSource (Tier 1) 538 + // 2. Apply WebContentsForceDark command-line switch (Tier 2, must be before app.ready) 539 + let _darkModeSetting = 'system'; // default 540 + const dbPath = path.join(profileDataPath, 'datastore.sqlite'); 541 + if (fs.existsSync(dbPath)) { 542 + try { 543 + const earlyDb = new Database(dbPath, { readonly: true, fileMustExist: true }); 544 + const row = earlyDb.prepare( 545 + "SELECT value FROM extension_settings WHERE extensionId = 'core' AND key = 'darkMode'" 546 + ).get() as { value: string } | undefined; 547 + if (row?.value) { 548 + // Value is JSON-encoded (stored via setThemeSetting which JSON.stringify's) 549 + try { 550 + _darkModeSetting = JSON.parse(row.value); 551 + } catch { 552 + _darkModeSetting = row.value; 553 + } 554 + } 555 + earlyDb.close(); 556 + DEBUG && console.log('[darkMode] Early read from db:', _darkModeSetting); 557 + } catch (err) { 558 + DEBUG && console.log('[darkMode] Early db read failed (using default):', err); 559 + } 560 + } 561 + 562 + // Tier 2: Apply WebContentsForceDark command-line switch for 'force' mode 563 + // This must be set before app.ready. Uses CIELAB-based inversion and skips images. 564 + if (_darkModeSetting === 'force') { 565 + app.commandLine.appendSwitch( 566 + 'enable-features', 567 + 'WebContentsForceDark:inversion_method/cielab_based/image_behavior/none' 568 + ); 569 + DEBUG && console.log('[darkMode] Tier 2: WebContentsForceDark enabled via command-line switch'); 570 + } 571 + 534 572 // Configure app before ready (registers protocol scheme, sets theme) 535 573 configure({ 536 574 rootDir: ROOT_DIR, ··· 538 576 userDataPath: defaultUserDataPath, 539 577 profile: PROFILE, 540 578 isDev: DEBUG, 541 - isTest: PROFILE.startsWith('test') 579 + isTest: PROFILE.startsWith('test'), 580 + darkMode: _darkModeSetting, 542 581 }); 543 582 544 583 // Register window-all-closed handler
+2
backend/electron/index.ts
··· 193 193 registerMiscHandlers, 194 194 registerAllHandlers, 195 195 restoreSavedTheme, 196 + getDarkModeSetting, 197 + applyDarkModeSetting, 196 198 } from './ipc.js'; 197 199 198 200 // Window helpers
+88
backend/electron/ipc.ts
··· 1768 1768 // Keep old function name for compatibility but call new one 1769 1769 export function registerDarkModeHandlers(): void { 1770 1770 registerThemeHandlers(); 1771 + registerDarkModeSettingHandlers(); 1772 + } 1773 + 1774 + /** 1775 + * Dark mode setting storage key 1776 + */ 1777 + const DARK_MODE_KEY = 'darkMode'; 1778 + 1779 + /** 1780 + * Get the dark mode setting from datastore 1781 + * Returns 'off' | 'system' | 'force', defaults to 'system' 1782 + */ 1783 + export function getDarkModeSetting(): string { 1784 + return getThemeSetting(DARK_MODE_KEY) || 'system'; 1785 + } 1786 + 1787 + /** 1788 + * Set the dark mode setting in datastore 1789 + */ 1790 + function setDarkModeSetting(value: string): void { 1791 + setThemeSetting(DARK_MODE_KEY, value); 1792 + } 1793 + 1794 + /** 1795 + * Apply dark mode setting to nativeTheme 1796 + * Tier 1: When 'system' or 'force', set nativeTheme.themeSource = 'dark' 1797 + * so prefers-color-scheme: dark is true for all webviews. 1798 + * When 'off', respect the color scheme theme setting. 1799 + */ 1800 + export function applyDarkModeSetting(mode: string): void { 1801 + if (mode === 'off') { 1802 + // Respect the user's color scheme theme setting 1803 + const colorScheme = getThemeSetting(THEME_COLOR_SCHEME_KEY) || 'system'; 1804 + nativeTheme.themeSource = colorScheme as 'system' | 'light' | 'dark'; 1805 + } else { 1806 + // 'system' or 'force': set nativeTheme to dark 1807 + // This makes prefers-color-scheme: dark true for all webviews 1808 + nativeTheme.themeSource = 'dark'; 1809 + } 1810 + } 1811 + 1812 + /** 1813 + * Register dark mode setting IPC handlers 1814 + */ 1815 + function registerDarkModeSettingHandlers(): void { 1816 + // Get current dark mode setting 1817 + ipcMain.handle('darkMode:get', () => { 1818 + const mode = getDarkModeSetting(); 1819 + return { 1820 + success: true, 1821 + data: { 1822 + mode, 1823 + // Tier 2 (WebContentsForceDark) requires app restart to take effect 1824 + tier2Active: app.commandLine.hasSwitch('enable-features') && 1825 + (app.commandLine.getSwitchValue('enable-features') || '').includes('WebContentsForceDark'), 1826 + } 1827 + }; 1828 + }); 1829 + 1830 + // Set dark mode setting 1831 + ipcMain.handle('darkMode:set', (_ev, mode: string) => { 1832 + if (!['off', 'system', 'force'].includes(mode)) { 1833 + return { success: false, error: 'Invalid dark mode value. Must be "off", "system", or "force".' }; 1834 + } 1835 + 1836 + const previousMode = getDarkModeSetting(); 1837 + setDarkModeSetting(mode); 1838 + 1839 + // Apply Tier 1 immediately (nativeTheme) 1840 + applyDarkModeSetting(mode); 1841 + 1842 + // Check if Tier 2 change requires restart 1843 + const tier2Changed = (previousMode === 'force') !== (mode === 'force'); 1844 + 1845 + // Broadcast change to all windows 1846 + const windows = BrowserWindow.getAllWindows(); 1847 + for (const win of windows) { 1848 + win.webContents.send('darkMode:changed', { mode }); 1849 + } 1850 + 1851 + return { 1852 + success: true, 1853 + data: { 1854 + mode, 1855 + restartRequired: tier2Changed, 1856 + } 1857 + }; 1858 + }); 1771 1859 } 1772 1860 1773 1861 /**
+10 -2
backend/electron/main.ts
··· 28 28 profile: string; 29 29 isDev: boolean; 30 30 isTest: boolean; 31 + darkMode?: string; // 'off' | 'system' | 'force' 31 32 } 32 33 33 34 // App state ··· 69 70 export function configure(cfg: AppConfig): void { 70 71 config = cfg; 71 72 72 - // Use system theme 73 - nativeTheme.themeSource = 'system'; 73 + // Tier 1 dark mode: Set nativeTheme based on dark mode setting 74 + // 'off' = respect system theme, 'system'/'force' = force dark for prefers-color-scheme 75 + const darkMode = cfg.darkMode || 'system'; 76 + if (darkMode === 'off') { 77 + nativeTheme.themeSource = 'system'; 78 + } else { 79 + // 'system' or 'force': make prefers-color-scheme: dark true for all webviews 80 + nativeTheme.themeSource = 'dark'; 81 + } 74 82 75 83 // Register custom protocol scheme (must be before app.ready) 76 84 registerScheme();
+39
preload.js
··· 836 836 } 837 837 }; 838 838 839 + // ========== Dark Mode API ========== 840 + 841 + /** 842 + * Dark Mode API - 3-tier dark mode system for web pages 843 + * 844 + * Tier 1 (system): Sets prefers-color-scheme: dark — sites with dark mode support switch automatically 845 + * Tier 2 (force): Chromium WebContentsForceDark — CIELAB-based inversion for all sites 846 + * Tier 3 (per-site): Dark Reader injection — TODO, not yet implemented 847 + */ 848 + api.darkMode = { 849 + /** 850 + * Get current dark mode setting 851 + * @returns {Promise<{success: boolean, data?: {mode: string, tier2Active: boolean}}>} 852 + */ 853 + get: () => { 854 + return ipcRenderer.invoke('darkMode:get'); 855 + }, 856 + 857 + /** 858 + * Set dark mode setting 859 + * @param {string} mode - 'off' | 'system' | 'force' 860 + * @returns {Promise<{success: boolean, data?: {mode: string, restartRequired: boolean}, error?: string}>} 861 + */ 862 + set: (mode) => { 863 + return ipcRenderer.invoke('darkMode:set', mode); 864 + }, 865 + 866 + /** 867 + * Listen for dark mode changes 868 + * @param {function} callback - Called with { mode: string } 869 + * @returns {function} Unsubscribe function 870 + */ 871 + onChange: (callback) => { 872 + const handler = (_event, data) => callback(data); 873 + ipcRenderer.on('darkMode:changed', handler); 874 + return () => ipcRenderer.removeListener('darkMode:changed', handler); 875 + }, 876 + }; 877 + 839 878 // ========== Bundled Web Extensions API ========== 840 879 841 880 /**