diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc222bf..68b5ac2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,10 @@ concurrency: jobs: test: name: Test and Package (unsigned) - runs-on: macos-latest + # Pinned rather than macos-latest: the app now compiles against macOS 26 + # SDK symbols (NSGlassEffectView), and `-latest` moves between OS versions + # on GitHub's own schedule. Pinning keeps CI and release on one SDK. + runs-on: macos-26 timeout-minutes: 20 permissions: contents: read @@ -25,6 +28,12 @@ jobs: - name: Checkout Code uses: actions/checkout@v4 + - name: Select Xcode + run: | + sudo xcode-select -s /Applications/Xcode.app + xcodebuild -version + xcrun --sdk macosx --show-sdk-version + - name: Cache Swift build uses: actions/cache@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bcc6a85..ed2327d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,7 +84,10 @@ jobs: && needs.draft_release.result == 'success' && (github.event_name == 'workflow_dispatch' || needs.changes.outputs.macos == 'true')) ) - runs-on: macos-latest + # Pinned rather than macos-latest: the app compiles against macOS 26 SDK + # symbols (NSGlassEffectView) and `-latest` moves between OS versions on + # GitHub's schedule. Release must build on the same SDK as CI. + runs-on: macos-26 timeout-minutes: 45 permissions: contents: read @@ -370,7 +373,10 @@ jobs: && needs.draft_release.result == 'success' && needs.build_release.result == 'success' && needs.notarize_release.result == 'success' - runs-on: macos-latest + # Pinned rather than macos-latest: the app compiles against macOS 26 SDK + # symbols (NSGlassEffectView) and `-latest` moves between OS versions on + # GitHub's schedule. Release must build on the same SDK as CI. + runs-on: macos-26 timeout-minutes: 20 permissions: contents: read diff --git a/macos/README.md b/macos/README.md index f659629..22b6d90 100644 --- a/macos/README.md +++ b/macos/README.md @@ -4,7 +4,9 @@ A menu-bar-only AppKit application that supervises a local ForgeCode WebSocket s ## Prerequisites -Building and testing the app needs Xcode 15 or later (Swift 5.9, macOS 13 SDK). Semantic-version parsing and comparison are implemented locally, and the package has no remote dependencies or `Package.resolved` remote pins. Before the first build, vendor the Sparkle updater framework once: +Building and testing the app needs **Xcode 26 or later** on a macOS 26 host. The app target references macOS 26 SDK symbols (`NSGlassEffectView`, `prefersCompactControlSizeMetrics`) behind `if #available(macOS 26.0, *)` checks, so it does not compile against an older SDK, and `swift test` builds every target including the app. The runtime deployment target is unchanged at macOS 13: those symbols are weak-linked and the app falls back to `NSVisualEffectView` on macOS 13-15. See `docs/liquid-glass-adoption.md`. + +Semantic-version parsing and comparison are implemented locally, and the package has no remote dependencies or `Package.resolved` remote pins. Before the first build, vendor the Sparkle updater framework once: ```sh tools/fetch-sparkle.sh @@ -61,6 +63,12 @@ The body shows the commands directly: Launch at Login and its approval path, err The service is not a user-facing toggle. It starts with the app and stops when the app quits, so there is no Run or Restart command; quitting and reopening ForgeCode restarts it. +### Panel material + +The panel's backdrop is selected at runtime. On macOS 26 it is Liquid Glass (`NSGlassEffectView`, `.regular` style), matching the system's own menu bar popovers; on macOS 13-15 it is the previous `NSVisualEffectView` `.menu` material. Corner radius, the row hover highlight, and control-size metrics follow the active backend. `MenuBackdrop` owns that choice, and `docs/liquid-glass-adoption.md` records why. + +Because the effect depends on what is behind the window, it cannot be reviewed from source or from the unit tests; it has to be seen running on each OS. + ### Console origin The **Open** link uses this default origin: diff --git a/macos/Sources/ForgeMenuBar/AppDelegate.swift b/macos/Sources/ForgeMenuBar/AppDelegate.swift index eec17c2..3e0007f 100644 --- a/macos/Sources/ForgeMenuBar/AppDelegate.swift +++ b/macos/Sources/ForgeMenuBar/AppDelegate.swift @@ -81,6 +81,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { statusItem.button?.target = self statusItem.button?.action = #selector(togglePopover(_:)) statusItem.button?.sendAction(on: [.leftMouseUp, .rightMouseUp]) + // Built once. The image never varies, so rebuilding it on every + // snapshot would re-rasterise the bezier path for no visible change. + // `statusImage()` already marks it as a template. + statusItem.button?.image = ForgeCodeLogo.statusImage() updateStatusItem(serviceController.snapshot) popoverController = PopoverController() @@ -164,8 +168,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let appItem = NSMenuItem() let appMenu = NSMenu(title: "ForgeCode") if let updaterController { + // Kept verbatim in sync with the panel row in MenuRenderer: both invoke + // the same Sparkle check, and the label is scoped to the app so it is + // not read as covering the separately-updated server runtime. let checkForUpdates = NSMenuItem( - title: "Check for Updates", + title: "Update ForgeCode App", action: #selector(SPUStandardUpdaterController.checkForUpdates(_:)), keyEquivalent: "" ) @@ -267,16 +274,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate { popoverController.toggle(relativeTo: sender) } + /// The status icon is deliberately constant: a single template image with no + /// tint, so it always matches the menu bar like every other status item. + /// + /// Phase-derived tinting used to live in `updateStatusItem`, but it made the + /// icon visibly change shade during every launch. The first paint happens + /// before `serviceController.start()`, when the snapshot still holds its + /// default `.stopped` phase, so the icon appeared dimmed + /// (`.secondaryLabelColor`), flipped to `.controlAccentColor` while + /// installing/starting, then settled untinted at `.ready` — read as the + /// logo "changing colour on its own". Phase is still surfaced through the + /// tooltip, the accessibility label, and the panel itself. private func updateStatusItem(_ snapshot: ServiceSnapshot) { guard let button = statusItem.button else { return } - switch snapshot.phase { - case .ready: button.contentTintColor = nil - case .installing, .starting, .restarting: button.contentTintColor = .controlAccentColor - case .installationFailed, .failed: button.contentTintColor = .systemOrange - case .disabled, .stopped: button.contentTintColor = .secondaryLabelColor - } - button.image = ForgeCodeLogo.statusImage() - button.image?.isTemplate = true let presentation = PopoverPresentation.make(snapshot: snapshot) let accessibilityStatus = "\(presentation.serviceTitle), \(presentation.serviceDetail)" button.setAccessibilityLabel(accessibilityStatus) diff --git a/macos/Sources/ForgeMenuBar/MenuBackdrop.swift b/macos/Sources/ForgeMenuBar/MenuBackdrop.swift new file mode 100644 index 0000000..d9a0958 --- /dev/null +++ b/macos/Sources/ForgeMenuBar/MenuBackdrop.swift @@ -0,0 +1,111 @@ +import AppKit + +/// The material behind the menu panel. +/// +/// macOS 26 introduced Liquid Glass, which is what the system's own menu bar +/// popovers use. `NSVisualEffectView` was not deprecated by that release, but it +/// also did not adopt the new material: `.menu` still renders the pre-26 frosted +/// blur. Left alone, the panel would read as a macOS 15 menu sitting next to +/// system surfaces that had moved on, so the backend is chosen per OS. +/// +/// See `docs/liquid-glass-adoption.md` for the decision record. +enum MenuBackdrop { + /// Which material is in use. Callers whose own styling has to follow the + /// backdrop -- corner radius and the row highlight -- branch on this rather + /// than repeating the availability check. + enum Backend { + case glass + case visualEffect + + static var active: Backend { + if #available(macOS 26.0, *) { return .glass } + return .visualEffect + } + } + + /// Corner radius of the panel. + /// + /// One value for both backends. A larger radius was tried on glass on the + /// theory that macOS 26 rounds popovers more generously, but at this panel's + /// size it read as overly bubbled rather than native, so both paths keep the + /// 10pt that matches the system menu silhouette. + static let cornerRadius: CGFloat = 10 + + /// Wraps `content` in the backdrop and returns the view to use as the + /// panel's root. + /// + /// `content` must be the whole panel body. On the glass path it becomes the + /// glass view's `contentView`, which is the only placement AppKit makes + /// guarantees about: arbitrary subviews of an `NSGlassEffectView` have + /// undefined z-order with respect to the effect, and a glass view installed + /// *behind* content as a sibling is explicitly wrong. + static func makeRoot(content: NSView) -> NSView { + content.translatesAutoresizingMaskIntoConstraints = false + let radius = cornerRadius + + if #available(macOS 26.0, *) { + let glass = NSGlassEffectView() + glass.translatesAutoresizingMaskIntoConstraints = false + // `.regular` is the variant the HIG names for popovers: it adapts + // luminosity to keep text legible. `.clear` is for floating over + // photo/video content and would wash these rows out. + glass.style = .regular + // Deliberately the view's own `cornerRadius` and not a layer mask. + // Glass samples a region larger than itself to refract what is + // behind the window; clipping the layer clips that sampling region + // and flattens the effect into a plain blur with hard corners. + glass.cornerRadius = radius + // `cornerRadius` alone only rounds the glass; the content view and + // its rows keep painting square right out to the bounds, so the + // corners read as pointy tabs poking past the curve and any row + // highlight squares off the top and bottom of the panel. Clipping + // is what makes the content follow the same silhouette. + glass.clipsToBounds = true + // Assigning `contentView` also installs the Auto Layout ties + // between the two, so the glass tracks the body's geometry. + glass.contentView = content + return glass + } + + let backdrop = NSVisualEffectView() + // `.menu` is the material the pre-26 system menus themselves use, so + // the panel picks up the same translucency and blur rather than the + // flatter, heavier `.hudWindow` wash. + backdrop.material = .menu + backdrop.blendingMode = .behindWindow + backdrop.state = .active + // Deliberately no explicit `appearance`: leaving it nil lets the view + // inherit the system light/dark setting, matching every other menu bar + // panel. Pinning it to .darkAqua forced a dark menu onto light-mode + // users. + backdrop.wantsLayer = true + // The mask (rather than layer.cornerRadius) is what clips a + // behind-window blend correctly; a plain cornerRadius leaves the + // blurred backdrop showing square corners underneath. This is the + // legacy path only -- see the glass branch above. + backdrop.maskImage = roundedMask(radius: radius) + backdrop.translatesAutoresizingMaskIntoConstraints = false + backdrop.addSubview(content) + NSLayoutConstraint.activate([ + content.topAnchor.constraint(equalTo: backdrop.topAnchor), + content.leadingAnchor.constraint(equalTo: backdrop.leadingAnchor), + content.trailingAnchor.constraint(equalTo: backdrop.trailingAnchor), + content.bottomAnchor.constraint(equalTo: backdrop.bottomAnchor) + ]) + return backdrop + } + + /// A resizable rounded-rect mask. The center is stretched, so one image + /// serves every panel height. + private static func roundedMask(radius: CGFloat) -> NSImage { + let edge = radius * 2 + 1 + let image = NSImage(size: NSSize(width: edge, height: edge), flipped: false) { rect in + NSColor.black.setFill() + NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill() + return true + } + image.capInsets = NSEdgeInsets(top: radius, left: radius, bottom: radius, right: radius) + image.resizingMode = .stretch + return image + } +} diff --git a/macos/Sources/ForgeMenuBar/MenuRenderer.swift b/macos/Sources/ForgeMenuBar/MenuRenderer.swift index 519f3ad..fcf062f 100644 --- a/macos/Sources/ForgeMenuBar/MenuRenderer.swift +++ b/macos/Sources/ForgeMenuBar/MenuRenderer.swift @@ -3,7 +3,7 @@ import ForgeMenuCore import Foundation @MainActor -final class PopoverController: NSObject, NSPopoverDelegate { +final class PopoverController: NSObject { enum LoginItemState: Equatable { case enabled case disabled @@ -11,15 +11,30 @@ final class PopoverController: NSObject, NSPopoverDelegate { case unavailable(String) } - // Sized so the steady-state text list fits snugly without scrolling. - // Transient states with extra rows or details overflow into the scroll view - // within this same fixed frame. - static let contentSize = NSSize(width: 260, height: 130) - private static let bodyContentWidth: CGFloat = 252 + /// The panel is width-fixed and height-fits-content: the row list is short + /// and its length varies by state, so a fixed height would either clip the + /// transient states or leave dead space in the steady state. + /// + /// Width is drawn from AppKit: an `NSMenu` built from these exact labels + /// reports an intrinsic width of 171pt. 220pt keeps headroom for the longest + /// steady-state row ("Update ForgeCode App") without leaving the ~110pt of + /// dead space the previous 280pt had. Error notes are not sized for here: + /// they wrap to two lines rather than widening the panel. + static let contentWidth: CGFloat = 220 + /// Ceiling for the fitted height. Beyond this the list scrolls rather than + /// growing into a panel taller than the states that actually occur. + private static let maxContentHeight: CGFloat = 420 + private static let bodyContentWidth = contentWidth - 8 /// Horizontal inset shared by rows and notes. - private static let rowInset: CGFloat = 10 + private static let rowInset: CGFloat = 12 /// Left edge shared by command titles and supporting notes. - private static let labelColumnInset = rowInset + private static let labelColumnInset: CGFloat = rowInset + /// Gap between the menu bar and the top of the panel. + private static let menuBarGap: CGFloat = 6 + + /// How far the panel's left edge extends past the status item's left + /// edge. System menus leave a small overhang rather than aligning exactly. + private static let leftEdgeOverhang: CGFloat = 8 var onWillShow: (() -> Void)? var onLaunchAtLoginChanged: ((Bool) -> Void)? @@ -30,7 +45,12 @@ final class PopoverController: NSObject, NSPopoverDelegate { var onCheckForUpdates: (() -> Void)? var onQuit: (() -> Void)? - private let popover = NSPopover() + private let panel = MenuPanel( + contentRect: NSRect(x: 0, y: 0, width: PopoverController.contentWidth, height: 100), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) private let contentController = PopoverContentViewController() private let scrollView = NSScrollView() private let bodyStack = NSStackView() @@ -47,7 +67,7 @@ final class PopoverController: NSObject, NSPopoverDelegate { override init() { super.init() - configurePopover() + configurePanel() rebuild() } @@ -72,31 +92,40 @@ final class PopoverController: NSObject, NSPopoverDelegate { applyVisibilityEvent(.outsideInteraction) } - private func configurePopover() { - popover.behavior = .applicationDefined - popover.animates = true - popover.contentSize = Self.contentSize - popover.delegate = self - contentController.preferredContentSize = Self.contentSize + private func configurePanel() { contentController.onCancel = { [weak self] in self?.close() } contentController.view = buildRootView() - popover.contentViewController = contentController + + // A borderless panel replaces NSPopover so the menu reads as a plain + // floating box: NSPopover always draws a callout arrow and an opaque + // backdrop behind its content, neither of which can be turned off. + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.isMovable = false + panel.hidesOnDeactivate = false + panel.level = .popUpMenu + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + panel.animationBehavior = .utilityWindow + panel.contentViewController = contentController } private func buildRootView() -> NSView { - let root = NSVisualEffectView() - root.material = .hudWindow - root.blendingMode = .behindWindow - root.state = .active - root.appearance = NSAppearance(named: .darkAqua) - root.translatesAutoresizingMaskIntoConstraints = false + // The body is assembled first and handed to the backdrop as a single + // content view. On macOS 26 the backdrop is an `NSGlassEffectView`, + // which only makes placement guarantees for its `contentView` -- the + // scroll view must not be a sibling of the effect. + let body = NSView() + body.translatesAutoresizingMaskIntoConstraints = false bodyStack.orientation = .vertical bodyStack.alignment = .leading // Rows carry their own internal padding, so the stack only needs a - // small outer margin and a hairline gap between rows. - bodyStack.spacing = 1 - bodyStack.edgeInsets = NSEdgeInsets(top: 6, left: 4, bottom: 8, right: 4) + // small outer margin. + // Native menus report 22pt per item with no inter-item gap and 10pt of + // total chrome, so rows butt together and the panel pads 5pt each end. + bodyStack.spacing = 0 + bodyStack.edgeInsets = NSEdgeInsets(top: 5, left: 4, bottom: 5, right: 4) bodyStack.translatesAutoresizingMaskIntoConstraints = false scrollView.drawsBackground = false @@ -110,15 +139,16 @@ final class PopoverController: NSObject, NSPopoverDelegate { document.addSubview(bodyStack) scrollView.documentView = document - root.addSubview(scrollView) + body.addSubview(scrollView) NSLayoutConstraint.activate([ - root.widthAnchor.constraint(equalToConstant: Self.contentSize.width), - root.heightAnchor.constraint(equalToConstant: Self.contentSize.height), - scrollView.topAnchor.constraint(equalTo: root.topAnchor), - scrollView.leadingAnchor.constraint(equalTo: root.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: root.trailingAnchor), - scrollView.bottomAnchor.constraint(equalTo: root.bottomAnchor), + // The width lives on the body, not the backdrop: the glass view + // derives its own geometry from its content view. + body.widthAnchor.constraint(equalToConstant: Self.contentWidth), + scrollView.topAnchor.constraint(equalTo: body.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: body.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: body.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: body.bottomAnchor), document.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor), document.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor), document.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor), @@ -128,9 +158,10 @@ final class PopoverController: NSObject, NSPopoverDelegate { bodyStack.leadingAnchor.constraint(equalTo: document.leadingAnchor), bodyStack.trailingAnchor.constraint(equalTo: document.trailingAnchor), bodyStack.bottomAnchor.constraint(equalTo: document.bottomAnchor), - bodyStack.widthAnchor.constraint(equalToConstant: Self.contentSize.width) + bodyStack.widthAnchor.constraint(equalToConstant: Self.contentWidth) ]) - return root + + return MenuBackdrop.makeRoot(content: body) } private func rebuild() { @@ -163,20 +194,21 @@ final class PopoverController: NSObject, NSPopoverDelegate { } else if presentation.retryInstallationEnabled { bodyStack.addArrangedSubview(noteView(presentation.actionableError ?? "Runtime installation failed.")) bodyStack.addArrangedSubview(commandRow( - title: "Retry Runtime Installation", + title: "Retry Install", action: #selector(retryInstallationCommand), isProminent: true )) bodyStack.addArrangedSubview(groupSpacer()) } + // No header or spacer ahead of this row: the switch itself is enough to + // mark it as a setting, so it sits flush with the command rows above. bodyStack.addArrangedSubview(commandRow( title: launchAtLoginTitle, action: #selector(toggleLaunchAtLoginCommand), isOn: loginItemState == .enabled, isEnabled: launchAtLoginAvailable, - isToggle: true, - trailingText: loginItemState == .enabled ? "✓" : nil + isToggle: true )) if loginItemState == .requiresApproval { bodyStack.addArrangedSubview(commandRow( @@ -186,16 +218,23 @@ final class PopoverController: NSObject, NSPopoverDelegate { } else if case .unavailable(let message) = loginItemState { bodyStack.addArrangedSubview(noteView("Login item unavailable: \(message)")) } - bodyStack.addArrangedSubview(commandRow( - title: "Check for Updates", - action: #selector(checkForUpdatesCommand) - )) if presentation.actionableError != nil { bodyStack.addArrangedSubview(commandRow( title: "Show Error Details", action: #selector(showErrorCommand) )) } + // Separator fencing off the two app-level commands from the + // service-related rows above. + bodyStack.addArrangedSubview(separator()) + bodyStack.addArrangedSubview(commandRow( + // Deliberately not "Check for Updates": the bundled server updates + // on its own cadence, so an unqualified label reads as though it + // covers both. "App" scopes it to this menu bar app only. + title: "Update ForgeCode App", + action: #selector(checkForUpdatesCommand), + toolTip: "Check for updates to the ForgeCode menu bar app (not the bundled server runtime)" + )) bodyStack.addArrangedSubview(commandRow( title: "Quit", action: #selector(quitCommand), @@ -209,7 +248,7 @@ final class PopoverController: NSObject, NSPopoverDelegate { let container = NSView() container.translatesAutoresizingMaskIntoConstraints = false let label = NSTextField(labelWithString: "") - label.font = .systemFont(ofSize: 10) + label.font = Typography.note label.textColor = .secondaryLabelColor label.translatesAutoresizingMaskIntoConstraints = false @@ -263,8 +302,10 @@ final class PopoverController: NSObject, NSPopoverDelegate { row.isEnabled = isEnabled row.configure( title: title, - titleFont: .systemFont(ofSize: 12, weight: isProminent ? .semibold : .regular), - trailingText: trailingText + titleFont: Typography.rowTitle(prominent: isProminent), + trailingText: trailingText, + toggleState: isToggle ? isOn : nil, + isProminent: isProminent ) row.toolTip = toolTip ?? title row.configureAccessibility( @@ -288,16 +329,42 @@ final class PopoverController: NSObject, NSPopoverDelegate { return container } + /// A hairline divider, inset to the label column so it aligns with row text + /// rather than spanning the full panel edge to edge. + private func separator() -> NSView { + let container = NSView() + container.translatesAutoresizingMaskIntoConstraints = false + let line = NSBox() + line.boxType = .separator + line.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(line) + NSLayoutConstraint.activate([ + container.widthAnchor.constraint(equalToConstant: Self.bodyContentWidth), + container.heightAnchor.constraint(equalToConstant: 11), + line.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: Self.rowInset), + line.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -Self.rowInset), + line.centerYAnchor.constraint(equalTo: container.centerYAnchor) + ]) + return container + } + /// Secondary text aligned to the label column, so it reads as a note about /// the row above rather than a row of its own. private func noteView(_ text: String) -> NSView { let container = NSView() container.translatesAutoresizingMaskIntoConstraints = false let label = NSTextField(labelWithString: text) - label.font = .systemFont(ofSize: 10) + label.font = Typography.note label.textColor = .tertiaryLabelColor + // `.byWordWrapping`, not `.byTruncatingTail`: a truncating break mode + // keeps the label on one line no matter what `maximumNumberOfLines` + // says, which clipped these notes mid-word ("...failed. Check y..."). + // Wrapping needs an explicit `preferredMaxLayoutWidth` too, or the + // label reports a single-line intrinsic height and the second line is + // laid out but never given room. label.maximumNumberOfLines = 2 - label.lineBreakMode = .byTruncatingTail + label.lineBreakMode = .byWordWrapping + label.preferredMaxLayoutWidth = Self.bodyContentWidth - Self.labelColumnInset - Self.rowInset label.alignment = .left label.translatesAutoresizingMaskIntoConstraints = false container.addSubview(label) @@ -322,19 +389,56 @@ final class PopoverController: NSObject, NSPopoverDelegate { guard let button = button ?? statusButton else { visibilityState = .hidden; return } onWillShow?() rebuild() - popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + positionPanel(relativeTo: button) + panel.orderFrontRegardless() // An accessory app is not active by default, so key events would // otherwise be delivered to whatever app is frontmost. Activate and - // take key focus so Escape and Command-Q reach this popover. + // take key focus so Escape and Command-Q reach this panel. NSApp.activate(ignoringOtherApps: true) - contentController.view.window?.makeKeyAndOrderFront(nil) + panel.makeKeyAndOrderFront(nil) + // Becoming key makes AppKit promote the first focusable row to + // first responder, which draws a focus ring on a row the user never + // chose. Hand focus back to the window; Tab still starts traversal. + panel.initialFirstResponder = nil + panel.makeFirstResponder(nil) installAllMonitors() case .close: removeAllMonitors() - if popover.isShown { popover.performClose(nil) } + // The panel has no close delegate, so unlike NSPopover there is no + // `.popoverDidClose` echo; the transition above already left the + // state `.hidden`. + if panel.isVisible { panel.orderOut(nil) } } } + /// Sizes the panel to its content and pins it under the status item, + /// clamped to the screen so it cannot run off the right edge. + private func positionPanel(relativeTo button: NSStatusBarButton) { + let fitted = bodyStack.fittingSize.height + let height = min(max(fitted, 1), Self.maxContentHeight) + // The scroll view only ever engages once content exceeds the ceiling. + scrollView.hasVerticalScroller = fitted > Self.maxContentHeight + panel.setContentSize(NSSize(width: Self.contentWidth, height: height)) + + guard let buttonWindow = button.window else { return } + let buttonFrame = buttonWindow.convertToScreen(button.convert(button.bounds, to: nil)) + let screen = buttonWindow.screen ?? NSScreen.main + // The panel left-aligns to the status item rather than centring on it, + // so the body hangs to the right of its icon. The overhang pulls the + // edge just past the icon so the icon does not sit flush against the + // corner of the panel. The clamp below still catches the case where a + // status item sits far enough right that the panel would overflow. + var origin = NSPoint( + x: buttonFrame.minX - Self.leftEdgeOverhang, + y: buttonFrame.minY - height - Self.menuBarGap + ) + if let visible = screen?.visibleFrame { + origin.x = min(max(origin.x, visible.minX + 8), visible.maxX - Self.contentWidth - 8) + origin.y = max(origin.y, visible.minY + 8) + } + panel.setFrameOrigin(origin) + } + private func installAllMonitors() { removeAllMonitors() localKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in @@ -389,8 +493,8 @@ final class PopoverController: NSObject, NSPopoverDelegate { } private func isInsidePopover(_ screenPoint: NSPoint) -> Bool { - guard let window = contentController.view.window, window.isVisible else { return false } - return window.frame.contains(screenPoint) + guard panel.isVisible else { return false } + return panel.frame.contains(screenPoint) } private func isInsideStatusButton(_ screenPoint: NSPoint) -> Bool { @@ -408,10 +512,6 @@ final class PopoverController: NSObject, NSPopoverDelegate { if let localKeyMonitor { NSEvent.removeMonitor(localKeyMonitor); self.localKeyMonitor = nil } } - func popoverDidClose(_ notification: Notification) { - applyVisibilityEvent(.popoverDidClose) - } - // Toggles keep the popover open so the resulting state change is visible. @objc private func toggleLaunchAtLoginCommand() { guard launchAtLoginAvailable else { return } @@ -438,7 +538,10 @@ final class PopoverController: NSObject, NSPopoverDelegate { } private var launchAtLoginTitle: String { - loginItemState == .requiresApproval ? "Launch at Login — Approval Required" : "Launch at Login" + // The approval case is carried by the subordinate "Approve in Login + // Items" row plus the off/disabled switch, not by a title suffix: the + // suffixed string overflowed the 280pt panel and truncated. + "Launch at Login" } private var launchAtLoginAvailable: Bool { @@ -450,13 +553,61 @@ final class PopoverController: NSObject, NSPopoverDelegate { } +/// Type scale for the menu, matched to native menus rather than chosen by eye. +/// `NSFont.menuFont(ofSize: 0)` reports 13pt at weight 5 (regular) on this OS, +/// so that is what the rows use. +enum Typography { + /// Row titles: 13pt regular, the system menu face. + /// + /// This was previously medium, on the theory that regular reads thin over a + /// translucent backdrop. It does not: vibrancy plus `labelColor` already + /// carries the contrast, and the extra weight simply made every row look + /// emphasised. Prominent rows no longer bump the weight either -- emphasis + /// comes from colour, which is how native menus signal it. + static func rowTitle(prominent: Bool) -> NSFont { + let size: CGFloat = 13 + let base = NSFont.systemFont(ofSize: size, weight: .regular) + // The system UI face at display sizes; falls back to `base` on any OS + // that does not vend the descriptor. + guard let descriptor = base.fontDescriptor.withDesign(.default) else { return base } + return NSFont(descriptor: descriptor, size: size) ?? base + } + + /// Key equivalents. Same size as the title so the two columns sit on a + /// shared optical baseline; it recedes via `tertiaryLabelColor`, not size. + static let rowTrailing = NSFont.systemFont(ofSize: 13, weight: .regular) + + /// Supporting notes and progress captions. + static let note = NSFont.systemFont(ofSize: 11, weight: .regular) +} + +/// A borderless panel that can take key focus. `NSPanel` refuses to become key +/// while borderless unless this is overridden, which would leave Escape and +/// Command-Q unhandled. +final class MenuPanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} + /// A full-width, text-only menu row with a rounded highlight on hover. private final class CommandRowView: NSView { - static let height: CGFloat = 28 - static let inset: CGFloat = 10 + /// 22pt, measured from AppKit rather than guessed: building an `NSMenu` of + /// N items and differencing the reported heights gives exactly 22pt per + /// item. The previous 30pt was ~36% taller than a native menu row. + static let height: CGFloat = 22 + static let inset: CGFloat = 12 + + /// Drives the resting title colour; see `applyContentColors()`. + private var isProminent = false private let titleLabel = NSTextField(labelWithString: "") private let trailingLabel = NSTextField(labelWithString: "") + /// Only installed for toggle rows. `.mini` keeps the control inside the + /// 22pt row; the regular size is 22pt tall on its own and would force every + /// row taller to stay aligned. + private let toggleSwitch = NSSwitch() + private var showsToggle = false + private var titleLeading: NSLayoutConstraint! private weak var target: AnyObject? private let action: Selector private var trackingAreaRef: NSTrackingArea? @@ -481,6 +632,11 @@ private final class CommandRowView: NSView { super.init(frame: .zero) wantsLayer = true translatesAutoresizingMaskIntoConstraints = false + // macOS 26 made mini/small controls taller. The row is pinned to the + // 22pt native menu metric and the switch is `.mini` precisely to fit + // inside it, so without this the switch outgrows the row it sits in. + // Apple names dense popovers as the motivating case for this property. + if #available(macOS 26.0, *) { prefersCompactControlSizeMetrics = true } buildLayout() setAccessibilityElement(true) setAccessibilityRole(.button) @@ -495,18 +651,34 @@ private final class CommandRowView: NSView { titleLabel.maximumNumberOfLines = 1 titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) trailingLabel.translatesAutoresizingMaskIntoConstraints = false - trailingLabel.font = .systemFont(ofSize: 11, weight: .regular) + trailingLabel.font = Typography.rowTrailing trailingLabel.textColor = .tertiaryLabelColor trailingLabel.lineBreakMode = .byTruncatingTail trailingLabel.maximumNumberOfLines = 1 trailingLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + toggleSwitch.translatesAutoresizingMaskIntoConstraints = false + // `.mini` so the control fits inside the 22pt native row height; at + // `.small` it was ~18pt tall and dominated the panel. + toggleSwitch.controlSize = .mini + toggleSwitch.target = self + toggleSwitch.action = #selector(switchToggled) + toggleSwitch.isHidden = true + toggleSwitch.setContentCompressionResistancePriority(.required, for: .horizontal) + toggleSwitch.setContentHuggingPriority(.required, for: .horizontal) + addSubview(titleLabel) addSubview(trailingLabel) + addSubview(toggleSwitch) + + titleLeading = titleLabel.leadingAnchor.constraint( + equalTo: leadingAnchor, + constant: Self.inset + ) NSLayoutConstraint.activate([ heightAnchor.constraint(equalToConstant: Self.height), - titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.inset), + titleLeading, titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), trailingLabel.centerYAnchor.constraint(equalTo: centerYAnchor), trailingLabel.leadingAnchor.constraint( @@ -516,10 +688,28 @@ private final class CommandRowView: NSView { trailingLabel.trailingAnchor.constraint( equalTo: trailingAnchor, constant: -Self.inset + ), + toggleSwitch.centerYAnchor.constraint(equalTo: centerYAnchor), + toggleSwitch.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.inset), + toggleSwitch.leadingAnchor.constraint( + greaterThanOrEqualTo: titleLabel.trailingAnchor, + constant: 8 ) ]) } + /// The switch is a passive indicator: the entire row is the click target, + /// matching every other row in the panel. Routing clicks that land on the + /// control to the row keeps a single toggle path — otherwise the switch + /// would flip itself and the row's `mouseUp` would immediately flip it back. + override func hitTest(_ point: NSPoint) -> NSView? { + let hit = super.hitTest(point) + return hit === toggleSwitch ? self : hit + } + + /// Only reachable via accessibility/keyboard activation of the control. + @objc private func switchToggled() { performAction() } + func configureAccessibility(label: String, value: String?, enabled: Bool) { setAccessibilityLabel(label) setAccessibilityValue(value) @@ -529,24 +719,47 @@ private final class CommandRowView: NSView { func configure( title: String, titleFont: NSFont, - trailingText: String? + trailingText: String?, + toggleState: Bool? = nil, + isProminent: Bool = false ) { titleLabel.stringValue = title titleLabel.font = titleFont + self.isProminent = isProminent applyContentColors() - trailingLabel.stringValue = trailingText ?? "" - trailingLabel.isHidden = trailingText == nil + showsToggle = toggleState != nil + toggleSwitch.isHidden = !showsToggle + if let toggleState { + toggleSwitch.state = toggleState ? .on : .off + } + // The switch occupies the trailing slot, so it suppresses any key + // equivalent there. No row needs both. + let text = showsToggle ? nil : trailingText + trailingLabel.stringValue = text ?? "" + trailingLabel.isHidden = text == nil } - /// The hover highlight is a saturated, fully opaque fill, so row content - /// switches to the matching foreground color to stay legible on top of it. + /// Row content colour follows the highlight fill; see `draw(_:)` for why + /// the two backdrops highlight differently. private func applyContentColors() { if isHovered { - let onHighlight = NSColor.alternateSelectedControlTextColor + let onHighlight: NSColor + switch MenuBackdrop.Backend.active { + case .glass: + // The glass highlight is a neutral translucent wash, not a + // saturated fill, so the label stays the ordinary label colour. + // `.selectedMenuItemTextColor` is white and would vanish into + // that wash in light mode. + onHighlight = .labelColor + case .visualEffect: + onHighlight = .alternateSelectedControlTextColor + } titleLabel.textColor = onHighlight trailingLabel.textColor = onHighlight.withAlphaComponent(0.75) } else { - titleLabel.textColor = .labelColor + // Prominence is carried by colour, not weight: native menus never + // bold a row, and doing so made call-to-action rows shout. + titleLabel.textColor = isProminent ? .controlAccentColor : .labelColor trailingLabel.textColor = .tertiaryLabelColor } } @@ -608,19 +821,43 @@ private final class CommandRowView: NSView { override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) - let highlight = bounds.insetBy(dx: 4, dy: 1) + let isGlass = MenuBackdrop.Backend.active == .glass + // One geometry for both backends, matching the panel's own radius + // decision: the wider inset and larger radius tried on glass read as + // over-rounded next to the system's menus. + let highlight = bounds.insetBy(dx: 5, dy: 1) + let radius: CGFloat = 6 if window?.firstResponder === self { - NSColor.keyboardFocusIndicatorColor.setStroke() - let focusPath = NSBezierPath(roundedRect: highlight, xRadius: 5, yRadius: 5) - focusPath.lineWidth = 2 - focusPath.stroke() + // A filled wash on glass rather than an accent-blue ring. System + // menu bar popovers mark the focused row the same way they mark the + // hovered one, so a saturated ring is the thing that looked bolted + // on; the pre-26 path keeps the ring it has always drawn. + if isGlass { + NSColor.unemphasizedSelectedContentBackgroundColor.setFill() + NSBezierPath(roundedRect: highlight, xRadius: radius, yRadius: radius).fill() + } else { + NSColor.keyboardFocusIndicatorColor.setStroke() + let focusPath = NSBezierPath(roundedRect: highlight, xRadius: radius, yRadius: radius) + focusPath.lineWidth = 2 + focusPath.stroke() + } } guard isHovered else { return } - // Fully opaque and using the brighter accent rather than the muted - // selection background: the previous 0.28 wash let the dark popover - // show through and read as washed-out lavender. - NSColor.controlAccentColor.setFill() - NSBezierPath(roundedRect: highlight, xRadius: 5, yRadius: 5).fill() + if isGlass { + // Neutral and translucent rather than an accent fill. The system's + // own macOS 26 menu bar popovers (volume, Wi-Fi) highlight with a + // plain tinted wash and let the glass show through; a saturated + // accent slab is what made this panel read as non-native. This + // colour is the system's non-emphasised selection, so it also + // tracks light/dark and the user's accent-free preference. + NSColor.unemphasizedSelectedContentBackgroundColor.setFill() + } else { + // Fully opaque and using the brighter accent rather than the muted + // selection background: over `NSVisualEffectView` a translucent wash + // lets the blurred backdrop through and reads as washed-out lavender. + NSColor.controlAccentColor.setFill() + } + NSBezierPath(roundedRect: highlight, xRadius: radius, yRadius: radius).fill() } override var isFlipped: Bool { true } diff --git a/macos/docs/liquid-glass-adoption.md b/macos/docs/liquid-glass-adoption.md new file mode 100644 index 0000000..c3928dc --- /dev/null +++ b/macos/docs/liquid-glass-adoption.md @@ -0,0 +1,241 @@ +# Liquid Glass adoption for the menu bar popover + +Status: **implemented.** This document records the decision and the research +behind it. The plan below is what was built; `MenuBackdrop.swift` and the +backend branches in `MenuRenderer.swift` are the result. + +## Problem + +The popover backdrop is an `NSVisualEffectView` with `material = .menu` +(`PopoverController.buildRootView` in `Sources/ForgeMenuBar/MenuRenderer.swift`). +On macOS 26, `NSVisualEffectView` is *not* deprecated but it also does *not* +render Liquid Glass: `.menu`, `.popover`, and `.hudWindow` keep the legacy +frosted blur. The panel therefore looks like a macOS 15 menu sitting next to +system popovers that have adopted the new material, which is the visual +mismatch this work fixes. + +The goal is for the panel to match the system's own menu bar popovers (the +volume/Control Center surfaces) on macOS 26, while continuing to look exactly as +it does today on macOS 13–15. + +## Decision: build against the macOS 26 SDK, branch with `#available` + +Two ways to reach `NSGlassEffectView` from a package whose deployment target is +macOS 13 were considered. + +| | A. macOS 26 SDK + `#available` | B. `NSClassFromString` + KVC | +| --- | --- | --- | +| OS version gate | runtime | runtime | +| Symbol exists? | **compile time** (weak-linked) | runtime, silent | +| Property names/types | **compile time** | stringly-typed | +| `style = .regular` | typed enum | guess the raw value | +| Typo in `"cornerRadius"` | build error | silently does nothing | + +Both are runtime-gated — a single binary spanning macOS 13 to 26 leaves no other +option. The real difference is that **B deletes the compile-time check** rather +than moving it. + +**Path A is chosen.** B was rejected because a failed +`setValue(_:forKey:)` does not throw: the failure mode is a transparent panel +with invisible text, on exactly the OS being targeted, in a signed and notarized +artifact that cannot be reproduced on an older development machine. B is also +invisible to the existing `validate_macho` guardrail (below), and would be +rewritten as soon as the toolchain moved anyway. + +### Backward compatibility + +Build SDK and deployment target are independent. The already-shipping binary +reads: + +``` +LC_BUILD_VERSION + minos 13.0 <- deployment target, from Package.swift .macOS(.v13) + sdk 15.2 <- build SDK +``` + +Raising the SDK does not raise `minos`. `NSGlassEffectView` carries availability +metadata, so it is weak-linked: on macOS 13 the symbol resolves to null and +`#available` guarantees it is never touched. No dyld failure. Glass lives inside +AppKit, so no new dylib appears and the `otool -L` allowlist in `validate_macho` +is unaffected. + +`scripts/common.sh` already enforces the invariant — `validate_macho` reads +per-arch `minos` and fails the build if it exceeds `MINIMUM_MACOS_VERSION`: + +``` +version_le "$minos" "$MINIMUM_MACOS_VERSION" || fail "$binary ($arch) requires macOS $minos, above declared $MINIMUM_MACOS_VERSION" +``` + +So an SDK bump that silently dragged the deployment target upward breaks the +release rather than shipping a binary that will not launch on macOS 13. + +## API constraints + +`NSGlassEffectView` (macOS 26.0+) exposes `contentView`, `cornerRadius`, +`style` (`.regular` / `.clear`), and `tintColor`. Note `effectIsInteractive` +is **macOS 27**, not 26, and is not usable here. + +1. **Content must go through `contentView`.** Apple: "only guarantees the + `contentView` will be placed inside the glass effect; arbitrary subviews + aren't guaranteed specific behavior with regard to z-order." The glass view + must not sit behind content as a sibling. +2. **Corner rounding must use `cornerRadius`.** Not `layer.cornerRadius`, not + `masksToBounds`, not `maskImage`. Glass samples a region *larger* than + itself; layer masking clips that sampling region and destroys the + refraction. `maskImage` remains correct on the `NSVisualEffectView` path. +3. **Glass cannot sample glass.** One glass surface per popover; ordinary + controls inside it. `NSGlassEffectContainerView` exists to merge *sibling* + glass views and is not needed for a single backdrop. +4. **`.regular` is the correct style** — the HIG names popovers explicitly. + `.clear` is for floating over photo/video content. +5. The window must stay `isOpaque = false` with `backgroundColor = .clear`, and + the shadow must come from `window.hasShadow`. `MenuPanel` already satisfies + this. + +`NSPopover` was reconsidered and rejected again: it picks up system glass for +free, but still forces the callout arrow and would cost the +`.canJoinAllSpaces` / `.fullScreenAuxiliary` collection behavior and the custom +Escape / Command-Q key handling that motivated the borderless panel originally. + +`NSHostingView` + SwiftUI `.glassEffect` was also rejected. It is the only route +to *interactive* glass on macOS 26, but this panel has no interactive glass +surfaces, and `NSHostingView` inside a `.nonactivatingPanel` has first-responder +quirks that would fight `MenuPanel.canBecomeKey`. + +## What was implemented + +### 1. A `MenuBackdrop` factory + +The bare `private let backdrop = NSVisualEffectView()` is replaced by a type that +returns either backend and reports which one is active, so the call sites that +need to differ (corner radius, hover highlight) can ask. + +`MenuBackdrop.makeRoot(content:)` returns the panel root, and +`MenuBackdrop.Backend.active` reports which material is in play so the call +sites that must follow it — corner radius and the row highlight — branch on one +place instead of repeating the availability check. + +`roundedMask(radius:)` moved out of `PopoverController` into `MenuBackdrop`, +since it is now only meaningful on the fallback path. + +### 2. Restructured `buildRootView()` + +`backdrop` used to *be* the root, with `scrollView` added onto it as a sibling — +which constraint 1 forbids. The hierarchy is now: + +``` +root (NSGlassEffectView | NSVisualEffectView) +└── contentView <- plain NSView container + └── scrollView + └── document -> bodyStack +``` + +The body is built first and handed to `MenuBackdrop.makeRoot(content:)`, and the +`widthAnchor` constraint moved from the root onto the body — the glass view +derives its geometry from `contentView`. + +### 3. `roundedMask(radius:)` applies to the fallback only + +Still needed for `NSVisualEffectView`; never applied on the glass path. + +### 4. Corner radius is one value for both backends + +`MenuBackdrop.cornerRadius` is `10` on both paths; the +`PopoverController.cornerRadius` constant is gone. + +This was initially `16` on glass, on the reasoning that macOS 26 rounds popovers +more generously. Reviewing the rendered panel, that was wrong at this panel's +size — it read as bubbled rather than native — so both backends keep the 10pt +that matches the system menu silhouette. The row highlight geometry was reverted +alongside it for the same reason (`dx: 5`, `radius: 6` on both paths). + +### 5. Reworked hover highlight + +`CommandRowView.draw(_:)` filled `NSColor.controlAccentColor` fully opaque. The +in-code rationale — a translucent wash looked washed out over +`NSVisualEffectView` — inverts over real glass. + +Glass uses `.unemphasizedSelectedContentBackgroundColor`, a neutral translucent +wash rather than a saturated accent fill, because that is how the system's own +menu bar popovers (volume, Wi-Fi) mark a row: the accent slab was what made the +panel read as non-native. The label stays `.labelColor` on that path — +`.selectedMenuItemTextColor` is white and disappears into a light wash. + +Keyboard focus follows the same rule on glass: a filled wash rather than an +accent-blue `keyboardFocusIndicatorColor` ring. The legacy path keeps both the +opaque accent highlight and the focus ring it has always drawn. + +### 6. Adopted `prefersCompactControlSizeMetrics` + +Set on `CommandRowView`. macOS 26 makes small/mini controls taller; Apple names +"complex inspectors and popovers" as the motivating case. `CommandRowView.height` +is pinned at 22pt and `toggleSwitch.controlSize` is `.mini` specifically to fit +it, so without this the `NSSwitch` overflows the row. **This was likely already +affecting shipped builds on macOS 26** — see the CI note below. + +Still outstanding: the 22pt row height and the 171pt→220pt `contentWidth` were +both measured from `NSMenu` on macOS 14 and should be re-measured on 26. The +screenshot artifact is the means to check them. + +### 7. Build configuration + +- **Do not** add `UIDesignRequiresCompatibility`. It is the opt-*out* key (same + name on macOS, Boolean, ignored from macOS 27 onward); absence means the new + design. +- `MINIMUM_MACOS_VERSION` / `LSMinimumSystemVersion` stay at `13.0`. +- `.macOS(.v13)` in `Package.swift` stays. +- `README.md` "Prerequisites" claimed Xcode 15 / macOS 13 SDK; updated to + Xcode 26, since the app target no longer compiles against an older SDK. + +## CI + +`actions/runner-images` now maps `macos-latest` to **macOS 26 arm64**, with +Xcode 26.6 as default and macOS SDKs 26.0–26.5 installed. `macos-14` is +deprecated. + +CI is therefore **already building against a macOS 26 SDK**, which has two +consequences: + +- Path A needs no runner change to become possible; `NSGlassEffectView` will + compile today. +- Standard AppKit components in the popover are *already* rendering with macOS + 26 metrics for users on 26, so item 6 is a live issue rather than a + hypothetical one. + +Pinning is still worth doing, for stability rather than enablement: +`runs-on: macos-26` plus an explicit `xcode-select` is now set in +`.github/workflows/ci.yml` and in both `release.yml` build jobs, so CI and +release cannot silently drift onto a future default (Xcode 27 images already +exist in preview). + +## Verification + +### Local builds now require macOS 26 + +Note a consequence: `swift test` builds **every** target, including +`ForgeMenuBar`, so it fails on a host older than macOS 26 even though the tests +themselves only cover `ForgeMenuCore`. `swift build --target ForgeMenuCore` +still works anywhere. This was a deliberate trade for the compile-time symbol +checking that path A buys. + +### Reviewing the appearance + +The visual result cannot be judged from source, from the unit tests, or on a +host that cannot boot macOS 26. Both materials need reviewing, since the app +supports macOS 13+ and most of that range sees the fallback. + +Screenshot capture on CI is tracked separately so that scaffolding stays out of +the shipping target. + +Both paths still need checking -- glass on macOS 26, and the unchanged +`NSVisualEffectView` appearance on macOS 13-15. + +## References + +- +- +- +- +- — Build an AppKit app with the new design +- +-