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 f486400..0b34249 100644 --- a/docs/superpowers/specs/2026-09-01-work-app-design.md +++ b/docs/superpowers/specs/2026-09-01-work-app-design.md @@ -150,7 +150,11 @@ ladder, and light and dark both designed rather than one derived from the other. - **Nav, expanded (~240px)** — traffic-light drag inset, title with cog and collapse chevron, then groups as collapsible sections with uppercase tracked labels, apps as favicon-and-name rows. The active row inverts to `bg-slate-900 text-white`. -- **Nav, collapsed (~52px)** — favicons only, active marked with a left accent bar, name on +- **Nav, collapsed** — as wide as the traffic lights need, which is asked of AppKit + rather than guessed: the rail is the close button's own left inset plus the zoom + button's right edge, so the cluster keeps the same margin on both sides. That inset is + macOS's to choose and has changed between releases. +- **Nav, collapsed (old note)** — favicons only, active marked with a left accent bar, name on hover, hairline dividers between groups. Still clickable, so switching does not require expanding. - **Top bar (~38px)** — back, forward, reload, the current URL muted and truncated, and diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c5f6d80..c0cb5e5 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -20,6 +20,10 @@ pub struct AppState { /// Title bar height: how far a child webview's origin sits above the /// content the shell measures from. pub chrome: Mutex, + /// Narrowest the collapsed rail can be while the traffic lights keep equal + /// margins. Measured at startup: asking on demand deadlocks, because the + /// answer arrives on the main thread and a command may be waiting on it. + pub rail: Mutex, /// When each app last raised a notification in its own words, so a count /// does not immediately say the same thing again in worse words. pub last_spoke: Mutex>, @@ -83,6 +87,7 @@ pub fn build_state(handle: &AppHandle) -> Result { dir, radius: Mutex::new(0.0), chrome: Mutex::new(0.0), + rail: Mutex::new(72.0), last_spoke: Mutex::new(std::collections::HashMap::new()), pending_password: Mutex::new(None), config: Mutex::new(config), @@ -724,6 +729,24 @@ pub fn paint_window_chrome(app: &AppHandle, dark: bool) { #[cfg(not(target_os = "macos"))] pub fn paint_window_chrome(_: &AppHandle, _: bool) {} +/// The narrowest the collapsed rail can be while the traffic lights keep the +/// same margin on their right as macOS gave them on their left. +#[tauri::command] +pub fn rail_width(state: State<'_, AppState>) -> f64 { + *state.rail.lock().unwrap() +} + +/// Works out that width, once, while nothing is waiting on the main thread. +pub fn measure_rail(app: &AppHandle) -> f64 { + match webviews::traffic_lights(app) { + // Equal by construction: the left margin is the cluster's own inset, so + // the same gap on the right means a rail of `right + left`. + Some((left, right)) => right + left, + // Nothing to measure against; the old fixed width, which was close. + None => 72.0, + } +} + /// How tall the title bar is, so the shell can keep clear of it. #[tauri::command] pub fn chrome_height(app: AppHandle) -> f64 { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 51837a2..aab7e16 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -91,6 +91,9 @@ pub fn run() { let chrome = webviews::measure_chrome(&handle); *handle.state::().chrome.lock().unwrap() = chrome; + let rail = commands::measure_rail(&handle); + *handle.state::().rail.lock().unwrap() = rail; + let theme = app.state::().cfg().settings.theme; commands::apply_window_theme(&app.handle().clone(), &theme); Ok(()) @@ -120,6 +123,7 @@ pub fn run() { commands::reset_config, commands::set_window_chrome, commands::chrome_height, + commands::rail_width, commands::unread_counts, commands::focus_window, commands::save_password, diff --git a/src-tauri/src/webviews.rs b/src-tauri/src/webviews.rs index 7379ead..2fd09f1 100644 --- a/src-tauri/src/webviews.rs +++ b/src-tauri/src/webviews.rs @@ -805,6 +805,61 @@ pub fn measure_chrome(_: &AppHandle) -> f64 { 0.0 } +/// Where the traffic lights actually sit, as (left inset, right edge) in +/// logical pixels. +/// +/// Asked rather than assumed. The collapsed rail has to be wide enough to give +/// the cluster the same margin on its right as macOS gives it on its left, and +/// those numbers are macOS's to choose — they differ by window style and have +/// changed between releases. +#[cfg(target_os = "macos")] +pub fn traffic_lights(handle: &AppHandle) -> Option<(f64, f64)> { + use objc2::runtime::AnyObject; + use objc2_foundation::NSRect; + + let wv = handle.get_webview_window("main")?; + 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(None); + return; + } + let window: *mut AnyObject = objc2::msg_send![view, window]; + if window.is_null() { + let _ = tx.send(None); + return; + } + + // NSWindowCloseButton = 0, NSWindowZoomButton = 2. + let close: *mut AnyObject = objc2::msg_send![window, standardWindowButton: 0isize]; + let zoom: *mut AnyObject = objc2::msg_send![window, standardWindowButton: 2isize]; + if close.is_null() || zoom.is_null() { + let _ = tx.send(None); + return; + } + + let nil: *mut AnyObject = std::ptr::null_mut(); + let cb: NSRect = objc2::msg_send![close, bounds]; + let zb: NSRect = objc2::msg_send![zoom, bounds]; + let c: NSRect = objc2::msg_send![close, convertRect: cb, toView: nil]; + let z: NSRect = objc2::msg_send![zoom, convertRect: zb, toView: nil]; + + let _ = tx.send(Some((c.origin.x, z.origin.x + z.size.width))); + }); + + if sent.is_err() { + return None; + } + rx.recv_timeout(std::time::Duration::from_millis(400)).ok().flatten() +} + +#[cfg(not(target_os = "macos"))] +pub fn traffic_lights(_: &AppHandle) -> Option<(f64, f64)> { + None +} + /// The title bar's height, for the shell to keep clear of. /// /// Not an offset for positioning apps. The window's content view runs the full diff --git a/src/App.tsx b/src/App.tsx index 5492661..1cf989a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,7 @@ export default function App() { opaque bar was only hiding that. With the bar painted the nav's colour, the shell has to keep clear of it or the nav lands on the traffic lights. */ const [chrome, setChrome] = useState(0); + const [rail, setRail] = useState(72); const [offer, setOffer] = useState(null); const [saved, setSaved] = useState(null); @@ -42,6 +43,7 @@ export default function App() { useEffect(() => { void api.chromeHeight().then(setChrome); + void api.railWidth().then(setRail); }, []); useEffect(() => { @@ -215,6 +217,7 @@ export default function App() { onReload={() => activeId && api.historyGo(activeId, 0)} unread={unread} chrome={chrome} + rail={rail} /> {/* The hole an app's native webview is positioned into. It stays empty diff --git a/src/api.ts b/src/api.ts index d64c9c6..b253f68 100644 --- a/src/api.ts +++ b/src/api.ts @@ -66,3 +66,4 @@ export const setWindowChrome = (dark: boolean) => export const chromeHeight = () => invoke("chrome_height"); export const setCountNotifications = (enabled: boolean) => invoke("set_count_notifications", { enabled }); +export const railWidth = () => invoke("rail_width"); diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index 2aefd90..4f19c48 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -17,21 +17,20 @@ interface Props { onReload: () => void; /** Height of the title bar the window's content runs underneath. */ chrome: number; + /** + * How wide the collapsed rail has to be for the traffic lights to keep the + * same margin on their right as macOS gave them on their left. Measured from + * the buttons themselves, since that inset is macOS's to choose. + */ + rail: number; } -/** - * Wide enough that the macOS traffic lights fit inside the rail rather than - * spilling over the page. Everything else about the rail follows from that. - */ -const RAIL = 72; const PANEL = 240; -export const navWidth = (collapsed: boolean) => (collapsed ? RAIL : PANEL); - export default function Nav({ config, activeId, collapsed, unread, onSelect, onToggleCollapse, onOpenSettings, onToggleGroup, - onBack, onForward, onReload, chrome, + onBack, onForward, onReload, chrome, rail, }: Props) { const groups = [...config.groups].sort((a, b) => a.order - b.order); const inGroup = (id: string | null) => @@ -125,7 +124,7 @@ export default function Nav({ }; return ( -