diff --git a/Cargo.lock b/Cargo.lock index 4de61bec..0469b2a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3462,10 +3462,15 @@ version = "0.2.0" dependencies = [ "arboard", "base64 0.22.1", + "block2", + "dispatch2", "gtk", "image", "libc", "notify", + "objc2", + "objc2-app-kit", + "objc2-foundation", "pickforge-core", "sentry", "serde", diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md index b56d7c77..53b043ac 100644 --- a/docs/releases/UNRELEASED.md +++ b/docs/releases/UNRELEASED.md @@ -9,7 +9,8 @@ reset this file. - The macOS window now uses native chrome: system rounded corners, shadow, and traffic lights via a titlebar overlay (the custom window-control buttons and edge resize handles are macOS-native now). Windows/Linux keep - the existing frameless chrome. + the existing frameless chrome. The traffic lights remain centered through + resizing, fullscreen transitions, and interface zoom changes. - The Codex agent-model picker now merges live-discovered models (`codex debug models --bundled`) with the curated static table, so new Codex releases can show up without a PickForge update. Curated entries keep diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0db319e3..46ad7c83 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -44,6 +44,22 @@ libc = "0.2" [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18" +[target.'cfg(target_os = "macos")'.dependencies] +block2 = "0.6" +dispatch2 = "0.3" +objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = [ + "NSButton", + "NSControl", + "NSResponder", + "NSView", + "NSWindow", +] } +objc2-foundation = { version = "0.3", default-features = false, features = [ + "NSNotification", + "NSString", +] } + [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] } tauri-plugin-updater = "2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 21a25fc7..15d7f11d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -24,6 +24,7 @@ mod test_support; mod voice_commands; mod vm_commands; mod watch_commands; +mod window_commands; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -243,6 +244,10 @@ fn setup_app( let _ = app .asset_protocol_scope() .allow_directory(agent_chat_commands::stash_image_dir(), true); + #[cfg(target_os = "macos")] + if let Some(window) = app.get_webview_window("main") { + window_commands::position_at_default(&window); + } Ok(()) } @@ -414,6 +419,8 @@ pub fn run() { voice_commands::voice_speak_cancel, telemetry_commands::telemetry_get, telemetry_commands::telemetry_set, + #[cfg(target_os = "macos")] + window_commands::set_traffic_light_bar_height, #[cfg(target_os = "linux")] graphics_commands::linux_graphics_get, #[cfg(target_os = "linux")] diff --git a/src-tauri/src/window_commands.rs b/src-tauri/src/window_commands.rs new file mode 100644 index 00000000..71698603 --- /dev/null +++ b/src-tauri/src/window_commands.rs @@ -0,0 +1,225 @@ +#[cfg(target_os = "macos")] +const DEFAULT_BAR_HEIGHT: f64 = 38.0; + +#[cfg(any(target_os = "macos", test))] +fn centered_container_height(bar_height: f64, button_height: f64, natural_y: f64) -> f64 { + let top_padding = ((bar_height - button_height) / 2.0).max(0.0); + button_height + natural_y + top_padding +} + +#[cfg(target_os = "macos")] +mod macos { + use std::ptr::NonNull; + use std::sync::atomic::{AtomicPtr, AtomicU64, AtomicU8, Ordering}; + use std::sync::{Mutex, OnceLock}; + use std::time::Duration; + + use block2::RcBlock; + use dispatch2::{DispatchQueue, DispatchTime}; + use objc2::rc::Retained; + use objc2::runtime::AnyObject; + use objc2_app_kit::{ + NSWindow, NSWindowButton, NSWindowDidChangeBackingPropertiesNotification, + NSWindowDidEndLiveResizeNotification, NSWindowDidEnterFullScreenNotification, + NSWindowDidExitFullScreenNotification, NSWindowDidResizeNotification, + NSWindowDidUpdateNotification, NSWindowStyleMask, NSWindowWillEnterFullScreenNotification, + NSWindowWillExitFullScreenNotification, + }; + use objc2_foundation::{NSNotification, NSNotificationCenter, NSNotificationName}; + use tauri::WebviewWindow; + + use super::{centered_container_height, DEFAULT_BAR_HEIGHT}; + + const CLOSE_BUTTON_X: f64 = 14.0; + const MIN_BAR_HEIGHT: f64 = 16.0; + const MAX_BAR_HEIGHT: f64 = 160.0; + const FRAME_EPSILON: f64 = 0.25; + const PHASE_IDLE: u8 = 0; + const PHASE_ENTERING: u8 = 1; + + static TARGET_BAR_HEIGHT: Mutex = Mutex::new(DEFAULT_BAR_HEIGHT); + static NATURAL_BUTTON_Y: OnceLock = OnceLock::new(); + static BUTTON_SPACING: OnceLock = OnceLock::new(); + static OBSERVED_WINDOW: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); + static FULLSCREEN_PHASE: AtomicU8 = AtomicU8::new(PHASE_IDLE); + static PHASE_GENERATION: AtomicU64 = AtomicU64::new(0); + + pub(super) fn set_bar_height(window: WebviewWindow, bar_height: f64) -> Result<(), String> { + if !bar_height.is_finite() || !(MIN_BAR_HEIGHT..=MAX_BAR_HEIGHT).contains(&bar_height) { + return Err(format!( + "traffic-light bar height must be finite and between {MIN_BAR_HEIGHT} and {MAX_BAR_HEIGHT} points; got {bar_height}" + )); + } + *TARGET_BAR_HEIGHT + .lock() + .expect("traffic-light target lock poisoned") = bar_height; + + let main_window = window.clone(); + window + .run_on_main_thread(move || unsafe { install_and_apply(&main_window) }) + .map_err(|error| error.to_string()) + } + + pub fn position_at_default(window: &WebviewWindow) { + let main_window = window.clone(); + let _ = window.run_on_main_thread(move || unsafe { install_and_apply(&main_window) }); + } + + unsafe fn install_and_apply(window: &WebviewWindow) { + let Ok(pointer) = window.ns_window() else { + return; + }; + let ns_window = &*(pointer.cast::()); + let window_pointer = (ns_window as *const NSWindow).cast_mut(); + if OBSERVED_WINDOW.swap(window_pointer, Ordering::SeqCst) != window_pointer { + install_observers(ns_window); + } + apply_target(ns_window); + } + + unsafe fn install_observers(ns_window: &NSWindow) { + for name in [ + NSWindowDidResizeNotification, + NSWindowDidEndLiveResizeNotification, + NSWindowDidUpdateNotification, + NSWindowDidChangeBackingPropertiesNotification, + ] { + observe_window(name, ns_window, |window| unsafe { apply_target(window) }); + } + + observe_window(NSWindowWillEnterFullScreenNotification, ns_window, |_| { + begin_fullscreen_phase(PHASE_ENTERING); + }); + observe_window(NSWindowDidEnterFullScreenNotification, ns_window, |_| { + end_fullscreen_phase(); + }); + observe_window(NSWindowWillExitFullScreenNotification, ns_window, |_| { + end_fullscreen_phase(); + }); + observe_window(NSWindowDidExitFullScreenNotification, ns_window, |window| { + end_fullscreen_phase(); + unsafe { apply_target(window) }; + }); + } + + fn begin_fullscreen_phase(phase: u8) { + FULLSCREEN_PHASE.store(phase, Ordering::SeqCst); + let generation = PHASE_GENERATION.fetch_add(1, Ordering::SeqCst) + 1; + let deadline = DispatchTime::try_from(Duration::from_secs(2)) + .expect("two-second dispatch deadline must be representable"); + let _ = DispatchQueue::main().after(deadline, move || { + if PHASE_GENERATION.load(Ordering::SeqCst) == generation { + FULLSCREEN_PHASE.store(PHASE_IDLE, Ordering::SeqCst); + } + }); + } + + fn end_fullscreen_phase() { + PHASE_GENERATION.fetch_add(1, Ordering::SeqCst); + FULLSCREEN_PHASE.store(PHASE_IDLE, Ordering::SeqCst); + } + + unsafe fn observe_window( + name: &'static NSNotificationName, + ns_window: &NSWindow, + handler: impl Fn(&NSWindow) + 'static, + ) { + let retained = Retained::retain((ns_window as *const NSWindow).cast_mut()) + .expect("main NSWindow pointer must be non-null"); + let object = &*((ns_window as *const NSWindow).cast::()); + let block = RcBlock::new(move |_: NonNull| handler(&retained)); + let token = NSNotificationCenter::defaultCenter() + .addObserverForName_object_queue_usingBlock(Some(name), Some(object), None, &block); + std::mem::forget(block); + std::mem::forget(token); + } + + unsafe fn apply_target(ns_window: &NSWindow) { + let phase = FULLSCREEN_PHASE.load(Ordering::SeqCst); + if phase == PHASE_ENTERING + || ns_window + .styleMask() + .contains(NSWindowStyleMask::FullScreen) + { + return; + } + + let Some(close) = ns_window.standardWindowButton(NSWindowButton::CloseButton) else { + return; + }; + let Some(minimize) = ns_window.standardWindowButton(NSWindowButton::MiniaturizeButton) + else { + return; + }; + let Some(zoom) = ns_window.standardWindowButton(NSWindowButton::ZoomButton) else { + return; + }; + let Some(container) = close.superview().and_then(|view| view.superview()) else { + return; + }; + + let close_frame = close.frame(); + let measured_spacing = minimize.frame().origin.x - close_frame.origin.x; + if close_frame.size.height <= 1.0 || measured_spacing <= 1.0 { + return; + } + let natural_y = *NATURAL_BUTTON_Y.get_or_init(|| close_frame.origin.y); + let spacing = *BUTTON_SPACING.get_or_init(|| measured_spacing); + let bar_height = *TARGET_BAR_HEIGHT + .lock() + .expect("traffic-light target lock poisoned"); + let container_height = + centered_container_height(bar_height, close_frame.size.height, natural_y); + + let mut container_frame = container.frame(); + let target_container_y = ns_window.frame().size.height - container_height; + if differs(container_frame.size.height, container_height) + || differs(container_frame.origin.y, target_container_y) + { + container_frame.size.height = container_height; + container_frame.origin.y = target_container_y; + container.setFrame(container_frame); + } + + for (index, button) in [&close, &minimize, &zoom].into_iter().enumerate() { + let mut frame = button.frame(); + let target_x = CLOSE_BUTTON_X + index as f64 * spacing; + if differs(frame.origin.x, target_x) || differs(frame.origin.y, natural_y) { + frame.origin.x = target_x; + frame.origin.y = natural_y; + button.setFrameOrigin(frame.origin); + } + } + } + + fn differs(actual: f64, target: f64) -> bool { + (actual - target).abs() > FRAME_EPSILON + } +} + +#[cfg(target_os = "macos")] +pub use macos::position_at_default; + +#[cfg(target_os = "macos")] +#[tauri::command] +pub fn set_traffic_light_bar_height( + window: tauri::WebviewWindow, + bar_height: f64, +) -> Result<(), String> { + macos::set_bar_height(window, bar_height) +} + +#[cfg(test)] +mod tests { + use super::centered_container_height; + + #[test] + fn container_height_centers_buttons_at_every_supported_zoom_sample() { + for (bar_height, natural_y) in [(38.0, 5.0), (47.5, 5.0), (57.0, 7.0)] { + let button_height = 14.0; + let container_height = centered_container_height(bar_height, button_height, natural_y); + let center_from_window_top = container_height - natural_y - button_height / 2.0; + assert!((center_from_window_top - bar_height / 2.0).abs() < f64::EPSILON); + } + } +} diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index 86f50a98..f367efc0 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -11,7 +11,6 @@ "decorations": true, "titleBarStyle": "Overlay", "hiddenTitle": true, - "trafficLightPosition": { "x": 14, "y": 13 }, "transparent": false, "backgroundColor": "#0a0a0b", "dragDropEnabled": true, diff --git a/src/lib/trafficLights.ts b/src/lib/trafficLights.ts new file mode 100644 index 00000000..3096eb75 --- /dev/null +++ b/src/lib/trafficLights.ts @@ -0,0 +1,19 @@ +import { invoke } from "@tauri-apps/api/core"; +import { hostPlatform } from "./platform"; + +function titlebarHeight(): number | undefined { + const value = getComputedStyle(document.documentElement).getPropertyValue("--pf-titlebar-h"); + const height = Number.parseFloat(value); + return Number.isFinite(height) && height > 0 ? height : undefined; +} + +export async function applyTrafficLightBarHeight(zoom: number): Promise { + if (hostPlatform() !== "macos") return; + const height = titlebarHeight(); + if (height === undefined) return; + try { + await invoke("set_traffic_light_bar_height", { barHeight: height * zoom }); + } catch (error) { + console.debug("[pickforge] traffic-light positioning unavailable", error); + } +} diff --git a/src/lib/zoom.ts b/src/lib/zoom.ts index a47c0aaa..ea778b29 100644 --- a/src/lib/zoom.ts +++ b/src/lib/zoom.ts @@ -3,6 +3,7 @@ // zoom raises the effective device-pixel-ratio and re-rasterizes everything // (UI + terminal) crisply. Ctrl/Cmd +/-/0, 0.25 steps, persisted. import { createSignal } from "solid-js"; +import { applyTrafficLightBarHeight } from "./trafficLights"; const KEY = "pickforge.zoom"; const MIN = 0.5; @@ -39,11 +40,16 @@ async function applyToWebview(z: number): Promise { } } +async function applyInterfaceZoom(z: number): Promise { + await applyToWebview(z); + await applyTrafficLightBarHeight(z); +} + function commit(z: number): void { const c = clampStep(z); setZoom(c); localStorage.setItem(KEY, String(c)); - void applyToWebview(c); + void applyInterfaceZoom(c); } export function zoomIn(): void { @@ -58,7 +64,7 @@ export function zoomReset(): void { /** Re-apply the persisted zoom to the webview (call once the app is mounted). */ export function applyPersistedZoom(): void { - void applyToWebview(zoom()); + void applyInterfaceZoom(zoom()); } /** True if a keydown is a zoom shortcut; performs the zoom and returns true. */ diff --git a/tests/macos/trafficLightAlignment.applescript b/tests/macos/trafficLightAlignment.applescript new file mode 100644 index 00000000..511c72fb --- /dev/null +++ b/tests/macos/trafficLightAlignment.applescript @@ -0,0 +1,145 @@ +-- Run against an open dev app, passing its visible zoom factor: +-- osascript tests/macos/trafficLightAlignment.applescript pickforge-tauri 1.25 +on run argv + set processName to "pickforge-tauri" + set zoomFactor to 1 + if (count argv) > 0 then set processName to item 1 of argv + if (count argv) > 1 then set zoomFactor to item 2 of argv as real + set originalState to captureWindowState(processName) + + try + resetWindow(processName) + set reports to {} + assertAligned(processName, zoomFactor, "at startup", reports) + fullscreenRoundTrip(processName) + fullscreenRoundTrip(processName) + resizeTopEdge(processName) + assertAligned(processName, zoomFactor, "after fullscreen + resize", reports) + set output to joinLines(reports) + restoreWindow(processName, originalState) + return output + on error errorMessage number errorNumber + restoreWindow(processName, originalState) + error errorMessage number errorNumber + end try +end run + +on captureWindowState(processName) + tell application "System Events" + tell process processName + set w to first window whose name is "PickForge" + if value of attribute "AXFullScreen" of w then error "traffic-light check requires a windowed PickForge app" + return {position of w, size of w} + end tell + end tell +end captureWindowState + +on restoreWindow(processName, originalState) + try + tell application "System Events" + tell process processName + set w to first window whose name is "PickForge" + if value of attribute "AXFullScreen" of w then set value of attribute "AXFullScreen" of w to false + set position of w to item 1 of originalState + set size of w to item 2 of originalState + end tell + end tell + end try +end restoreWindow + +on resetWindow(processName) + tell application "System Events" + tell process processName + set frontmost to true + set w to first window whose name is "PickForge" + set position of w to {160, 100} + set size of w to {1000, 700} + end tell + end tell + delay 0.3 +end resetWindow + +on fullscreenRoundTrip(processName) + tell application "System Events" + tell process processName + set w to first window whose name is "PickForge" + set value of attribute "AXFullScreen" of w to true + end tell + end tell + waitForFullscreen(processName, true) + delay 0.3 + tell application "System Events" + tell process processName + set w to first window whose name is "PickForge" + set value of attribute "AXFullScreen" of w to false + end tell + end tell + waitForFullscreen(processName, false) + delay 0.3 +end fullscreenRoundTrip + +on waitForFullscreen(processName, expected) + repeat 50 times + tell application "System Events" + tell process processName + try + set w to first window whose name is "PickForge" + if (value of attribute "AXFullScreen" of w) is expected then return + end try + end tell + end tell + delay 0.1 + end repeat + error "timed out waiting for fullscreen=" & expected +end waitForFullscreen + +on resizeTopEdge(processName) + tell application "System Events" + tell process processName + set w to first window whose name is "PickForge" + set startPosition to position of w + set startSize to size of w + repeat with step from 1 to 8 + set position of w to {(item 1 of startPosition), (item 2 of startPosition) - (step * 4)} + set size of w to {(item 1 of startSize), (item 2 of startSize) + (step * 4)} + delay 0.04 + end repeat + end tell + end tell + delay 0.6 +end resizeTopEdge + +on assertAligned(processName, zoomFactor, stage, reports) + set baseTitlebarHeight to 38 + set measured to geometry(processName) + set expectedCenter to (baseTitlebarHeight * zoomFactor) / 2 + set leftInset to item 1 of measured + set centerInset to item 2 of measured + set report to (((zoomFactor * 100) as integer) as text) & "% " & stage & ": left=" & (leftInset as text) & " center=" & (centerInset as text) + set end of reports to report + if leftInset < 12 or leftInset > 16 or (centerInset - expectedCenter) < -1.5 or (centerInset - expectedCenter) > 1.5 then + error "traffic lights misaligned: " & report & "; expected left=14±2 center=" & expectedCenter & "±1.5" + end if +end assertAligned + +on geometry(processName) + tell application "System Events" + tell process processName + set w to first window whose name is "PickForge" + set windowPosition to position of w + set closeButton to first button of w whose description is "close button" + set buttonPosition to position of closeButton + set buttonSize to size of closeButton + set leftInset to (item 1 of buttonPosition) - (item 1 of windowPosition) + set centerInset to (item 2 of buttonPosition) - (item 2 of windowPosition) + ((item 2 of buttonSize) / 2) + return {leftInset, centerInset} + end tell + end tell +end geometry + +on joinLines(values) + set AppleScript's text item delimiters to linefeed + set output to values as text + set AppleScript's text item delimiters to "" + return output +end joinLines diff --git a/tests/unit/tauriMacosWindowConf.test.ts b/tests/unit/tauriMacosWindowConf.test.ts index 39023a87..1c993a38 100644 --- a/tests/unit/tauriMacosWindowConf.test.ts +++ b/tests/unit/tauriMacosWindowConf.test.ts @@ -3,9 +3,10 @@ // This guards against drift: a field edited in the base config but forgotten in // the macOS overlay would silently not apply on macOS. import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; import { describe, expect, it } from "vitest"; -const MACOS_ONLY_FIELDS = ["decorations", "titleBarStyle", "hiddenTitle", "trafficLightPosition"]; +const MACOS_ONLY_FIELDS = ["decorations", "titleBarStyle", "hiddenTitle"]; function mainWindow(path: string): Record { const conf = JSON.parse(readFileSync(path, "utf8")) as { @@ -28,5 +29,18 @@ describe("tauri.macos.conf.json window entry", () => { expect(macos.decorations).toBe(true); expect(macos.titleBarStyle).toBe("Overlay"); expect(macos.hiddenTitle).toBe(true); + expect(macos.trafficLightPosition).toBeUndefined(); + }); + + it("keeps the native startup height aligned with the titlebar token", () => { + const tokensPath = createRequire(import.meta.url).resolve("@pickforge/brand/tokens.css"); + const token = readFileSync(tokensPath, "utf8").match(/--pf-titlebar-h:\s*(\d+)px/); + const rust = readFileSync("src-tauri/src/window_commands.rs", "utf8"); + const smoke = readFileSync("tests/macos/trafficLightAlignment.applescript", "utf8"); + + const barHeight = Number(token?.[1]); + expect(barHeight).toBeGreaterThan(0); + expect(rust).toContain(`const DEFAULT_BAR_HEIGHT: f64 = ${barHeight}.0;`); + expect(smoke).toContain(`set baseTitlebarHeight to ${barHeight}`); }); }); diff --git a/tests/unit/trafficLights.test.ts b/tests/unit/trafficLights.test.ts new file mode 100644 index 00000000..0d0bd2a9 --- /dev/null +++ b/tests/unit/trafficLights.test.ts @@ -0,0 +1,38 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn<(...args: unknown[]) => Promise>(), + platform: "macos", +})); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); +vi.mock("../../src/lib/platform", () => ({ hostPlatform: () => mocks.platform })); + +describe("macOS traffic-light geometry", () => { + beforeEach(() => { + vi.resetModules(); + mocks.invoke.mockReset().mockResolvedValue(undefined); + mocks.platform = "macos"; + document.documentElement.style.setProperty("--pf-titlebar-h", "38px"); + }); + + it("reports the zoomed titlebar height to the native positioner", async () => { + const { applyTrafficLightBarHeight } = await import("../../src/lib/trafficLights"); + + await applyTrafficLightBarHeight(1.25); + + expect(mocks.invoke).toHaveBeenCalledWith("set_traffic_light_bar_height", { + barHeight: 47.5, + }); + }); + + it("leaves non-macOS window controls untouched", async () => { + mocks.platform = "linux"; + const { applyTrafficLightBarHeight } = await import("../../src/lib/trafficLights"); + + await applyTrafficLightBarHeight(1.5); + + expect(mocks.invoke).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/zoom.test.ts b/tests/unit/zoom.test.ts new file mode 100644 index 00000000..23af61fa --- /dev/null +++ b/tests/unit/zoom.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + setZoom: vi.fn<(zoom: number) => Promise>(), + applyTrafficLights: vi.fn<(zoom: number) => Promise>(), + calls: [] as string[], +})); + +vi.mock("@tauri-apps/api/webview", () => ({ + getCurrentWebview: () => ({ setZoom: mocks.setZoom }), +})); +vi.mock("../../src/lib/trafficLights", () => ({ + applyTrafficLightBarHeight: mocks.applyTrafficLights, +})); + +const stored = new Map(); +const storage = { + getItem: (key: string) => stored.get(key) ?? null, + setItem: (key: string, value: string) => void stored.set(key, value), + removeItem: (key: string) => void stored.delete(key), + clear: () => stored.clear(), + key: () => null, + get length() { + return stored.size; + }, +} satisfies Storage; + +describe("interface zoom", () => { + beforeEach(() => { + vi.resetModules(); + Object.defineProperty(globalThis, "localStorage", { value: storage, configurable: true }); + localStorage.clear(); + Object.assign(window, { __TAURI_INTERNALS__: {} }); + mocks.calls.length = 0; + mocks.setZoom.mockReset().mockImplementation(async (zoom) => { + mocks.calls.push(`webview:${zoom}`); + }); + mocks.applyTrafficLights.mockReset().mockImplementation(async (zoom) => { + mocks.calls.push(`traffic:${zoom}`); + }); + }); + + it("updates native traffic lights after the webview reaches 125%", async () => { + const { zoomIn } = await import("../../src/lib/zoom"); + + zoomIn(); + await vi.waitFor(() => expect(mocks.applyTrafficLights).toHaveBeenCalledWith(1.25)); + + expect(mocks.calls).toEqual(["webview:1.25", "traffic:1.25"]); + }); + + it("applies persisted zoom to both surfaces on startup", async () => { + localStorage.setItem("pickforge.zoom", "1.5"); + const { applyPersistedZoom } = await import("../../src/lib/zoom"); + + applyPersistedZoom(); + await vi.waitFor(() => expect(mocks.applyTrafficLights).toHaveBeenCalledWith(1.5)); + + expect(mocks.calls).toEqual(["webview:1.5", "traffic:1.5"]); + }); +});