From 72a31ab3ad1efddb24caea3945c5045c19c982d4 Mon Sep 17 00:00:00 2001 From: brainstorm-os Date: Tue, 20 Oct 2026 12:55:41 +0200 Subject: [PATCH] fix(shell+sdk): split panes reserve the OS-control gutter only on their own window edge; inspector rows share one inset Split view (6.14 residue): every pane's preload applied the macOS traffic-light gutter, so the right pane's header carried an 86px dead zone under nothing. The window container now tells each pane which window edges it runs along (`window:pane-edges-changed`); the preload reserves the traffic-light gutter only for a pane on the left edge and the Win/Linux controls gutter only for one on the right edge. Re-sent on load, de-duped across divider drags, and covered by a container test. Properties panel: the row's hover padding indented labels 8px past the section headings, buttons and meta rows around them, and the shared Properties|Comments strip sat 8px in while the body sat 16px. The row now bleeds its hover face into the gutter instead (negative inline margin), the strip insets `--space-4` like the body (zeroed in Database, where it mounts inside the padded body), and the Files inspector body adopts the same `--space-4` inset as the other inspectors. Co-Authored-By: Claude Fable 5.1 --- apps/database/src/styles.css | 7 +++ apps/files/src/styles.css | 4 +- .../editor/src/comments/comments-styles.css | 4 +- .../src/properties-panel/properties-panel.css | 8 +++- .../main/apps/window-container-split.test.ts | 29 +++++++++++ .../shell/src/main/apps/window-container.ts | 48 ++++++++++++++++++- packages/shell/src/preload/app-preload.ts | 25 ++++++++-- 7 files changed, 118 insertions(+), 7 deletions(-) diff --git a/apps/database/src/styles.css b/apps/database/src/styles.css index 10c2abbb..153b75ed 100644 --- a/apps/database/src/styles.css +++ b/apps/database/src/styles.css @@ -2842,6 +2842,13 @@ body.is-resizing .dbv-tl__labels-resize { * full-height flex / nested scroll / outer padding neutralization lives in * the SDK sheet now. */ +/* The Properties|Comments strip (shared `EntityCommentsPanel`) mounts INSIDE + * the padded body here, not at the panel's top edge like Preview / Notes, so + * its own `--space-4` inset would double up against the body's. */ +.db-inspector__props-host .bs-panel-tabs { + padding-inline: 0; +} + /* Two-column property grid — label left, value right, baseline-aligned. * Used by the System metadata block (created / updated / id). */ .db-inspector__props { diff --git a/apps/files/src/styles.css b/apps/files/src/styles.css index 4d878d7a..9d753059 100644 --- a/apps/files/src/styles.css +++ b/apps/files/src/styles.css @@ -1392,7 +1392,9 @@ body:not([data-inspector="open"]) .inspector { .inspector__body { flex: 1 1 auto; overflow-y: auto; - padding: var(--space-2); + /* The inset every inspector body shares (Database / Preview / Notes), + * which is also where the tab labels above start. */ + padding: var(--space-4); font-size: var(--text-size-md); } diff --git a/packages/editor/src/comments/comments-styles.css b/packages/editor/src/comments/comments-styles.css index 82e45202..9fe57d69 100644 --- a/packages/editor/src/comments/comments-styles.css +++ b/packages/editor/src/comments/comments-styles.css @@ -168,7 +168,9 @@ flex: 0 0 auto; display: flex; gap: var(--space-1, var(--space-1)); - padding: var(--space-1, var(--space-1)) var(--space-2, var(--space-2)); + /* Inline inset matches the panel body's (`--space-4`) so the strip's + * outer edges line up with the rows, headings and buttons below it. */ + padding: var(--space-1, var(--space-1)) var(--space-4, var(--space-4)); border-bottom: var(--border-width) solid var(--border, rgba(127, 127, 127, 0.18)); } diff --git a/packages/sdk/src/properties-panel/properties-panel.css b/packages/sdk/src/properties-panel/properties-panel.css index c353679b..7f5a60d7 100644 --- a/packages/sdk/src/properties-panel/properties-panel.css +++ b/packages/sdk/src/properties-panel/properties-panel.css @@ -154,7 +154,13 @@ align-items: start; gap: var(--space-2); /* Tighter vertical rhythm so the list reads as a dense property sheet - * rather than an airy form — horizontal padding stays for the hover row. */ + * rather than an airy form — horizontal padding stays for the hover row. + * The hover face BLEEDS into the host's gutter instead of indenting the + * text: the negative inline margin cancels the horizontal padding, so a + * label starts on the same x as the section headings, buttons and meta + * rows around it (they sit on the host's inset) rather than 8px further + * in. Every host that renders these rows pads at least `--space-2`. */ + margin-inline: calc(-1 * var(--space-2)); padding: var(--space-1) var(--space-2); border-radius: var(--radius-md); } diff --git a/packages/shell/src/main/apps/window-container-split.test.ts b/packages/shell/src/main/apps/window-container-split.test.ts index 2de593c1..7fc6154e 100644 --- a/packages/shell/src/main/apps/window-container-split.test.ts +++ b/packages/shell/src/main/apps/window-container-split.test.ts @@ -4,6 +4,7 @@ import { SPLIT_DIVIDER_PX, SPLIT_MIN_PANE_PX } from "../window/split-layout"; import { SplitOrder, SplitPane } from "../window/split-state"; import { type BaseWindowHandle, + PANE_EDGES_CHANNEL, type Rectangle, type Tab, type TabViewFactory, @@ -409,4 +410,32 @@ describe("WindowContainer split view", () => { expect(side.route).toBe("brainstorm://entity/c"); expect(t.primary.route).toBe("brainstorm://entity/a"); }); + + it("reserves the OS-control gutter only on the window edge each pane runs along", () => { + const t = setup(); + const edges = (tab: Tab) => + (view(tab).webContents.send as ReturnType).mock.calls + .filter((c) => c[0] === PANE_EDGES_CHANNEL) + .map((c) => c[1]); + // A lone pane spans the whole window: traffic lights AND overlay controls. + expect(edges(t.primary).at(-1)).toEqual({ left: true, right: true }); + + const side = t.container.openSidePane({ ...t.spec("Side"), appId: OTHER }); + // Primary first: it alone sits under the traffic lights; the side pane + // alone under the Win/Linux controls. + expect(edges(t.primary).at(-1)).toEqual({ left: true, right: false }); + expect(edges(side).at(-1)).toEqual({ left: false, right: true }); + + // A divider drag re-lays out on every pointer move: no re-send without a change. + const before = edges(t.primary).length; + t.container.layout(); + expect(edges(t.primary).length).toBe(before); + + t.container.swapPanes(); + expect(edges(t.primary).at(-1)).toEqual({ left: false, right: true }); + expect(edges(side).at(-1)).toEqual({ left: true, right: false }); + + t.container.closeSidePane(); + expect(edges(t.primary).at(-1)).toEqual({ left: true, right: true }); + }); }); diff --git a/packages/shell/src/main/apps/window-container.ts b/packages/shell/src/main/apps/window-container.ts index 21338c57..c8f4ac9c 100644 --- a/packages/shell/src/main/apps/window-container.ts +++ b/packages/shell/src/main/apps/window-container.ts @@ -181,6 +181,17 @@ export type Tab = { icon: string | null; }; +/** Which window edges a pane's header runs along. The app header reserves the + * OS window controls only on the edges it actually abuts: in a split, the + * left pane alone sits under the macOS traffic lights and the right pane + * alone under the Win/Linux `titleBarOverlay`, so neither reserves the other + * side's gutter. Sent to the pane's preload on `PANE_EDGES_CHANNEL`. */ +export type PaneEdges = { left: boolean; right: boolean }; +export const PANE_EDGES_CHANNEL = "window:pane-edges-changed"; +const WINDOW_EDGES: PaneEdges = { left: true, right: true }; +const LEFT_PANE_EDGES: PaneEdges = { left: true, right: false }; +const RIGHT_PANE_EDGES: PaneEdges = { left: false, right: true }; + export type WindowContainerOptions = { appId: string; baseWindow: BaseWindowHandle; @@ -220,6 +231,9 @@ export class WindowContainer { private stripVisible = false; private revealed = false; private readonly perTabDetach = new Map void>(); + /** Last edges sent per tab, so a resize-driven `layout()` only re-sends on + * a real change (the divider drag lays out on every pointer move). */ + private readonly paneEdgesSent = new Map(); private readonly changeListeners = new Set<() => void>(); private detachBaseWindow: (() => void) | null = null; private disposed = false; @@ -676,10 +690,39 @@ export class WindowContainer { const primaryFirst = this.split.order === SplitOrder.PrimaryFirst; active.view.setBounds(primaryFirst ? rects.left : rects.right); this.side.view.setBounds(primaryFirst ? rects.right : rects.left); + this.syncPaneEdges(active); + this.syncPaneEdges(this.side); // A resize moves the divider; the chrome draws it, so it must hear. this.pushChromeState(); } else if (active) { active.view.setBounds(body); + this.syncPaneEdges(active); + } + } + + /** The window edges `tab`'s header runs along under the current layout. */ + private edgesFor(tab: Tab): PaneEdges { + if (!this.isSplit()) return WINDOW_EDGES; + const sideFirst = this.split.order === SplitOrder.SideFirst; + const isLeft = (tab === this.side) === sideFirst; + return isLeft ? LEFT_PANE_EDGES : RIGHT_PANE_EDGES; + } + + private syncPaneEdges(tab: Tab): void { + const edges = this.edgesFor(tab); + const key = `${edges.left}:${edges.right}`; + if (this.paneEdgesSent.get(tab.tabId) === key) return; + this.paneEdgesSent.set(tab.tabId, key); + this.sendPaneEdges(tab, edges); + } + + private sendPaneEdges(tab: Tab, edges: PaneEdges): void { + const wc = tab.view?.webContents; + if (!wc || wc.isDestroyed()) return; + try { + wc.send(PANE_EDGES_CHANNEL, edges); + } catch { + // Renderer gone between layout and send — harmless. } } @@ -864,8 +907,10 @@ export class WindowContainer { this.reveal(); // A tab can finish loading after the strip is already up (opened into an // existing multi-tab window); its preload assumed the single-tab gutter, - // so re-send the live state once it can receive it. + // so re-send the live state once it can receive it. Same for the pane + // edges: a side pane is laid out before its renderer exists. this.sendStripVisible(tab, this.stripVisible); + this.sendPaneEdges(tab, this.edgesFor(tab)); }; // Shell window-management chords (close tab / cycle tabs) are intercepted // before the app renderer sees them. @@ -921,6 +966,7 @@ export class WindowContainer { private unwireTab(tab: Tab): void { this.perTabDetach.get(tab.tabId)?.(); this.perTabDetach.delete(tab.tabId); + this.paneEdgesSent.delete(tab.tabId); } private teardownTab(tab: Tab): void { diff --git a/packages/shell/src/preload/app-preload.ts b/packages/shell/src/preload/app-preload.ts index ea432960..4d7d4111 100644 --- a/packages/shell/src/preload/app-preload.ts +++ b/packages/shell/src/preload/app-preload.ts @@ -675,6 +675,9 @@ let lastChromeCss = ""; // the strip row ABOVE the app, so the header below stops reserving their gutter. let chromeFullscreen = false; let chromeStripVisible = false; +// Which window edges this pane's header runs along (mirrors `PaneEdges` in +// `main/apps/window-container.ts`). A lone pane spans both; a split pane one. +let chromePaneEdges = { left: true, right: true }; function whenDocumentReady(fn: () => void): void { if (typeof document === "undefined") return; @@ -877,13 +880,17 @@ function themeColorScheme(color: string): "dark" | "light" { // Any other case → 12px // // When the tab strip is visible the OS controls live in the strip row, not over -// this header, so the gutter collapses to the default on every platform. +// this header, so the gutter collapses to the default on every platform. In a +// split view a pane reserves a gutter only on the window edge it runs along: +// the right pane never sits under the traffic lights, the left pane never under +// the Win/Linux controls (`window:pane-edges-changed`). function applyChrome(): void { try { const reserveControls = !chromeFullscreen && !chromeStripVisible; - const padLeft = IS_MAC && reserveControls ? CHROME_PAD_MAC_LEFT : CHROME_PAD_DEFAULT; + const padLeft = + IS_MAC && reserveControls && chromePaneEdges.left ? CHROME_PAD_MAC_LEFT : CHROME_PAD_DEFAULT; let padRight = CHROME_PAD_DEFAULT; - if (reserveControls) { + if (reserveControls && chromePaneEdges.right) { if (IS_WIN) padRight = CHROME_PAD_WIN_RIGHT; else if (IS_LINUX) padRight = CHROME_PAD_LINUX_RIGHT; } @@ -1144,6 +1151,18 @@ ipcRenderer.on("window:strip-visible-changed", (_event, stripVisible: boolean) = applyChrome(); }); +// Split view: the container tells each pane which window edges it runs along, +// so only the left pane reserves the traffic-light gutter and only the right +// pane the Win/Linux controls gutter. Shape-checked — main is trusted, but a +// malformed payload must not leave the header with an undefined inset. +ipcRenderer.on("window:pane-edges-changed", (_event, edges: unknown) => { + if (!edges || typeof edges !== "object") return; + const { left, right } = edges as { left?: unknown; right?: unknown }; + if (typeof left !== "boolean" || typeof right !== "boolean") return; + chromePaneEdges = { left, right }; + applyChrome(); +}); + // Window visibility (parked/hidden vs shown). Mirrors the main-process // `window:visibility-changed` emit (see launch-setup.ts). On macOS a parked // window's renderer never sees a Page Visibility change, so apps that run an