mac: unpin NSApp.appearance in system mode (dark could never release) (#82)
The webview reported a RESOLVED light/dark boolean and native pinned NSApp.appearance to match — but inside a WKWebView, prefers-color-scheme reflects NSApp.appearance, so "follow my device" resolved against the pinned value: once dark, system mode stayed dark forever regardless of the actual macOS setting. Report the mode (light/dark/system) instead, and unpin (nil) on system so the app and the page both follow the OS.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+21
−16
ios/LChatMac/ChatFeedWebView.swift+12 −11ios/LChatMac/LChatMacApp.swift+9 −5
diff --git a/ios/LChatMac/ChatFeedWebView.swift b/ios/LChatMac/ChatFeedWebView.swift| index 8db1110..e879056 100644 |
| --- a/ios/LChatMac/ChatFeedWebView.swift |
| +++ b/ios/LChatMac/ChatFeedWebView.swift |
| @@ -123,21 +123,22 @@ struct ChatFeedWebView: NSViewRepresentable { |
| input.dispatchEvent(new Event('change', { bubbles: true })); |
| }; |
| - // Report the web feed's effective light/dark to native so the app chrome (rail, |
| + // Report the web feed's appearance CHOICE to native so the app chrome (rail, |
| // palette, title bar) matches. chat.js sets color-scheme on <html> to 'light', |
| - // 'dark', or 'light dark' (follow OS); resolve the last via matchMedia. No web |
| - // change needed — we just read what the page already sets. |
| - function effectiveDark() { |
| + // 'dark', or 'light dark' (follow OS). Crucially we report the mode, never a |
| + // resolved boolean: inside a WKWebView, prefers-color-scheme reflects |
| + // NSApp.appearance — so resolving 'system' here reads back whatever native |
| + // last pinned, and a forced dark could never release to the real OS setting. |
| + function effectiveMode() { |
| var cs = document.documentElement.style.colorScheme || ''; |
| - if (cs.indexOf('dark') !== -1 && cs.indexOf('light') === -1) return true; |
| - if (cs.indexOf('light') !== -1 && cs.indexOf('dark') === -1) return false; |
| - return matchMedia('(prefers-color-scheme: dark)').matches; |
| + if (cs.indexOf('dark') !== -1 && cs.indexOf('light') === -1) return 'dark'; |
| + if (cs.indexOf('light') !== -1 && cs.indexOf('dark') === -1) return 'light'; |
| + return 'system'; |
| } |
| function reportTheme() { |
| - try { window.webkit.messageHandlers.lchatTheme.postMessage({ dark: effectiveDark() }); } catch (e) {} |
| + try { window.webkit.messageHandlers.lchatTheme.postMessage({ mode: effectiveMode() }); } catch (e) {} |
| } |
| new MutationObserver(reportTheme).observe(document.documentElement, { attributes: true, attributeFilter: ['style', 'data-theme'] }); |
| - matchMedia('(prefers-color-scheme: dark)').addEventListener('change', reportTheme); |
| reportTheme(); |
| })(); |
| """ |
| @@ -202,8 +203,8 @@ struct ChatFeedWebView: NSViewRepresentable { |
| case "lchatDrag": moveWindow(message) |
| case "lchatNotify": showNotification(message) |
| case "lchatTheme": |
| - if let body = message.body as? [String: Any], let dark = body["dark"] as? Bool { |
| - app.setAppearance(dark: dark, for: chat.id) |
| + if let body = message.body as? [String: Any], let mode = body["mode"] as? String { |
| + app.setAppearance(mode: mode, for: chat.id) |
| } |
| default: break |
| } |
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift| index 15e5165..219b53c 100644 |
| --- a/ios/LChatMac/LChatMacApp.swift |
| +++ b/ios/LChatMac/LChatMacApp.swift |
| @@ -177,16 +177,20 @@ final class MacAppState: ObservableObject { |
| // Native appearance follows the web feed's light/dark: each webview reports its |
| // effective mode (setAppearance), and the *selected* instance's mode drives the |
| // whole app (rail, palette, title bar) so the chrome matches what's on screen. |
| - private var appearanceDark: [String: Bool] = [:] |
| + private var appearanceMode: [String: String] = [:] // "light" | "dark" | "system" |
| - func setAppearance(dark: Bool, for id: String) { |
| - appearanceDark[id] = dark |
| + func setAppearance(mode: String, for id: String) { |
| + appearanceMode[id] = mode |
| if id == selection { applyAppearance() } |
| } |
| private func applyAppearance() { |
| - guard let sel = selection, let dark = appearanceDark[sel] else { return } |
| - NSApp.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) |
| + guard let sel = selection, let mode = appearanceMode[sel] else { return } |
| + // "system" must UNPIN (nil = follow macOS): the webview's |
| + // prefers-color-scheme reads NSApp.appearance, so a pinned value here is |
| + // what the page's "follow my device" resolves against — pinning dark once |
| + // would make system mode stick dark forever. |
| + NSApp.appearance = mode == "system" ? nil : NSAppearance(named: mode == "dark" ? .darkAqua : .aqua) |
| } |
| } |