experiments in a post-browser web
10
fork

Configure Feed

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

fix(page): eliminate one-frame visual flash on navbar show/hide

Fade center column to invisible before calling setBounds() so the
compositor desync (OS resizes NSWindow before Chromium re-renders)
shows a transparent frame instead of shifted/stale pixels.

+309 -8
+14 -8
app/page/page.js
··· 917 917 // generation and bails out. 918 918 let navbarGeneration = 0; 919 919 920 - function show(opts) { 920 + async function show(opts) { 921 921 console.log('[page] show() called, source:', opts?.source); 922 922 navbarGeneration++; 923 923 if (hideTimer) { ··· 938 938 navbar.focusUrl(); 939 939 } 940 940 941 + // Fade-to-invisible before expanding window to prevent compositor desync flash. 942 + // Same technique as hide() — make content invisible so the stale pixel buffer 943 + // during resize is transparent, then restore after setBounds completes. 944 + centerColumn.style.opacity = '0'; 941 945 // Show page info and entities panels alongside navbar 942 946 // Expand window to fit panel overhang on each side 943 947 if (pageInfoPanel) pageInfoPanel.classList.add('visible'); 944 948 if (entitiesPanel) entitiesPanel.classList.add('visible'); 945 - setWindowPadding(PANEL_OVERHANG * 2); 949 + await setWindowPadding(PANEL_OVERHANG * 2); 950 + centerColumn.style.opacity = '1'; 946 951 } 947 952 948 953 async function hide() { ··· 954 959 hideTimer = null; 955 960 } 956 961 const gen = ++navbarGeneration; 957 - // Shrink the window FIRST, before hiding any CSS elements. 958 - // setBounds is async IPC — if we hide panels (CSS) before the window shrinks, 959 - // there's a frame where panels are gone but the window is still wide, and 960 - // flexbox re-centers the content in the oversized window, causing a visible jump. 961 - // By awaiting setBounds first, the window shrinks while panels are still visible 962 - // (they get clipped by the window edge), then we clean up CSS classes. 962 + // Fade-to-invisible before resizing to prevent compositor desync flash. 963 + // The OS resizes the NSWindow before Chromium re-renders, causing a one-frame 964 + // glitch where stale pixels are visible. By making content invisible first, 965 + // the stale frame is transparent (invisible to the user). 966 + centerColumn.style.opacity = '0'; 963 967 await setWindowPadding(0); 964 968 // If show() was called while we were awaiting, abort — don't clobber the new state 965 969 if (navbarGeneration !== gen) return; ··· 968 972 if (pageInfoPanel) pageInfoPanel.classList.remove('visible'); 969 973 if (entitiesPanel) entitiesPanel.classList.remove('visible'); 970 974 updatePositions(); 975 + // Restore visibility now that the window has been resized 976 + centerColumn.style.opacity = '1'; 971 977 navbar.inputElement?.blur(); 972 978 showSource = null; 973 979 DEBUG && console.log('[page] Navbar hidden');
+60
docs/cmd-space-switching-analysis.md
··· 1 + # Cmd Palette macOS Space Switching Analysis 2 + 3 + ## Problem 4 + 5 + When the user is on macOS Desktop Space 2 (or any non-primary Space) and opens the cmd palette via Alt+Space, macOS switches them back to the Space where the Peek app was initially started. 6 + 7 + ## Root Cause 8 + 9 + The cmd palette window is created as a `keepLive` window with `modal: true`, `type: 'panel'`, and `alwaysOnTop: true`. When a keepLive window is reused (shown after being hidden), the `show()` + `focus()` calls on the BrowserWindow cause macOS to switch to the Space where the window was originally created. 10 + 11 + macOS treats BrowserWindow display Spaces based on the `visibleOnAllWorkspaces` property. Without this property set, a window is bound to the Space where it was first created. When `show()` is called, macOS brings the user to that Space. 12 + 13 + ## Architecture 14 + 15 + ### Cmd Panel Window Lifecycle 16 + 17 + 1. **First open**: `openPanelWindow()` in `extensions/cmd/background.js` calls `api.window.open()` with `keepLive: true`, `modal: true`, `type: 'panel'`, `alwaysOnTop: true`. 18 + 2. **IPC handler**: `window-open` in `backend/electron/ipc.ts` creates a BrowserWindow with `type: 'panel'` (macOS) and `alwaysOnTop` set. 19 + 3. **Subsequent opens**: When the global shortcut is triggered again, the keepLive window is found by key and reused via `existingWindow.window.show()` + `.focus()` (ipc.ts lines ~2187-2195). 20 + 4. **Dismiss**: ESC or blur hides the window via `closeOrHideWindow()` which calls `win.hide()` for keepLive windows. 21 + 22 + ### Key Files 23 + 24 + - `/Users/dietrich/misc/mpeek/extensions/cmd/background.js` - `openPanelWindow()` defines the window params 25 + - `/Users/dietrich/misc/mpeek/extensions/cmd/config.js` - Global shortcut default (Option+Space) 26 + - `/Users/dietrich/misc/mpeek/backend/electron/ipc.ts` - `window-open` IPC handler creates/reuses windows 27 + - `/Users/dietrich/misc/mpeek/backend/electron/shortcuts.ts` - Global shortcut registration (no app.focus()) 28 + - `/Users/dietrich/misc/mpeek/backend/electron/windows.ts` - `closeOrHideWindow()` handles keepLive hide 29 + 30 + ### What Was NOT the Cause 31 + 32 + - No `app.focus()` or `app.show()` calls in the shortcut path 33 + - The `type: 'panel'` is already correct (panel-type windows don't activate the app's main window) 34 + - The shortcut handler simply calls `openPanelWindow()` which calls `api.window.open()` 35 + 36 + ## Fix Applied 37 + 38 + Two changes in `/Users/dietrich/misc/mpeek/backend/electron/ipc.ts`: 39 + 40 + ### 1. New modal window creation 41 + 42 + After creating a new BrowserWindow for a modal window, call `setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })`. This ensures the window is not bound to a specific Space. 43 + 44 + ### 2. keepLive window reuse 45 + 46 + Before calling `show()` + `focus()` on a reused keepLive window, reinforce `setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })`. This handles the case where the property might have been lost or needs reinforcement. 47 + 48 + ### Why `setVisibleOnAllWorkspaces()` and not constructor option 49 + 50 + The `visibleOnAllWorkspaces` property is not available in Electron's `BrowserWindowConstructorOptions` TypeScript type -- it's only available as an instance property and via the `setVisibleOnAllWorkspaces()` method. The method also accepts a `visibleOnFullScreen` option which ensures correct behavior in fullscreen Spaces. 51 + 52 + ## Scope of Impact 53 + 54 + The fix applies to all windows opened with `modal: true`, which includes: 55 + - Cmd palette (the primary use case) 56 + - Any other modal/palette windows that might be opened via the same code path 57 + 58 + The HUD window is NOT affected because it uses `focusable: false` and has separate hide/show logic via `did-resign-active`/`did-become-active`. 59 + 60 + Regular content windows (web pages) are NOT affected -- they remain bound to their creation Space, which is the expected macOS behavior.
+235
docs/navbar-hide-flash-analysis.md
··· 1 + # Navbar Hide Flash/Jump Analysis 2 + 3 + ## Overview 4 + 5 + When the navbar hides in the page host, the user sees a brief visual flash or jump of the webview content. This document traces the exact sequence of operations that causes it. 6 + 7 + ## Architecture Context 8 + 9 + The page host uses a three-column flexbox layout: 10 + 11 + ``` 12 + [ left-gutter (flex:1) ] [ center-column (flex:none) ] [ right-gutter (flex:1) ] 13 + ``` 14 + 15 + The `center-column` has a fixed pixel width set by `updatePositions()`. The gutters absorb any extra window width (`extraWidth`), keeping the center column visually centered. When `extraWidth > 0`, the window is wider than the center column needs, and the gutters fill the surplus symmetrically. 16 + 17 + The variable `extraWidth` controls how much wider the window is than the core content. When panels (page info + entities) are visible, `extraWidth = PANEL_OVERHANG * 2 = 420px`. When hidden, `extraWidth = 0`. 18 + 19 + `computeWindowBounds()` uses `extraWidth` to calculate the BrowserWindow position and size: 20 + 21 + ```js 22 + winX = sb.x - MARGIN - extraWidth / 2 23 + winW = sb.width + MARGIN * 2 + extraWidth 24 + ``` 25 + 26 + So when `extraWidth` changes, the window X shifts by half the delta, and the window width changes by the full delta — the center column stays at the same screen position because flexbox centers it. 27 + 28 + ## The Hide Sequence — Frame by Frame 29 + 30 + ### Starting State (navbar visible, panels visible) 31 + 32 + - `extraWidth = 420` (PANEL_OVERHANG * 2) 33 + - Window is 420px wider than the core content area 34 + - Left and right gutters each absorb ~210px 35 + - Center column is centered at the correct screen position 36 + - Panels are positioned within center-column at negative left / overflowing right offsets, visible within the gutter space 37 + 38 + ### Frame 0: `hide()` is called 39 + 40 + ```js 41 + async function hide() { 42 + if (isDragging || holdDragPending) return; 43 + if (loadingLifecycle.state === 'loading') return; 44 + // ... clear hideTimer ... 45 + const gen = ++navbarGeneration; 46 + 47 + // SYNCHRONOUS: setWindowPadding(0) is called 48 + await setWindowPadding(0); 49 + // ... post-await CSS cleanup ... 50 + } 51 + ``` 52 + 53 + ### Frame 0 (continued): Inside `setWindowPadding(0)` 54 + 55 + ```js 56 + function setWindowPadding(width) { 57 + width = Math.max(0, width); // width = 0 58 + if (width === extraWidth) return; // 0 !== 420, continues 59 + extraWidth = width; // *** SYNCHRONOUS: extraWidth = 0 *** 60 + if (pendingBoundsUpdate) { // cancel any RAF-throttled update 61 + cancelAnimationFrame(pendingBoundsUpdate); 62 + pendingBoundsUpdate = null; 63 + } 64 + const p = api.window.setBounds(computeWindowBounds(screenBounds)); // IPC call 65 + return p; 66 + } 67 + ``` 68 + 69 + **Critical moment**: `extraWidth` is set to 0 **synchronously**, but `setBounds` is an async IPC call (`ipcRenderer.invoke`). The function returns a promise. 70 + 71 + At this point: 72 + - `extraWidth = 0` (JS state updated) 73 + - `computeWindowBounds(screenBounds)` now computes bounds with `extraWidth = 0`: window is 420px narrower and shifted 210px rightward 74 + - The IPC message (`window-set-bounds`) has been **sent** to the main process but has not yet been **applied** 75 + 76 + ### Frame 0 (still): Microtask checkpoint 77 + 78 + The `await` in `hide()` suspends execution. The promise from `setWindowPadding(0)` (which wraps `ipcRenderer.invoke`) is pending. Control returns to the event loop. 79 + 80 + **No CSS classes have been changed yet.** The navbar still has class `visible`. The panels still have class `visible`. `updatePositions()` has NOT been called. 81 + 82 + ### Frame 1: The problematic render 83 + 84 + Here is where the flash occurs. There are **two** things that can trigger a re-layout/repaint before the IPC resolves: 85 + 86 + #### Scenario A: Nothing triggers a re-layout before setBounds resolves 87 + 88 + In the best case, no re-layout occurs between `extraWidth = 0` and the main process applying the new bounds. The `setBounds` call in the main process (`win.setBounds(...)`) is synchronous from the main process perspective. It resizes and repositions the BrowserWindow immediately. 89 + 90 + When the window shrinks from the main process side: 91 + 1. The BrowserWindow shrinks by 420px and shifts 210px rightward 92 + 2. The renderer's viewport shrinks 93 + 3. Flexbox recalculates: the center column width hasn't changed (still set by `updatePositions()` to `screenBounds.width + MARGIN * 2`), but the viewport is now only as wide as the center column 94 + 4. The gutters collapse to 0 width (they were `flex: 1, min-width: 0`) 95 + 5. **The panels, which are positioned at negative left offsets and overflowing right, are now clipped by the window edge** — the code comments describe this as the intended behavior: "the window shrinks while panels are still visible (they get clipped by the window edge)" 96 + 97 + This is the designed behavior and should look clean. 98 + 99 + #### Scenario B: Something forces a re-layout while IPC is in flight 100 + 101 + If **any** code path triggers `updatePositions()` or the browser performs a style recalculation while `extraWidth = 0` but the window is still at its old (wide) size, the following happens: 102 + 103 + - `computeWindowBounds()` would produce different values if called 104 + - However, `updatePositions()` does NOT read `extraWidth` — it only reads `screenBounds` and the navbar visibility CSS class 105 + - The center column width is unchanged (`screenBounds.width + MARGIN * 2`) 106 + - So `updatePositions()` itself would not cause a jump 107 + 108 + **This scenario is not the primary cause.** 109 + 110 + #### Scenario C: The actual race condition (most likely cause) 111 + 112 + The IPC round-trip has non-trivial latency. Here is the precise sequence: 113 + 114 + 1. **T=0ms (renderer)**: `extraWidth = 0`, `ipcRenderer.invoke('window-set-bounds', newBounds)` sent 115 + 2. **T=~0.5-2ms (main process)**: Main process receives message, calls `win.setBounds(narrowBounds)` — this is a synchronous Cocoa/AppKit call that immediately resizes the NSWindow 116 + 3. **T=~0.5-2ms (compositor)**: The window manager resizes the window on screen. The renderer's viewport is now smaller. 117 + 4. **T=~2-4ms (renderer)**: The renderer receives the resize event. Chromium re-layouts the page in the new viewport. Flexbox recalculates. 118 + 119 + **Between T=2 (window shrinks on screen) and T=3 (renderer re-layouts), there is a frame where the OS has shrunk the window but the renderer has not yet re-drawn.** The OS compositor shows the old renderer content clipped to the new, smaller window bounds. Depending on timing, the content might appear to shift because the window's X position changed (moved 210px right) but the rendered pixels inside haven't been re-composited yet. 120 + 121 + However, this is a standard window resize artifact and is usually imperceptible. 122 + 123 + ### The Real Root Cause: Post-await CSS changes trigger a second layout shift 124 + 125 + After the `await setWindowPadding(0)` resolves (the IPC promise returns `{ success: true }`): 126 + 127 + ```js 128 + await setWindowPadding(0); 129 + if (navbarGeneration !== gen) return; // generation check 130 + navbar.classList.remove('visible'); // *** CSS: navbar goes from display:block to display:none *** 131 + if (pageInfoPanel) pageInfoPanel.classList.remove('visible'); // *** panels hidden *** 132 + if (entitiesPanel) entitiesPanel.classList.remove('visible'); 133 + updatePositions(); // *** recalculates trigger zone, resize handles *** 134 + navbar.inputElement?.blur(); 135 + showSource = null; 136 + ``` 137 + 138 + When the `await` resolves: 139 + 140 + 1. The window has **already** been resized to the narrow bounds (no extraWidth) 141 + 2. `navbar.classList.remove('visible')` sets navbar to `display: none` 142 + 3. `updatePositions()` is called, which updates the trigger zone position: 143 + - With `navVisible = false`: trigger zone top becomes `0px`, height becomes `navOffset + TRIGGER_ZONE_HEIGHT = 44px` 144 + - Navbar resize handles are hidden 145 + 146 + **This is clean — no jump here** because the window is already the right size and the panels are already clipped. 147 + 148 + ### The ACTUAL Root Cause: `computeWindowBounds` includes navbar space unconditionally, but `setWindowPadding` changes happen in a different frame than the visual hide 149 + 150 + Let me re-examine. The comment at line 115 says: 151 + 152 + ``` 153 + // NOTE: The window always includes navbar space (NAVBAR_HEIGHT) to avoid 154 + // visual jumps on show/hide. Navbar visibility is CSS-only — no window resize. 155 + ``` 156 + 157 + This means the **Y-axis** is not the problem — the window height and Y-position never change when the navbar shows/hides. The navbar space is always allocated. 158 + 159 + **The problem is the X-axis and width — the panel overhang.** Here is the exact cause: 160 + 161 + ### Root Cause: Two-frame transition 162 + 163 + When `hide()` runs: 164 + 165 + **Frame N (synchronous)**: 166 + 1. `extraWidth` is set to 0 167 + 2. `computeWindowBounds()` is called with `extraWidth = 0`, producing bounds 420px narrower and 210px shifted right 168 + 3. IPC message is dispatched (async) 169 + 4. Execution yields at `await` 170 + 171 + **At this point, the renderer still shows the old layout.** The window is still wide. The panels and navbar are still visible. No visual change yet. 172 + 173 + **Frame N+1 (main process applies setBounds)**: 174 + 1. The main process calls `win.setBounds()` — the window shrinks 420px and moves 210px right 175 + 2. The OS immediately resizes the NSWindow 176 + 3. The renderer receives a resize notification 177 + 4. **Flexbox recalculates**: center column stays at its fixed width, gutters collapse from ~210px each to 0 178 + 5. **The center column shifts leftward within the viewport** because: 179 + - Before: viewport was wide, gutters took 210px on each side, center was in the middle 180 + - After: viewport equals center column width, center is flush left 181 + - BUT the window also moved 210px right on screen 182 + - Net effect: the center column's **screen position** should be unchanged (210px less gutter offset + 210px rightward window shift = 0 net shift) 183 + 184 + **In theory, this should be visually stable.** The flexbox centering math is designed so that shrinking the window while shifting it rightward keeps the center content at the same screen position. 185 + 186 + ### So Where Does the Flash Come From? 187 + 188 + The flash comes from **timing jitter between the window position change and the window size change**. On macOS, `setBounds()` (which maps to `[NSWindow setFrame:display:]`) applies position and size atomically at the OS level. However, the Chromium compositor may take an extra frame to re-render content at the new size. 189 + 190 + **The real culprit is this**: Between the OS resizing the window and Chromium re-rendering, there is exactly one frame where: 191 + 192 + 1. The window has moved 210px to the right (new X position) 193 + 2. The window has shrunk 420px 194 + 3. But the **rendered content** (the pixels in the compositor buffer) is still from the old layout (wide, with gutters) 195 + 4. The OS clips the old rendered content to the new, smaller window rect 196 + 5. Since the old content had 210px of left gutter, and the window moved right by 210px, **the left gutter is now clipped out** — but the old pixel content still shows the center column at its old position within the buffer 197 + 6. The visible result: **the center column appears to jump left by 210px for one frame**, because the old pixels (with left gutter) are being shown in a window that has shifted right 198 + 199 + Then on the next frame, Chromium re-renders with the new viewport size, the gutters collapse, the center column is flush with the left edge of the (now-narrowed) viewport, and everything looks correct again. 200 + 201 + **This one-frame discrepancy is the flash.** 202 + 203 + ### Secondary Contributor: Panel CSS transitions 204 + 205 + The panels have CSS transitions: 206 + 207 + ```css 208 + .page-info-panel { 209 + transition: opacity 0.2s ease, transform 0.2s ease; 210 + } 211 + ``` 212 + 213 + But the panels' CSS classes are only removed **after** the `await` — so by the time they animate out, the window is already narrow and they're clipped. The transitions don't contribute to the flash. 214 + 215 + However, if the panels had been made invisible (CSS) **before** the window shrank, there would be a frame where the panels disappear but the window is still wide, and the empty gutter space would be visible. The code explicitly avoids this with the comment: 216 + 217 + ```js 218 + // setBounds is async IPC — if we hide panels (CSS) before the window shrinks, 219 + // there's a frame where panels are gone but the window is still wide, and 220 + // flexbox re-centers the content in the oversized window, causing a visible jump. 221 + ``` 222 + 223 + This suggests the developer already identified one ordering problem but the remaining flash comes from the compositor timing issue described above. 224 + 225 + ## Summary of Root Cause 226 + 227 + The visual flash when the navbar hides is caused by a **single-frame compositor desync** during the window bounds change: 228 + 229 + 1. `setWindowPadding(0)` sets `extraWidth = 0` and sends `setBounds` via IPC to shrink the window by 420px (210px from each side). 230 + 2. The main process applies `setBounds()` atomically — the window moves right by 210px and shrinks by 420px. 231 + 3. For exactly one compositor frame, the rendered pixel buffer still shows the old wide layout (with 210px gutters on each side), but the OS window has already moved and shrunk. 232 + 4. The OS clips the stale pixel buffer to the new window rect. Since the buffer had the center column at offset ~210px from the left, but the window has shifted right and the buffer is clipped from the right edge, **the user sees the content appear to jump or shift laterally for one frame**. 233 + 5. On the next frame, Chromium re-renders at the new size, gutters collapse, and the center column is correctly positioned. 234 + 235 + The root issue is that **there is no way to atomically synchronize the BrowserWindow bounds change (main process, OS-level) with the renderer's composite buffer (renderer process, GPU-level)**. The IPC-based `setBounds` always has at least a one-frame gap between the window geometry change and the renderer catching up.