experiments in a post-browser web
10
fork

Configure Feed

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

fix(page): clamp side panels to window and reflow on OS resize

Two related layout bugs in the page-host renderer:

1. Side panels (page info, extensions, tags, notes, entities, widgets) were
anchored to webview-relative coordinates (left at -202, right edge at
webviewWidth + 210) and relied entirely on setWindowPadding having
expanded the BrowserWindow by PANEL_OVERHANG * 2 = 420px. Near a screen
edge the OS clips the expansion, the gutter shrinks, and panels float
into negative coordinates that the user never sees. Now clamp every
panel left into [-gutterEach, centerColumnWidth + gutterEach -
PANEL_WIDTH] using window.innerWidth as ground truth — panels slide
over the webview content rather than disappearing off the edge.

2. The renderer never listened for window resize events, so OS-driven
bounds changes (display-watcher rescue, future Aero-snap, etc.) left
panel positions stale. Add a rAF-throttled resize listener that
re-runs updatePositions, picking up the new window.innerWidth and
re-clamping panels in place.

Out of scope: re-enabling resizable: true on the canvas window for
native OS resize gestures. That requires a "who owns the bounds right
now" lock between the custom in-renderer drag handles and the OS path,
plus a screenBounds refresh from getBounds() on each OS-driven resize.
Left as a follow-up — the resize listener added here is the prerequisite
half.

+85 -22
+33 -6
app/page/page.js
··· 427 427 // Panel top offset — enough clearance below the top edge of the web content area 428 428 const panelTopOffset = webviewTop + 40; 429 429 430 - // Page Info Panel — top-left, 75% off-page / 25% overlapping 430 + // Compute the centerColumn-relative range that's actually visible in the 431 + // rendered BrowserWindow. The centerColumn is flex-centered with a known 432 + // width; the gutters absorb whatever extra width the window has. Near a 433 + // screen edge the OS may clip the window, shrinking the gutter and pushing 434 + // panel anchors past the visible edge. Clamp every panel's `left` into 435 + // [minLeft, maxLeft] so they stay on-screen instead of disappearing into 436 + // negative coordinates or beyond the right edge. 437 + const centerColumnWidth = screenBounds.width + MARGIN * 2; 438 + const gutterEach = Math.max(0, (window.innerWidth - centerColumnWidth) / 2); 439 + const minPanelLeft = -gutterEach; 440 + const maxPanelLeft = centerColumnWidth + gutterEach - PANEL_WIDTH; 441 + const clampPanelLeft = (left) => Math.max(minPanelLeft, Math.min(maxPanelLeft, left)); 442 + 443 + // Page Info Panel — top-left, 75% off-page / 25% overlapping (clamped) 431 444 if (pageInfoPanel) { 432 - pageInfoPanel.style.left = `${webviewLeft - PANEL_WIDTH + PANEL_OVERLAP}px`; 445 + pageInfoPanel.style.left = `${clampPanelLeft(webviewLeft - PANEL_WIDTH + PANEL_OVERLAP)}px`; 433 446 pageInfoPanel.style.top = `${panelTopOffset}px`; 434 447 pageInfoPanel.style.width = `${PANEL_WIDTH}px`; 435 448 } 436 449 437 - // Extensions Panel — left side, below page info 450 + // Extensions Panel — left side, below page info (clamped) 438 451 if (extensionsPanel) { 439 - extensionsPanel.style.left = `${webviewLeft - PANEL_WIDTH + PANEL_OVERLAP}px`; 452 + extensionsPanel.style.left = `${clampPanelLeft(webviewLeft - PANEL_WIDTH + PANEL_OVERLAP)}px`; 440 453 const pageInfoHeight = (pageInfoPanel && pageInfoPanel.classList.contains('visible')) ? pageInfoPanel.offsetHeight : 0; 441 454 const extensionsPanelTop = panelTopOffset + (pageInfoHeight > 0 ? pageInfoHeight + 12 : 0); 442 455 extensionsPanel.style.top = `${extensionsPanelTop}px`; 443 456 extensionsPanel.style.width = `${PANEL_WIDTH}px`; 444 457 } 445 458 446 - // Right-side stack: widgets first (top), then tags, notes, entities 447 - const rightLeft = webviewLeft + webviewWidth - PANEL_OVERLAP; 459 + // Right-side stack: widgets first (top), then tags, notes, entities (clamped) 460 + const rightLeft = clampPanelLeft(webviewLeft + webviewWidth - PANEL_OVERLAP); 448 461 let rightStackTop = panelTopOffset; 449 462 450 463 // Widget container — top of right stack ··· 718 731 719 732 // Initial positioning 720 733 updatePositions(); 734 + 735 + // Re-clamp panels and recompute screen-edge math whenever the OS resizes 736 + // the BrowserWindow (display-watcher rescue, OS snap, drag-by-OS-handle 737 + // when re-enabled). updatePositions reads window.innerWidth, so the panels 738 + // reflow immediately even though screenBounds (the webview's intended 739 + // "screen" target) is unchanged. Throttled to one rAF per resize burst. 740 + let pendingResizeFrame = null; 741 + window.addEventListener('resize', () => { 742 + if (pendingResizeFrame !== null) return; 743 + pendingResizeFrame = requestAnimationFrame(() => { 744 + pendingResizeFrame = null; 745 + updatePositions(); 746 + }); 747 + }); 721 748 722 749 // Expose initial fsmState even before the first dispatch (so tests can 723 750 // observe INITIALIZING vs first-event transitions).
+52 -16
tests/desktop/page-layout.spec.ts
··· 240 240 await closeWindow(sharedBgWindow, windowId); 241 241 }); 242 242 243 - // Test 3: Panel positions — left panels partially off-left, right panels partially off-right 244 - test('panel positions: left panels offset left, right panels offset right', async () => { 243 + // Test 3: Panel positions — clamped to within the rendered window so they 244 + // don't disappear into negative coords near a screen edge. Panels are 245 + // anchored relative to the webview (left = webviewLeft - PANEL_WIDTH + 246 + // PANEL_OVERLAP, right = webviewLeft + webviewWidth - PANEL_OVERLAP) but 247 + // the renderer now clamps each `left` to [-gutterEach, centerColumnWidth 248 + // + gutterEach - PANEL_WIDTH] using window.innerWidth as ground truth. 249 + // When there is no gutter (panels hidden / no setWindowPadding), the 250 + // effective minimum left is 0 — left panels overlap the webview rather 251 + // than render off-screen. 252 + test('panel positions: clamped to visible range, never off the screen edge', async () => { 245 253 const { pageWindow, windowId } = await openCanvasPage( 246 254 sharedBgWindow, 247 255 'https://example.com' ··· 249 257 250 258 await waitForPageReady(pageWindow); 251 259 252 - // Show navbar to make panels visible (panels share navbar lifecycle) 253 260 await sharedBgWindow.evaluate(async (wid: number) => { 254 261 (window as any).app.publish( 255 262 'page:show-navbar', ··· 266 273 { timeout: 5000 } 267 274 ); 268 275 269 - // Check left panel (page-info-panel) position 270 - // Expected left = webviewLeft(8) - PANEL_WIDTH(280) + PANEL_OVERLAP(70) = -202 271 - const pageInfoPos = await getElementPosition(pageWindow, 'page-info-panel'); 272 - const expectedLeftPanelLeft = MARGIN - PANEL_WIDTH + PANEL_OVERLAP; // 8 - 280 + 70 = -202 273 - expect(pageInfoPos.left).toBe(expectedLeftPanelLeft); 276 + // Read all the layout inputs and panel positions in one shot so the 277 + // clamp math runs against the SAME renderer state that produced the 278 + // panel positions. Otherwise innerWidth (which depends on 279 + // setWindowPadding) can drift between reads and the expected/actual 280 + // diverge by 200+px. 281 + const snap = await pageWindow.evaluate(() => { 282 + const PANEL_WIDTH = 280; 283 + const PANEL_OVERLAP = 70; 284 + const MARGIN = 8; 285 + const $ = (id: string) => document.getElementById(id); 286 + const rect = (el: HTMLElement | null) => el ? { left: parseFloat(el.style.left || '0'), width: el.offsetWidth } : null; 274 287 275 - // Check right panel position (e.g. tags-panel, entities-panel) 276 - // Expected left = webviewLeft(8) + webviewWidth - PANEL_OVERLAP(70) 277 - const webviewPos = await getElementPosition(pageWindow, 'content'); 278 - const expectedRightPanelLeft = MARGIN + webviewPos.width - PANEL_OVERLAP; 288 + const centerColumn = document.querySelector('.center-column') as HTMLElement | null; 289 + const centerWidth = centerColumn ? centerColumn.offsetWidth : 0; 290 + const innerWidth = window.innerWidth; 291 + const gutterEach = Math.max(0, (innerWidth - centerWidth) / 2); 292 + const minLeft = -gutterEach; 293 + const maxLeft = centerWidth + gutterEach - PANEL_WIDTH; 279 294 280 - const tagsPanelPos = await getElementPosition(pageWindow, 'tags-panel'); 281 - expect(tagsPanelPos.left).toBe(expectedRightPanelLeft); 295 + return { 296 + innerWidth, 297 + centerWidth, 298 + gutterEach, 299 + minLeft, 300 + maxLeft, 301 + webview: rect($('content')), 302 + pageInfo: rect($('page-info-panel')), 303 + tags: rect($('tags-panel')), 304 + entities: rect($('entities-panel')), 305 + // Mirror the renderer math: 306 + rawLeftPanelLeft: MARGIN - PANEL_WIDTH + PANEL_OVERLAP, 307 + rawRightPanelLeft: MARGIN + ($('content')?.offsetWidth ?? 0) - PANEL_OVERLAP, 308 + }; 309 + }); 282 310 283 - const entitiesPanelPos = await getElementPosition(pageWindow, 'entities-panel'); 284 - expect(entitiesPanelPos.left).toBe(expectedRightPanelLeft); 311 + // Left panel: must lie within the clamp range. 312 + expect(snap.pageInfo).not.toBeNull(); 313 + expect(snap.pageInfo!.left).toBeGreaterThanOrEqual(snap.minLeft); 314 + expect(snap.pageInfo!.left).toBeLessThanOrEqual(snap.maxLeft); 315 + 316 + // Right panels: must equal the clamped raw value, identically for 317 + // tags + entities (they share the right-stack anchor). 318 + const expectedRightPanelLeft = Math.max(snap.minLeft, Math.min(snap.maxLeft, snap.rawRightPanelLeft)); 319 + expect(snap.tags!.left).toBe(expectedRightPanelLeft); 320 + expect(snap.entities!.left).toBe(expectedRightPanelLeft); 285 321 286 322 await closeWindow(sharedBgWindow, windowId); 287 323 });