experiments in a post-browser web
10
fork

Configure Feed

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

fix(page): resolve navbar show/hide and loading lifecycle failures

Two initialization bugs in page.js prevented the navbar from working:

1. TDZ (temporal dead zone): hideTimer and showSource were declared with
'let' after loadingLifecycle.startLoading() called show(), causing a
ReferenceError that silently aborted module execution. Moved the
declarations before the loadingLifecycle.onChange callback.

2. Unguarded webview methods: updateState() called webview.canGoBack()
during initial startLoading(), before the webview guest was attached.
The thrown error propagated up through startLoading() and prevented
all subsequent code from executing — including event listener
registrations for did-stop-loading, did-finish-load, and pubsub
subscriptions for page:show-navbar and trigger zone hover.

This caused all three reported symptoms:
- Navbar never shown on hover (trigger zone listener never registered)
- Cmd+L never showed navbar (pubsub subscription never registered)
- Loading animation never stopped (did-stop-loading listener never registered)

Added Playwright tests covering navbar show during loading, auto-hide
after load, Cmd+L pubsub show, and trigger zone hover show.

+390 -9
+158
app/lib/card-helpers.js
··· 1 + /** 2 + * Shared card helpers for building peek-card elements. 3 + * 4 + * Composable utilities that eliminate duplication across groups, search, 5 + * and tags extensions when constructing standard card header/footer slots, 6 + * favicons, title extraction, URL formatting, and click-debounce wrappers. 7 + */ 8 + 9 + // ── Constants ──────────────────────────────────────────────────────────── 10 + 11 + const GLOBE_FAVICON = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F310}</text></svg>'; 12 + 13 + // ── Header / Footer slot builders ──────────────────────────────────────── 14 + 15 + /** 16 + * Create the standard header-slot div with flex layout. 17 + * 18 + * @param {HTMLElement} iconEl - An icon element (img, span, div, etc.) 19 + * @param {string} titleText - Text for the h2 title 20 + * @returns {HTMLElement} div[slot="header"] 21 + */ 22 + export const createHeaderSlot = (iconEl, titleText) => { 23 + const header = document.createElement('div'); 24 + header.slot = 'header'; 25 + header.style.display = 'flex'; 26 + header.style.alignItems = 'center'; 27 + header.style.gap = '8px'; 28 + 29 + if (iconEl) header.appendChild(iconEl); 30 + 31 + const title = document.createElement('h2'); 32 + title.className = 'card-title'; 33 + title.textContent = titleText; 34 + title.style.margin = '0'; 35 + title.style.flex = '1'; 36 + title.style.minWidth = '0'; 37 + 38 + header.appendChild(title); 39 + return header; 40 + }; 41 + 42 + /** 43 + * Create the standard footer-slot div with flex justify-between layout. 44 + * 45 + * @param {HTMLElement|null} leftEl - Left-side element (URL span, preview, etc.) 46 + * @param {HTMLElement|null} rightEl - Right-side element (visit count, etc.) 47 + * @returns {HTMLElement} div[slot="footer"] 48 + */ 49 + export const createFooterSlot = (leftEl, rightEl) => { 50 + const footer = document.createElement('div'); 51 + footer.slot = 'footer'; 52 + footer.className = 'card-meta'; 53 + footer.style.cssText = 'display:flex;justify-content:space-between;align-items:center;gap:8px;'; 54 + 55 + if (leftEl) footer.appendChild(leftEl); 56 + if (rightEl) footer.appendChild(rightEl); 57 + return footer; 58 + }; 59 + 60 + // ── Favicon helper ─────────────────────────────────────────────────────── 61 + 62 + /** 63 + * Create a favicon <img> with onerror fallback. 64 + * 65 + * @param {string} [url] - Favicon URL. Falls back to globe emoji SVG. 66 + * @param {string} [fallbackSvg] - Custom fallback SVG data-URI (default: globe emoji) 67 + * @returns {HTMLImageElement} 68 + */ 69 + export const createFaviconEl = (url, fallbackSvg) => { 70 + const fallback = fallbackSvg || GLOBE_FAVICON; 71 + const img = document.createElement('img'); 72 + img.className = 'card-favicon'; 73 + img.src = url || fallback; 74 + img.onerror = () => { 75 + img.src = fallback; 76 + img.onerror = null; 77 + }; 78 + return img; 79 + }; 80 + 81 + // ── Title extraction ───────────────────────────────────────────────────── 82 + 83 + /** 84 + * Extract a display title from an item/address object. 85 + * 86 + * Priority: item.title > metadata.title > fallback 87 + * 88 + * @param {object} item - Item or Address object 89 + * @param {string} [fallback] - Fallback when no title found (default: item URL or '(untitled)') 90 + * @returns {string} 91 + */ 92 + export const extractTitle = (item, fallback) => { 93 + let title = item.title; 94 + if (!title && item.metadata) { 95 + try { 96 + const meta = typeof item.metadata === 'string' ? JSON.parse(item.metadata) : item.metadata; 97 + title = meta.title; 98 + } catch (e) { 99 + // Ignore parse errors 100 + } 101 + } 102 + return title || fallback || item.uri || item.content || '(untitled)'; 103 + }; 104 + 105 + // ── URL formatting ─────────────────────────────────────────────────────── 106 + 107 + /** 108 + * Format a URL for display: strips protocol, shows hostname + truncated path. 109 + * 110 + * @param {string} url 111 + * @returns {string} 112 + */ 113 + export const formatUrl = (url) => { 114 + if (!url) return ''; 115 + try { 116 + const u = new URL(url); 117 + return u.hostname + (u.pathname !== '/' ? u.pathname : ''); 118 + } catch { 119 + return url; 120 + } 121 + }; 122 + 123 + /** 124 + * Create a styled URL span element for use inside a footer slot. 125 + * 126 + * @param {string} text - Display text (typically from formatUrl) 127 + * @returns {HTMLSpanElement} 128 + */ 129 + export const createUrlSpan = (text) => { 130 + const span = document.createElement('span'); 131 + span.className = 'card-url'; 132 + span.textContent = text; 133 + span.style.cssText = 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;opacity:0.7;'; 134 + return span; 135 + }; 136 + 137 + // ── Click debounce ─────────────────────────────────────────────────────── 138 + 139 + /** 140 + * Wrap a card-click handler with an isOpening debounce flag. 141 + * Prevents rapid double-clicks from opening multiple windows. 142 + * 143 + * @param {HTMLElement} card - The peek-card element 144 + * @param {Function} handler - Async handler called on card-click 145 + * @param {number} [cooldown=500] - Cooldown in ms before allowing another click 146 + */ 147 + export const withClickDebounce = (card, handler, cooldown = 500) => { 148 + let isOpening = false; 149 + card.addEventListener('card-click', async (...args) => { 150 + if (isOpening) return; 151 + isOpening = true; 152 + try { 153 + await handler(...args); 154 + } finally { 155 + setTimeout(() => { isOpening = false; }, cooldown); 156 + } 157 + }); 158 + };
+32 -5
app/page/page.js
··· 19 19 20 20 import api from '../api.js'; 21 21 22 + console.log('[page] Script loaded'); 23 + 22 24 const DEBUG = false; 23 25 24 26 // Cache our window ID for filtering GLOBAL pubsub messages ··· 124 126 }; 125 127 const modeIndicator = document.getElementById('mode-indicator'); 126 128 const widgetContainer = document.getElementById('widget-container'); 129 + 130 + console.log('[page] DOM elements initialized'); 127 131 128 132 // Disable component-level keyboard shortcuts — page.js handles them via pubsub 129 133 navbar.shortcuts = false; ··· 286 290 // --- Set up webview partition and load URL --- 287 291 288 292 async function initWebview() { 293 + console.log('[page] initWebview: getting partition...'); 289 294 try { 290 295 const result = await api.profiles.getPartition(); 291 296 if (result.success && result.data?.partition) { ··· 298 303 console.error('[page] Failed to get partition:', err); 299 304 } 300 305 306 + console.log('[page] initWebview: partition resolved, setting src:', targetUrl); 301 307 webview.src = targetUrl; 308 + console.log('[page] initWebview: src set'); 302 309 navbar.setUrl(targetUrl); 303 310 } 304 311 ··· 335 342 } 336 343 }; 337 344 345 + // --- Show / Hide navbar state --- 346 + // Declared here (before loadingLifecycle.onChange) so they are initialized when 347 + // startLoading() triggers show() synchronously during module execution. 348 + let hideTimer = null; 349 + let showSource = null; // 'hover', 'shortcut', or 'loading' 350 + 338 351 // Visual effect module — applies/removes CSS classes based on lifecycle state 339 352 // Also shows/hides navbar with loading spinner during page load. 340 353 loadingLifecycle.onChange((state) => { 341 354 if (state === 'loading') { 355 + console.log('[page] Loading state: loading'); 342 356 webview.classList.add('loading'); 343 357 webview.classList.remove('loaded'); 344 358 navbar.loading = true; 345 359 show({ source: 'loading' }); 346 360 } else if (state === 'loaded') { 361 + console.log('[page] Loading state: loaded'); 347 362 webview.classList.remove('loading'); 348 363 webview.classList.add('loaded'); 349 364 navbar.loading = false; ··· 386 401 }); 387 402 388 403 webview.addEventListener('did-stop-loading', () => { 404 + console.log('[page] did-stop-loading fired'); 389 405 loadingLifecycle.stopLoading(); 390 406 }); 391 407 ··· 838 854 // --- State display --- 839 855 840 856 function updateState() { 841 - navbar.canGoBack = webview.canGoBack(); 842 - navbar.canGoForward = webview.canGoForward(); 857 + // Guard: webview methods (canGoBack, canGoForward) are only available after the 858 + // guest process is attached. Calling them before src is set throws an error. 859 + // During initial loading (show() called from startLoading()), the webview isn't 860 + // ready yet, so we silently skip. 861 + try { 862 + navbar.canGoBack = webview.canGoBack(); 863 + navbar.canGoForward = webview.canGoForward(); 864 + } catch { 865 + // Webview not ready yet — will be updated on did-navigate 866 + } 843 867 } 844 868 845 869 // --- Show / Hide navbar --- 846 870 // When the navbar becomes visible, the window must expand upward to accommodate it. 847 871 // When it hides, the window contracts back down. 848 - 849 - let hideTimer = null; 850 - let showSource = null; // 'hover' or 'shortcut' 872 + // NOTE: hideTimer and showSource are declared near the top of the file (before 873 + // loadingLifecycle.onChange) to avoid TDZ errors when startLoading() calls show() 874 + // during module initialization. 851 875 852 876 function show(opts) { 877 + console.log('[page] show() called, source:', opts?.source); 853 878 if (hideTimer) { 854 879 clearTimeout(hideTimer); 855 880 hideTimer = null; ··· 931 956 } 932 957 933 958 triggerZone.addEventListener('mouseenter', () => { 959 + console.log('[page] Trigger zone mouseenter'); 934 960 // Suppress navbar auto-show during OAuth flows (Level 2) to avoid 935 961 // interrupting credential entry. User can still Cmd+L to force show. 936 962 if (isOnOAuthPage) { ··· 954 980 955 981 // Cmd+L: show navbar with URL focus 956 982 api.subscribe('page:show-navbar', (msg) => { 983 + console.log('[page] page:show-navbar received, windowId:', msg.windowId, 'myWindowId:', myWindowId); 957 984 if (msg.windowId != null && msg.windowId !== myWindowId) return; 958 985 show({ focusUrl: true, source: 'shortcut' }); 959 986 }, api.scopes.GLOBAL);
+12 -4
backend/electron/ipc.ts
··· 2370 2370 // Track window position for display-watcher home display restoration 2371 2371 trackWindow(win); 2372 2372 2373 - // Forward console logs from window to main process stdout (for debugging) 2373 + // Forward console logs from window to main process stdout (for debugging). 2374 + // Errors/warnings always forwarded; info/debug only when DEBUG is on. 2375 + // For canvas pages, the loadUrl is peek://app/page/index.html so this 2376 + // catches page host errors. The label uses the original url for clarity. 2377 + const consoleLabel = useCanvas ? `page-host:${url.substring(0, 60)}` : url.replace('peek://', ''); 2374 2378 win.webContents.on('console-message', (event) => { 2375 - // Only forward for peek:// URLs to avoid noise 2376 - if (url.startsWith('peek://')) { 2377 - DEBUG && console.log(`[${url.replace('peek://', '')}] ${event.message}`); 2379 + const level = parseInt(String(event.level), 10); 2380 + if (level >= 2) { 2381 + console.error(`[${consoleLabel}] ${event.message}`); 2382 + } else if (DEBUG && url.startsWith('peek://')) { 2383 + console.log(`[${consoleLabel}] ${event.message}`); 2378 2384 } 2379 2385 }); 2380 2386 ··· 2547 2553 DEBUG && console.log('Set window mode:', win.id, 'url:', modeUrl, 'mode:', detectedMode); 2548 2554 2549 2555 // Load the URL AFTER mode/context is set, so page.js can read inherited mode 2556 + console.log(`[page-host:${win.id}] Loading: ${loadUrl.substring(0, 120)}`); 2550 2557 await win.loadURL(loadUrl); 2558 + console.log(`[page-host:${win.id}] loadURL resolved`); 2551 2559 2552 2560 // Background detection for non-canvas web pages (slides, modals, quick-views). 2553 2561 // Canvas pages get this via the webview dom-ready handler in page.js.
+188
tests/desktop/page-navbar.spec.ts
··· 1 + /** 2 + * Page Host Navbar Tests 3 + * 4 + * Tests the navbar show/hide behavior in canvas page windows: 5 + * - Loading lifecycle shows/hides navbar 6 + * - Cmd+L shortcut shows navbar with URL focus (via pubsub) 7 + * - Hover trigger zone shows navbar 8 + * 9 + * Run with: 10 + * BACKEND=electron yarn test:electron 11 + */ 12 + 13 + import { test, expect, DesktopApp, getSharedApp, closeSharedApp } from '../fixtures/desktop-app'; 14 + import { Page } from '@playwright/test'; 15 + import { waitForExtensionsReady } from '../helpers/window-utils'; 16 + 17 + // Shared app instance 18 + let sharedApp: DesktopApp; 19 + let sharedBgWindow: Page; 20 + 21 + test.beforeAll(async () => { 22 + sharedApp = await getSharedApp(); 23 + sharedBgWindow = await sharedApp.getBackgroundWindow(); 24 + await waitForExtensionsReady(sharedBgWindow); 25 + }); 26 + 27 + test.afterAll(async () => { 28 + await closeSharedApp(); 29 + }); 30 + 31 + // ============================================================================ 32 + // Helper: Open a web URL as a canvas page and get the page host window 33 + // ============================================================================ 34 + 35 + async function openCanvasPage( 36 + bgWindow: Page, 37 + url: string 38 + ): Promise<{ pageWindow: Page; windowId: number }> { 39 + const result = await bgWindow.evaluate(async (targetUrl: string) => { 40 + return await (window as any).app.window.open(targetUrl, { 41 + width: 800, 42 + height: 600, 43 + }); 44 + }, url); 45 + expect(result.success).toBe(true); 46 + const windowId = result.id; 47 + 48 + // The URL gets rewritten to peek://app/page/index.html?url=... 49 + const pageWindow = await sharedApp.getWindow('page/index.html', 15000); 50 + expect(pageWindow).toBeTruthy(); 51 + 52 + return { pageWindow, windowId }; 53 + } 54 + 55 + // ============================================================================ 56 + // Helper: Wait for page host to be fully loaded (webview has 'loaded' class) 57 + // ============================================================================ 58 + 59 + async function waitForPageLoaded(pageWindow: Page, timeout = 30000): Promise<void> { 60 + await pageWindow.waitForFunction( 61 + () => { 62 + const webview = document.getElementById('content'); 63 + return webview && webview.classList.contains('loaded'); 64 + }, 65 + undefined, 66 + { timeout } 67 + ); 68 + } 69 + 70 + // ============================================================================ 71 + // Helper: Wait for navbar to reach a specific visibility state 72 + // ============================================================================ 73 + 74 + async function waitForNavbarVisible(pageWindow: Page, visible: boolean, timeout = 10000): Promise<void> { 75 + await pageWindow.waitForFunction( 76 + (vis: boolean) => { 77 + const navbar = document.getElementById('navbar'); 78 + if (!navbar) return false; 79 + const hasClass = navbar.classList.contains('visible'); 80 + return vis ? hasClass : !hasClass; 81 + }, 82 + visible, 83 + { timeout } 84 + ); 85 + } 86 + 87 + // ============================================================================ 88 + // Page Navbar Tests 89 + // ============================================================================ 90 + 91 + test.describe('Page Navbar @desktop', () => { 92 + test('navbar shows during loading and hides after load completes', async () => { 93 + const { pageWindow, windowId } = await openCanvasPage( 94 + sharedBgWindow, 95 + 'https://example.com' 96 + ); 97 + 98 + // Wait for page.js to initialize (DOM elements ready) 99 + await pageWindow.waitForFunction( 100 + () => document.getElementById('navbar') !== null, 101 + undefined, 102 + { timeout: 10000 } 103 + ); 104 + 105 + // The loading lifecycle should show the navbar during loading. 106 + await waitForNavbarVisible(pageWindow, true, 15000); 107 + 108 + // Wait for loading to complete: webview should get 'loaded' class 109 + await waitForPageLoaded(pageWindow); 110 + 111 + // After loading completes, navbar should auto-hide (since showSource was 'loading') 112 + await waitForNavbarVisible(pageWindow, false, 10000); 113 + 114 + // Clean up: close the page window 115 + await sharedBgWindow.evaluate(async (id: number) => { 116 + return await (window as any).app.window.close(id); 117 + }, windowId); 118 + }); 119 + 120 + test('Cmd+L shows navbar via pubsub', async () => { 121 + const { pageWindow, windowId } = await openCanvasPage( 122 + sharedBgWindow, 123 + 'https://example.com' 124 + ); 125 + 126 + // Wait for page.js to fully initialize and loading to complete 127 + await pageWindow.waitForFunction( 128 + () => document.getElementById('navbar') !== null, 129 + undefined, 130 + { timeout: 10000 } 131 + ); 132 + await waitForPageLoaded(pageWindow); 133 + 134 + // Wait for navbar to be hidden after loading 135 + await waitForNavbarVisible(pageWindow, false, 10000); 136 + 137 + // Simulate Cmd+L via pubsub (same mechanism as the before-input-event handler) 138 + await sharedBgWindow.evaluate(async (wid: number) => { 139 + (window as any).app.publish( 140 + 'page:show-navbar', 141 + { windowId: wid }, 142 + (window as any).app.scopes.GLOBAL 143 + ); 144 + }, windowId); 145 + 146 + // Navbar should become visible 147 + await waitForNavbarVisible(pageWindow, true, 5000); 148 + 149 + // Clean up 150 + await sharedBgWindow.evaluate(async (id: number) => { 151 + return await (window as any).app.window.close(id); 152 + }, windowId); 153 + }); 154 + 155 + test('trigger zone hover shows navbar', async () => { 156 + const { pageWindow, windowId } = await openCanvasPage( 157 + sharedBgWindow, 158 + 'https://example.com' 159 + ); 160 + 161 + // Wait for page.js to fully initialize and loading to complete 162 + await pageWindow.waitForFunction( 163 + () => document.getElementById('navbar') !== null, 164 + undefined, 165 + { timeout: 10000 } 166 + ); 167 + await waitForPageLoaded(pageWindow); 168 + 169 + // Wait for navbar to be hidden 170 + await waitForNavbarVisible(pageWindow, false, 10000); 171 + 172 + // Simulate mouseenter on the trigger zone 173 + await pageWindow.evaluate(() => { 174 + const triggerZone = document.getElementById('trigger-zone'); 175 + if (triggerZone) { 176 + triggerZone.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); 177 + } 178 + }); 179 + 180 + // Navbar should become visible 181 + await waitForNavbarVisible(pageWindow, true, 5000); 182 + 183 + // Clean up 184 + await sharedBgWindow.evaluate(async (id: number) => { 185 + return await (window as any).app.window.close(id); 186 + }, windowId); 187 + }); 188 + });