diff --git a/docs/superpowers/specs/2026-09-01-work-app-design.md b/docs/superpowers/specs/2026-09-01-work-app-design.md index 5273d91..85280d9 100644 --- a/docs/superpowers/specs/2026-09-01-work-app-design.md +++ b/docs/superpowers/specs/2026-09-01-work-app-design.md @@ -281,6 +281,23 @@ a corner radius applied to each app's layer because a native view sitting on top shell cannot be clipped by the CSS around it. It is gone. A window that behaves like a window is worth more than one that looks bespoke. +### The title bar offset + +A child webview is positioned against the **window frame**; the shell measures the hole it +left from inside the **content view**. With a borderless window those origins coincide, so +this never came up. With a title bar they are a title bar apart, and every app was drawn +that much too high: it painted over the right-hand part of the bar — which read as the bar +being tinted by whichever site was open — and left a strip of the same height along the +bottom. + +Tauri cannot report that height. Measured on this machine, `inner_position` and +`outer_position` return the same point, and `inner_size` and `outer_size` return the same +size, against a window whose content is plainly a title bar shorter than its frame. Both +differences are zero and both are useless. + +`NSWindow.contentLayoutRect` knows. The height is asked for once through it and cached, +since it does not change. + The margin That margin is the only part of the window that is not a web page, and therefore the only place left to grab it by. Full screen has no use for it and gets the room back. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 82328a7..693bc8f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,7 +27,7 @@ uuid = { version = "1", features = ["v4"] } objc2 = "0.6" objc2-web-kit = { version = "0.3.2", features = ["WKWebView", "WKWebViewConfiguration", "WKPreferences", "WKSnapshotConfiguration", "block2"] } objc2-app-kit = { version = "0.3", features = ["NSImage", "NSBitmapImageRep", "NSImageRep", "NSGraphics"] } -objc2-foundation = { version = "0.3", features = ["NSData", "NSString", "NSDictionary", "NSValue", "NSError"] } +objc2-foundation = { version = "0.3", features = ["NSData", "NSString", "NSDictionary", "NSValue", "NSError", "NSGeometry"] } block2 = "0.6" # Notifications are raised here rather than through the plugin, which offers no # way to learn that one was clicked. diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 994dded..7f4a497 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -17,6 +17,9 @@ pub struct AppState { pub dir: PathBuf, /// Corner radius the window's inner frame is currently drawn with. pub radius: Mutex, + /// Title bar height: how far a child webview's origin sits above the + /// content the shell measures from. + pub chrome: Mutex, pub config: Mutex, pub active: Mutex>, pub stage: Mutex, @@ -72,7 +75,8 @@ pub fn build_state(handle: &AppHandle) -> Result { let config = config::load(&dir)?; Ok(AppState { dir, - radius: Mutex::new(12.0), + radius: Mutex::new(0.0), + chrome: Mutex::new(0.0), config: Mutex::new(config), active: Mutex::new(None), stage: Mutex::new((240.0, 38.0, 800.0, 600.0)), @@ -524,9 +528,15 @@ pub fn notification_status(app: AppHandle) -> String { let app_state = app.state::(); let last = app_state.last_notification.lock().unwrap().clone(); let from_page = if last.is_empty() { "none yet".into() } else { last }; + let stage = *app_state.stage.lock().unwrap(); + let offset = webviews::chrome_offset(&app); let shot = app_state.last_snapshot.lock().unwrap().clone(); let shot = if shot.is_empty() { "not taken".into() } else { shot }; - format!("permission: {state} · direct: {raised} · from page: {from_page} · backdrop: {shot}") + format!( + "permission: {state} · direct: {raised} · from page: {from_page} · backdrop: {shot} \ + · stage: {:.0},{:.0} {:.0}×{:.0} · chrome offset: {offset:.1}", + stage.0, stage.1, stage.2, stage.3 + ) } /// Asks macOS for notification permission, and claims this app's identity. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2fd9de5..275f464 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -82,6 +82,13 @@ pub fn run() { // the prompt does not arrive attached to someone else's message. commands::ensure_notification_permission(&app.handle().clone()); + // Measured once: every app is positioned against the window frame + // while the shell measures from inside the content view, and the + // two are a title bar apart. + let handle = app.handle().clone(); + let chrome = webviews::measure_chrome(&handle); + *handle.state::().chrome.lock().unwrap() = chrome; + let theme = app.state::().cfg().settings.theme; commands::apply_window_theme(&app.handle().clone(), &theme); Ok(()) diff --git a/src-tauri/src/webviews.rs b/src-tauri/src/webviews.rs index b50b0f5..f17d810 100644 --- a/src-tauri/src/webviews.rs +++ b/src-tauri/src/webviews.rs @@ -587,7 +587,7 @@ pub fn create( window .add_child( builder, - LogicalPosition::new(stage.0, stage.1), + LogicalPosition::new(stage.0, stage.1 + chrome_offset(handle)), LogicalSize::new(stage.2, stage.3), ) .map_err(|e| e.to_string())?; @@ -601,6 +601,62 @@ pub fn create( Ok(()) } +/// How far the window's frame sits above its content, in logical pixels. +/// +/// A child webview is positioned against the window frame, but the shell +/// measures the hole it left from inside the content view — and with a title +/// bar those two origins are a title bar apart. Without this every app is drawn +/// a title bar too high: it paints over the right half of the bar, which is why +/// the bar looked like it was tinted by whichever site was open, and leaves an +/// empty strip of the same height along the bottom. +/// +/// Asks AppKit how tall the title bar is, and remembers the answer. +/// +/// Tauri cannot say. Both `inner_position`/`outer_position` and +/// `inner_size`/`outer_size` come back identical on macOS — measured, both +/// reported a difference of zero against a window whose content is plainly a +/// title bar shorter than its frame. `contentLayoutRect` is the one thing that +/// knows, so it is asked once and cached; the height does not change. +#[cfg(target_os = "macos")] +pub fn measure_chrome(handle: &AppHandle) -> f64 { + use objc2::runtime::AnyObject; + use objc2_foundation::NSRect; + + let Some(wv) = handle.get_webview_window("main") else { return 0.0 }; + let (tx, rx) = std::sync::mpsc::channel::(); + + let sent = wv.with_webview(move |platform| unsafe { + let view = platform.inner() as *mut AnyObject; + if view.is_null() { + let _ = tx.send(0.0); + return; + } + let window: *mut AnyObject = objc2::msg_send![view, window]; + if window.is_null() { + let _ = tx.send(0.0); + return; + } + let frame: NSRect = objc2::msg_send![window, frame]; + let content: NSRect = objc2::msg_send![window, contentLayoutRect]; + let _ = tx.send((frame.size.height - content.size.height).max(0.0)); + }); + + if sent.is_err() { + return 0.0; + } + rx.recv_timeout(std::time::Duration::from_millis(500)).unwrap_or(0.0) +} + +#[cfg(not(target_os = "macos"))] +pub fn measure_chrome(_: &AppHandle) -> f64 { + 0.0 +} + +/// How far the window's frame sits above its content, in logical pixels. +pub fn chrome_offset(handle: &AppHandle) -> f64 { + *handle.state::().chrome.lock().unwrap() +} + fn radius(handle: &AppHandle) -> f64 { *handle.state::().radius.lock().unwrap() } @@ -623,9 +679,10 @@ pub fn show_only( cfg: &Config, stage: (f64, f64, f64, f64), ) { + let top = stage.1 + chrome_offset(handle); for app in &cfg.apps { let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue }; - let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1)); + let _ = wv.set_position(LogicalPosition::new(stage.0, top)); let _ = wv.set_size(LogicalSize::new(stage.2, stage.3)); let _ = wv.set_zoom(app.zoom); let _ = wv.show(); @@ -649,9 +706,10 @@ pub fn set_stage( radius: f64, ) { let cfg = handle.state::().cfg(); + let top = stage.1 + chrome_offset(handle); for app in &cfg.apps { let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue }; - let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1)); + let _ = wv.set_position(LogicalPosition::new(stage.0, top)); let _ = wv.set_size(LogicalSize::new(stage.2, stage.3)); set_corner_radius(handle, &app.id, radius); } @@ -669,7 +727,10 @@ pub fn set_stage( pub fn hide_all(handle: &AppHandle, cfg: &Config, stage: (f64, f64, f64, f64)) { for app in &cfg.apps { if let Some(wv) = handle.get_webview(&label_for(&app.id)) { - let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1 + PARKED_OFFSET)); + let _ = wv.set_position(LogicalPosition::new( + stage.0, + stage.1 + chrome_offset(handle) + PARKED_OFFSET, + )); } // Nothing is on screen behind a dialog, so nothing should think it is. set_page_visibility(handle, &app.id, false); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c56d002..52d2d2f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -18,7 +18,8 @@ "height": 900, "minWidth": 900, "minHeight": 600, - "center": true + "center": true, + "hiddenTitle": true } ], "security": { "csp": null } diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index db996d4..3547182 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -122,7 +122,7 @@ export default function Nav({ {/* Only the expander survives the rail's header. Back and forward are a two-finger swipe and reload is ⌘R, so a toolbar here would be clutter standing in for something nobody asked for. */} -
+
@@ -173,7 +173,7 @@ export default function Nav({ return (