| author | |
| committer | |
| log | c1b2210411da79350a3f9f5a65a4a64971e4acad |
| tree | 4a9b21f1d20b80f776b79a781c319a5b93280cf5 |
| parent | ae53072af0560c0606202b87b5fdb7e52d48ac5c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: T3 Code Test <t3code-test@example.com>5 files changed, 78 insertions(+), 6 deletions(-)
apps/desktop/src/preload.ts+14-1| ... | @@ -6,7 +6,7 @@ import type { | ... | @@ -6,7 +6,7 @@ import type { |
| 6 | DesktopSnapShotEvent, | 6 | DesktopSnapShotEvent, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { exposeClerkBridge } from "@clerk/electron/preload"; | 8 | import { exposeClerkBridge } from "@clerk/electron/preload"; |
| 9 | import { contextBridge, ipcRenderer } from "electron"; | 9 | import { contextBridge, ipcRenderer, webFrame } from "electron"; |
| 10 | 10 | ||
| 11 | import * as IpcChannels from "./ipc/channels.ts"; | 11 | import * as IpcChannels from "./ipc/channels.ts"; |
| 12 | 12 | ||
| ... | @@ -32,6 +32,19 @@ exposeClerkBridge({ passkeys: true }); | ... | @@ -32,6 +32,19 @@ exposeClerkBridge({ passkeys: true }); |
| 32 | // oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process. | 32 | // oxlint-disable-next-line t3code/no-global-process-runtime -- Electron exposes the client platform in its sandboxed preload process. |
| 33 | const clientPlatform = process.platform; | 33 | const clientPlatform = process.platform; |
| 34 | 34 | ||
| 35 | if (clientPlatform === "darwin") { | ||
| 36 | // Native window buttons do not scale with Chromium zoom. Keep their reserved | ||
| 37 | // space in native points, including when a zoomed page is reloaded. | ||
| 38 | const syncWindowControlInset = () => { | ||
| 39 | document.documentElement.style.setProperty( | ||
| 40 | "--desktop-window-controls-inset", | ||
| 41 | `${90 / webFrame.getZoomFactor()}px`, | ||
| 42 | ); | ||
| 43 | }; | ||
| 44 | window.addEventListener("DOMContentLoaded", syncWindowControlInset, { once: true }); | ||
| 45 | window.addEventListener("resize", syncWindowControlInset); | ||
| 46 | } | ||
| 47 | |||
| 35 | function unwrapEnsureSshEnvironmentResult(result: unknown) { | 48 | function unwrapEnsureSshEnvironmentResult(result: unknown) { |
| 36 | if ( | 49 | if ( |
| 37 | typeof result === "object" && | 50 | typeof result === "object" && |
apps/desktop/src/window/DesktopWindow.test.ts+36| ... | @@ -77,6 +77,7 @@ function makeFakeBrowserWindow() { | ... | @@ -77,6 +77,7 @@ function makeFakeBrowserWindow() { |
| 77 | isDestroyed: vi.fn(() => false), | 77 | isDestroyed: vi.fn(() => false), |
| 78 | getURL: vi.fn(() => "t3code-dev://app/"), | 78 | getURL: vi.fn(() => "t3code-dev://app/"), |
| 79 | getZoomLevel: vi.fn(() => zoomLevel), | 79 | getZoomLevel: vi.fn(() => zoomLevel), |
| 80 | getZoomFactor: vi.fn(() => 1.2 ** zoomLevel), | ||
| 80 | setZoomLevel: vi.fn((level: number) => { | 81 | setZoomLevel: vi.fn((level: number) => { |
| 81 | zoomLevel = level; | 82 | zoomLevel = level; |
| 82 | }), | 83 | }), |
| ... | @@ -118,6 +119,7 @@ function makeFakeBrowserWindow() { | ... | @@ -118,6 +119,7 @@ function makeFakeBrowserWindow() { |
| 118 | setOpacity: vi.fn(), | 119 | setOpacity: vi.fn(), |
| 119 | setTitle: vi.fn(), | 120 | setTitle: vi.fn(), |
| 120 | setTitleBarOverlay: vi.fn(), | 121 | setTitleBarOverlay: vi.fn(), |
| 122 | setWindowButtonPosition: vi.fn(), | ||
| 121 | show: vi.fn(), | 123 | show: vi.fn(), |
| 122 | webContents, | 124 | webContents, |
| 123 | }; | 125 | }; |
| ... | @@ -136,6 +138,7 @@ function makeFakeBrowserWindow() { | ... | @@ -136,6 +138,7 @@ function makeFakeBrowserWindow() { |
| 136 | reload: webContents.reload, | 138 | reload: webContents.reload, |
| 137 | send: webContents.send, | 139 | send: webContents.send, |
| 138 | setZoomLevel: webContents.setZoomLevel, | 140 | setZoomLevel: webContents.setZoomLevel, |
| 141 | setWindowButtonPosition: window.setWindowButtonPosition, | ||
| 139 | setBackgroundThrottling: webContents.setBackgroundThrottling, | 142 | setBackgroundThrottling: webContents.setBackgroundThrottling, |
| 140 | setAutoHideCursor: window.setAutoHideCursor, | 143 | setAutoHideCursor: window.setAutoHideCursor, |
| 141 | setFullScreen: window.setFullScreen, | 144 | setFullScreen: window.setFullScreen, |
| ... | @@ -742,6 +745,39 @@ describe("DesktopWindow", () => { | ... | @@ -742,6 +745,39 @@ describe("DesktopWindow", () => { |
| 742 | }), | 745 | }), |
| 743 | ); | 746 | ); |
| 744 | 747 | ||
| 748 | it.effect("keeps macOS window buttons centered when zooming and leaving fullscreen", () => | ||
| 749 | Effect.gen(function* () { | ||
| 750 | const fakeWindow = makeFakeBrowserWindow(); | ||
| 751 | const createCount = yield* Ref.make(0); | ||
| 752 | const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none()); | ||
| 753 | const layer = makeTestLayer({ window: fakeWindow.window, createCount, mainWindow }); | ||
| 754 | |||
| 755 | yield* Effect.gen(function* () { | ||
| 756 | const desktopWindow = yield* DesktopWindow.DesktopWindow; | ||
| 757 | yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773")); | ||
| 758 | |||
| 759 | for (const direction of ["in", "in", "out", "reset", "out"] as const) { | ||
| 760 | yield* desktopWindow.zoomMain(direction); | ||
| 761 | const position = fakeWindow.setWindowButtonPosition.mock.lastCall?.[0]; | ||
| 762 | assert.isDefined(position); | ||
| 763 | // The 14-point native buttons should share the zoomed 52px header's center. | ||
| 764 | const headerCenter = 26 * fakeWindow.window.webContents.getZoomFactor(); | ||
| 765 | assert.isAtMost(Math.abs(position.y + 7 - headerCenter), 0.5); | ||
| 766 | assert.equal(position.x, 16); | ||
| 767 | } | ||
| 768 | |||
| 769 | fakeWindow.isFullScreen.mockReturnValue(true); | ||
| 770 | fakeWindow.setWindowButtonPosition.mockClear(); | ||
| 771 | yield* desktopWindow.zoomMain("reset"); | ||
| 772 | assert.equal(fakeWindow.setWindowButtonPosition.mock.calls.length, 0); | ||
| 773 | |||
| 774 | fakeWindow.isFullScreen.mockReturnValue(false); | ||
| 775 | fakeWindow.windowListeners.get("leave-full-screen")?.(); | ||
| 776 | assert.deepEqual(fakeWindow.setWindowButtonPosition.mock.lastCall, [{ x: 16, y: 19 }]); | ||
| 777 | }).pipe(Effect.provide(layer)); | ||
| 778 | }), | ||
| 779 | ); | ||
| 780 | |||
| 745 | it.effect("uses the persisted main window bounds when opening the window", () => | 781 | it.effect("uses the persisted main window bounds when opening the window", () => |
| 746 | Effect.gen(function* () { | 782 | Effect.gen(function* () { |
| 747 | const fakeWindow = makeFakeBrowserWindow(); | 783 | const fakeWindow = makeFakeBrowserWindow(); |
apps/desktop/src/window/DesktopWindow.ts+23-1| ... | @@ -31,6 +31,22 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; | ... | @@ -31,6 +31,22 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; |
| 31 | import { makeQuitShortcutHandler } from "./QuitHold.ts"; | 31 | import { makeQuitShortcutHandler } from "./QuitHold.ts"; |
| 32 | 32 | ||
| 33 | const TITLEBAR_HEIGHT = 40; | 33 | const TITLEBAR_HEIGHT = 40; |
| 34 | // Matches --workspace-topbar-height in apps/web/src/index.css. Native macOS | ||
| 35 | // buttons are 14 points tall and do not scale with the renderer's zoom. | ||
| 36 | const MACOS_WORKSPACE_TOPBAR_HEIGHT = 52; | ||
| 37 | const MACOS_WINDOW_BUTTON_RADIUS = 7; | ||
| 38 | |||
| 39 | function syncMacosWindowButtons(window: Electron.BrowserWindow): void { | ||
| 40 | if (window.isDestroyed() || window.isFullScreen()) return; | ||
| 41 | window.setWindowButtonPosition({ | ||
| 42 | x: 16, | ||
| 43 | y: Math.round( | ||
| 44 | (MACOS_WORKSPACE_TOPBAR_HEIGHT * window.webContents.getZoomFactor()) / 2 - | ||
| 45 | MACOS_WINDOW_BUTTON_RADIUS, | ||
| 46 | ), | ||
| 47 | }); | ||
| 48 | } | ||
| 49 | |||
| 34 | const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux | 50 | const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux |
| 35 | const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; | 51 | const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; |
| 36 | const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; | 52 | const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; |
| ... | @@ -241,7 +257,10 @@ function getWindowTitleBarOptions( | ... | @@ -241,7 +257,10 @@ function getWindowTitleBarOptions( |
| 241 | if (platform === "darwin") { | 257 | if (platform === "darwin") { |
| 242 | return { | 258 | return { |
| 243 | titleBarStyle: "hiddenInset", | 259 | titleBarStyle: "hiddenInset", |
| 244 | trafficLightPosition: { x: 16, y: 18 }, | 260 | trafficLightPosition: { |
| 261 | x: 16, | ||
| 262 | y: MACOS_WORKSPACE_TOPBAR_HEIGHT / 2 - MACOS_WINDOW_BUTTON_RADIUS, | ||
| 263 | }, | ||
| 245 | }; | 264 | }; |
| 246 | } | 265 | } |
| 247 | 266 | ||
| ... | @@ -660,6 +679,7 @@ export const make = Effect.gen(function* () { | ... | @@ -660,6 +679,7 @@ export const make = Effect.gen(function* () { |
| 660 | window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true); | 679 | window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true); |
| 661 | }); | 680 | }); |
| 662 | window.on("leave-full-screen", () => { | 681 | window.on("leave-full-screen", () => { |
| 682 | syncMacosWindowButtons(window); | ||
| 663 | window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, false); | 683 | window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, false); |
| 664 | }); | 684 | }); |
| 665 | } | 685 | } |
| ... | @@ -720,6 +740,7 @@ export const make = Effect.gen(function* () { | ... | @@ -720,6 +740,7 @@ export const make = Effect.gen(function* () { |
| 720 | clearDevelopmentLoadRetry(); | 740 | clearDevelopmentLoadRetry(); |
| 721 | developmentLoadRetryIndex = 0; | 741 | developmentLoadRetryIndex = 0; |
| 722 | window.setTitle(environment.displayName); | 742 | window.setTitle(environment.displayName); |
| 743 | if (environment.platform === "darwin") syncMacosWindowButtons(window); | ||
| 723 | }); | 744 | }); |
| 724 | window.webContents.on( | 745 | window.webContents.on( |
| 725 | "did-fail-load", | 746 | "did-fail-load", |
| ... | @@ -990,6 +1011,7 @@ export const make = Effect.gen(function* () { | ... | @@ -990,6 +1011,7 @@ export const make = Effect.gen(function* () { |
| 990 | webContents.setZoomLevel( | 1011 | webContents.setZoomLevel( |
| 991 | direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), | 1012 | direction === "reset" ? 0 : webContents.getZoomLevel() + (direction === "in" ? 0.5 : -0.5), |
| 992 | ); | 1013 | ); |
| 1014 | if (environment.platform === "darwin") syncMacosWindowButtons(window.value); | ||
| 993 | // Chromium pushes the new level down to embedded guests, which would zoom | 1015 | // Chromium pushes the new level down to embedded guests, which would zoom |
| 994 | // the previewed page along with the app UI. The preview browser keeps its | 1016 | // the previewed page along with the app UI. The preview browser keeps its |
| 995 | // own zoom, so put each guest back where the preview left it. | 1017 | // own zoom, so put each guest back where the preview left it. |
apps/web/src/components/AppSidebarLayout.tsx+1-1| ... | @@ -46,7 +46,7 @@ import { | ... | @@ -46,7 +46,7 @@ import { |
| 46 | } from "./ui/sidebar"; | 46 | } from "./ui/sidebar"; |
| 47 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 47 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 48 | 48 | ||
| 49 | const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; | 49 | const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "var(--desktop-window-controls-inset, 90px)"; |
| 50 | 50 | ||
| 51 | function subscribeToViewportWidth(onChange: () => void): () => void { | 51 | function subscribeToViewportWidth(onChange: () => void): () => void { |
| 52 | window.addEventListener("resize", onChange); | 52 | window.addEventListener("resize", onChange); |
apps/web/src/components/sidebar/SidebarChrome.tsx+4-3| ... | @@ -91,11 +91,12 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { | ... | @@ -91,11 +91,12 @@ function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { |
| 91 | )} | 91 | )} |
| 92 | to="/" | 92 | to="/" |
| 93 | > | 93 | > |
| 94 | <span className="inline-flex min-w-0 items-baseline gap-1"> | 94 | {/* Center the visible capitals, without the font's ascender/descender space. */} |
| 95 | <T3Wordmark aria-label="T3" className="h-2.5 w-auto shrink-0" /> | 95 | <span className="inline-flex min-w-0 items-baseline gap-1 text-sm font-medium tracking-tight"> |
| 96 | <T3Wordmark aria-label="T3" className="h-[1cap] w-auto shrink-0" /> | ||
| 96 | <span | 97 | <span |
| 97 | className={cn( | 98 | className={cn( |
| 98 | "truncate text-sm font-medium tracking-tight", | 99 | "truncate [text-box:trim-both_cap_alphabetic]", |
| 99 | onBackdrop ? "text-white/70" : "text-muted-foreground", | 100 | onBackdrop ? "text-white/70" : "text-muted-foreground", |
| 100 | )} | 101 | )} |
| 101 | > | 102 | > |