# Declarative Page Bounds ## Problem Statement The page content area's bounds (the region where tab `WebContentsView`s are placed) are currently measured in the renderer process using `getBoundingClientRect()` and sent to the main process via IPC. This approach has several problems: - **Latency.** The renderer measurement round-trip takes 2-3 frames (~33-50ms): `getBoundingClientRect()` -> React `setState` -> `useEffect` -> IPC send -> main process handler. During this window the tab view is positioned using stale bounds. - **Layout reflows.** `getBoundingClientRect()` forces the browser to synchronously compute layout if any pending style/DOM changes exist. The `useBoundingRect` hook calls it on every rAF tick during its burst, which can interact poorly with other layout work in the same frame. - **Sidebar tween gap.** When the attached sidebar animates in or out (100ms CSS `transition-[margin]`), the content area's width changes continuously. The current `ResizeObserver` + rAF burst in `useBoundingRect` does not use `observingWithLoop: true` for `BrowserContent`, so it only captures a few intermediate frames rather than tracking the full transition. The `WebContentsView` lags behind or snaps at the end. - **Complexity.** The main process has a parallel "fast path" for window resize that caches `pageInsets` and recomputes bounds from `getContentSize()`. This creates two sources of truth (`_isResizing` flag, debounce timer, inset cache) that must be carefully coordinated. - **Fragility.** Floating-point epsilon comparisons (0.5px), settle-frame heuristics, and debounce timers are all workarounds for the fundamental issue: the main process is guessing when renderer-reported bounds are stale. ### What already works well - The resize fast-path concept (recompute from insets + `getContentSize()`) is sound; it just needs to be generalized. - The spring-physics `TabBoundsController` for mode transitions (glance/split) is unrelated and stays unchanged. - Portal and omnibox bounds genuinely depend on dynamic DOM position and are out of scope. --- ## Core Idea The page content area's position is fully determined by a small set of **layout parameters** that the renderer already knows: | Parameter | Source | When it changes | | ------------------ | ------------------------------ | -------------------------------- | | `topbarHeight` | `AdaptiveTopbarProvider` | Platform detection, sidebar side | | `topbarVisible` | `AdaptiveTopbarProvider` | Fullscreen enter/exit | | `sidebarWidth` | `recordedSidebarSizeRef` | User drag, sidebar toggle | | `sidebarSide` | Settings (`sidebarSide`) | User preference change | | `sidebarVisible` | `BrowserSidebarProvider` | Toggle, floating trigger | | `sidebarAnimating` | `BrowserSidebarProvider` | Mount/unmount 100ms animation | | `outerPadding` | Fixed at `12` (Tailwind `p-3`) | Never (unless redesigned) | Instead of measuring the DOM and sending pixel coordinates, the renderer sends these parameters to the main process. The main process computes bounds arithmetically from `getContentSize()` and the parameters, eliminating `getBoundingClientRect()` from the critical path entirely. --- ## Layout Geometry The current CSS layout (from `main.tsx`) produces bounds as follows. All units are pixels; `cw` and `ch` are from `BrowserWindow.getContentSize()`. ``` topbarVisible = true: ┌──────────────────────────── cw ────────────────────────────┐ │ AdaptiveTopbar (height = topbarHeight) │ ├────────────────────────────────────────────────────────────┤ │ │ pad │ │ pad │ │ │ [side] │ 12 │ BrowserContent │ 12 │ [sidebar] │ │ [bar ] │ │ (flex-1) │ │ [or pad ] │ │ [or ] │ │ │ │ │ │ [pad ] │ │ ← tab views go here → │ │ │ │ │ │ │ │ │ │ │ │ (with pt-0) │ │ │ │ │ │ │ │ │ ├────────┴─────┴──────────────────────────┴─────┴────────────┤ │ bottom padding (12px from py-3) │ └────────────────────────────────────────────────────────────┘ topbarVisible = false (macOS, sidebar on left): ┌──────────────────────────── cw ────────────────────────────┐ │ │ pad │ │ pad │ │ │ [side] │ 12 │ BrowserContent │ 12 │ pad 12 │ │ [bar ] │ │ (flex-1) │ │ │ │ │ │ (with py-3) │ │ │ │ │ │ │ │ │ ├────────┴─────┴──────────────────────────┴─────┴────────────┤ │ bottom padding (12px from py-3) │ └────────────────────────────────────────────────────────────┘ ``` **Attached sidebar:** - Total sidebar footprint = `sidebarWidth + handleOrPadWidth`. - When sidebar is present: `sidebarWidth` (the `PixelBasedResizablePanel` pixel width) + a 12px resize handle area (the `SidebarResizeHandle`, which is `w-3`). - When sidebar is absent: a 12px spacer (`
`). - During the sidebar hide animation, the panel applies a negative margin equal to `sidebarWidth`, collapsing it from `sidebarWidth` to `0` over 100ms. The handle/spacer stays at 12px throughout. **Floating sidebar:** - Rendered in a `PortalComponent` (separate overlay window). Zero layout impact on `BrowserContent`. The content area bounds do not change. ### Bounds formula ``` paddingH = 12 (left spacer/handle + right spacer/handle) paddingTop = topbarVisible ? topbarHeight : 12 paddingBot = 12 sidebarSpace = sidebarVisible ? effectiveSidebarWidth : 0 where effectiveSidebarWidth lerps from 0 → sidebarWidth over animation x = (sidebarSide === "left" ? sidebarSpace : 0) + 12 y = paddingTop width = cw - sidebarSpace - paddingH height = ch - paddingTop - paddingBot ``` The `12` on both sides accounts for the spacer/handle divs (`w-3 = 12px`) that always exist between the sidebar panel (or its absence) and the content. --- ## Sidebar Tween Handling The attached sidebar uses a 100ms `ease-in-out` CSS `transition-[margin]`. The sidebar's pixel width stays constant; a negative margin slides it off-screen. From the layout's perspective, `BrowserContent` grows/shrinks as the margin animates. ### Approach: main-process eased interpolation When the renderer reports that a sidebar animation has started, the main process interpolates `effectiveSidebarWidth` between the start and end values over the same 100ms duration with the same `ease-in-out` timing function. This mirrors the CSS transition exactly, so the `WebContentsView` tracks the content area without any renderer round-trip during the animation. The flow: ``` Renderer Main Process ──────── ──────────── sidebar toggled → startAnimation() → send IPC: page:set-layout-params { receives layout params ..., with sidebarAnimating: true sidebarVisible: false, and sidebarVisible: false sidebarAnimating: true } starts 100ms interpolation: effectiveSidebarWidth: sidebarWidth → 0 using ease-in-out curve recomputing pageBounds each frame (~16ms ticks) 100ms later: interpolation settles → stopAnimation() effectiveSidebarWidth = 0 → send IPC: page:set-layout-params { ..., sidebarAnimating: false } confirms final state ``` The interpolation runs a `setTimeout`-based loop at ~4ms intervals (~25 ticks per 100ms animation) in the main process, using **Tailwind's `ease-in-out`** easing function: `cubic-bezier(0.4, 0, 0.2, 1)`. Note: this is NOT the CSS standard `ease-in-out` keyword which is `cubic-bezier(0.42, 0, 0.58, 1)` — Tailwind uses a different, front-loaded curve. For opening, the interpolation goes `0 → sidebarWidth`. For closing, `sidebarWidth → 0`. The direction is determined by the `sidebarVisible` flag in the layout params. **Floating sidebar:** No interpolation needed. The floating sidebar is an overlay (`PortalComponent`) and does not affect `BrowserContent` bounds. --- ## New Shared Types ```typescript // src/shared/flow/types.ts export interface PageLayoutParams { /** Pixel height of the topbar (0 when not applicable, e.g. macOS with left sidebar). */ topbarHeight: number; /** Whether the topbar is currently rendered. */ topbarVisible: boolean; /** Pixel width of the sidebar panel (not including the handle/spacer). */ sidebarWidth: number; /** Which side the sidebar attaches to. */ sidebarSide: "left" | "right"; /** * Whether the attached sidebar is currently visible (taking up layout space). * Floating sidebars do not affect layout and should not change this flag. */ sidebarVisible: boolean; /** * Whether the sidebar is currently animating (opening or closing). * When true, the main process interpolates effectiveSidebarWidth between * the previous and target values over 100ms with ease-in-out timing. */ sidebarAnimating: boolean; } ``` --- ## New IPC Channel ### `page:set-layout-params` Replaces `page:set-bounds` for the primary page bounds flow. - **Direction:** Renderer -> Main (fire-and-forget, `ipcRenderer.send`) - **Payload:** `PageLayoutParams` - **When sent:** - On initial mount (once layout parameters are known) - When sidebar visibility changes (toggle on/off) - When sidebar animation starts or ends - When sidebar is resized by the user (drag handle) - When sidebar side setting changes - When topbar height/visibility changes (unlikely at runtime) - When fullscreen state changes This is significantly less frequent than the current approach, which sends updated bounds on every rAF tick during any layout change. --- ## Main Process Changes ### `BrowserWindowInstance` (browser.ts) Replace the current `pageBounds` / `pageInsets` / `_isResizing` mechanism: ```typescript class BrowserWindowInstance { // Remove: // private pageInsets: { ... } | null = null; // private _isResizing: boolean; // private _resizeEndTimer: ...; // Add: private layoutParams: PageLayoutParams | null = null; private sidebarInterpolation: SidebarInterpolation | null = null; public setLayoutParams(params: PageLayoutParams): void { const prevParams = this.layoutParams; this.layoutParams = params; if (params.sidebarAnimating && prevParams) { // Start interpolation from previous effective width to new target const fromWidth = prevParams.sidebarVisible ? prevParams.sidebarWidth : 0; const toWidth = params.sidebarVisible ? params.sidebarWidth : 0; this.sidebarInterpolation = new SidebarInterpolation( fromWidth, toWidth, SIDEBAR_ANIMATE_DURATION, // 100ms, matching CSS () => { this.recomputePageBounds(); }, () => { // Animation complete callback this.sidebarInterpolation = null; this.recomputePageBounds(); } ); this.sidebarInterpolation.start(); } else { // No animation — apply immediately if (this.sidebarInterpolation) { this.sidebarInterpolation.stop(); this.sidebarInterpolation = null; } this.recomputePageBounds(); } } private recomputePageBounds(): void { if (!this.layoutParams) return; const [cw, ch] = this.browserWindow.getContentSize(); const { topbarHeight, topbarVisible, sidebarWidth, sidebarSide, sidebarVisible } = this.layoutParams; // Effective sidebar width (animated or static) let effectiveSidebarWidth: number; if (this.sidebarInterpolation) { effectiveSidebarWidth = this.sidebarInterpolation.currentValue; } else { effectiveSidebarWidth = sidebarVisible ? sidebarWidth : 0; } const PADDING = 12; const padTop = topbarVisible ? topbarHeight : PADDING; const padBottom = PADDING; const x = (sidebarSide === "left" ? effectiveSidebarWidth : 0) + PADDING; const y = padTop; const width = Math.max(0, cw - effectiveSidebarWidth - PADDING * 2); const height = Math.max(0, ch - padTop - padBottom); const newBounds: PageBounds = { x, y, width, height }; this.pageBounds = newBounds; this.emit("page-bounds-changed", newBounds); tabsController.handlePageBoundsChanged(this.id); } } ``` The `resize` event handler simplifies to: ```typescript browserWindow.on("resize", () => { this.recomputePageBounds(); }); ``` No `_isResizing` flag, no inset cache, no debounce timer. ### `SidebarInterpolation` class A small utility that drives a value from A to B over a duration with Tailwind's `ease-in-out` timing, ticking via `setTimeout` at ~4ms intervals (~25 ticks per 100ms animation): ```typescript const SIDEBAR_ANIMATE_DURATION = 100; // ms, matches CSS class SidebarInterpolation { public currentValue: number; private startTime: number = 0; private timer: ReturnType