From 02d64fd0c374b3a9e5b0a77f1a7bbab90b5ba771 Mon Sep 17 00:00:00 2001 From: Sandipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:09:01 +0530 Subject: [PATCH 1/8] feat(macos): replace popover with native-style borderless menu panel --- macos/Sources/ForgeMenuBar/AppDelegate.swift | 5 +- macos/Sources/ForgeMenuBar/MenuRenderer.swift | 239 ++++++++++++++---- 2 files changed, 195 insertions(+), 49 deletions(-) diff --git a/macos/Sources/ForgeMenuBar/AppDelegate.swift b/macos/Sources/ForgeMenuBar/AppDelegate.swift index a4500ab..10de10a 100644 --- a/macos/Sources/ForgeMenuBar/AppDelegate.swift +++ b/macos/Sources/ForgeMenuBar/AppDelegate.swift @@ -138,8 +138,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let mainMenu = NSMenu(title: "Main Menu") let appItem = NSMenuItem() let appMenu = NSMenu(title: "ForgeCode") + // 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: "" ) diff --git a/macos/Sources/ForgeMenuBar/MenuRenderer.swift b/macos/Sources/ForgeMenuBar/MenuRenderer.swift index 519f3ad..73479d8 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,26 @@ 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. + static let contentWidth: CGFloat = 280 + /// 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 + /// Corner radius of the panel. Matches the system menu silhouette. + private static let cornerRadius: CGFloat = 12 + /// 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,8 +41,14 @@ 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 backdrop = NSVisualEffectView() private let scrollView = NSScrollView() private let bodyStack = NSStackView() private let installationProgress = NSProgressIndicator() @@ -47,7 +64,7 @@ final class PopoverController: NSObject, NSPopoverDelegate { override init() { super.init() - configurePopover() + configurePanel() rebuild() } @@ -72,23 +89,41 @@ 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 + let root = backdrop + // `.menu` is the material the system menus themselves use, so the panel + // picks up the same translucency and blur rather than the flatter, + // heavier `.hudWindow` wash. + root.material = .menu root.blendingMode = .behindWindow root.state = .active - root.appearance = NSAppearance(named: .darkAqua) + // 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. + root.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. + root.maskImage = Self.roundedMask(radius: Self.cornerRadius) root.translatesAutoresizingMaskIntoConstraints = false bodyStack.orientation = .vertical @@ -96,7 +131,7 @@ final class PopoverController: NSObject, NSPopoverDelegate { // 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) + bodyStack.edgeInsets = NSEdgeInsets(top: 6, left: 4, bottom: 6, right: 4) bodyStack.translatesAutoresizingMaskIntoConstraints = false scrollView.drawsBackground = false @@ -113,8 +148,7 @@ final class PopoverController: NSObject, NSPopoverDelegate { root.addSubview(scrollView) NSLayoutConstraint.activate([ - root.widthAnchor.constraint(equalToConstant: Self.contentSize.width), - root.heightAnchor.constraint(equalToConstant: Self.contentSize.height), + root.widthAnchor.constraint(equalToConstant: Self.contentWidth), scrollView.topAnchor.constraint(equalTo: root.topAnchor), scrollView.leadingAnchor.constraint(equalTo: root.leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: root.trailingAnchor), @@ -128,11 +162,25 @@ 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 } + /// 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 + } + private func rebuild() { presentation = PopoverPresentation.make(snapshot: snapshot) rebuildBody() @@ -186,16 +234,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 +264,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,7 +318,7 @@ final class PopoverController: NSObject, NSPopoverDelegate { row.isEnabled = isEnabled row.configure( title: title, - titleFont: .systemFont(ofSize: 12, weight: isProminent ? .semibold : .regular), + titleFont: Typography.rowTitle(prominent: isProminent), trailingText: trailingText ) row.toolTip = toolTip ?? title @@ -288,13 +343,32 @@ 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 label.maximumNumberOfLines = 2 label.lineBreakMode = .byTruncatingTail @@ -322,17 +396,54 @@ 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() { @@ -389,8 +500,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 +519,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 } @@ -450,10 +557,45 @@ final class PopoverController: NSObject, NSPopoverDelegate { } +/// Type scale for the menu. Sizes track the system menu bar metrics rather +/// than the smaller control sizes the panel previously used, which rendered +/// cramped next to native menus like the one in the reference screenshot. +enum Typography { + /// Row titles: 13pt medium. Regular weight at this size reads thin against + /// a translucent backdrop, so medium is the resting weight and semibold is + /// reserved for prominent (call-to-action) rows. + static func rowTitle(prominent: Bool) -> NSFont { + let size: CGFloat = 13 + let weight: NSFont.Weight = prominent ? .semibold : .medium + let base = NSFont.systemFont(ofSize: size, weight: weight) + // 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 and checkmarks. A step down and lighter than the title, + /// so the shortcut column recedes. + static let rowTrailing = NSFont.systemFont(ofSize: 12, 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 + /// Taller than the old 28pt to match the system menu rhythm and give the + /// 13pt title room to breathe. + static let height: CGFloat = 30 + static let inset: CGFloat = 12 private let titleLabel = NSTextField(labelWithString: "") private let trailingLabel = NSTextField(labelWithString: "") @@ -495,7 +637,7 @@ 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 @@ -608,19 +750,20 @@ private final class CommandRowView: NSView { override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) - let highlight = bounds.insetBy(dx: 4, dy: 1) + 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) + 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. + // selection background: a translucent wash would let the blurred + // backdrop through and read as washed-out lavender. NSColor.controlAccentColor.setFill() - NSBezierPath(roundedRect: highlight, xRadius: 5, yRadius: 5).fill() + NSBezierPath(roundedRect: highlight, xRadius: radius, yRadius: radius).fill() } override var isFlipped: Bool { true } From eb0e86245e930ce1132a72986c1c26cf7c2b547d Mon Sep 17 00:00:00 2001 From: Sandipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:49:24 +0530 Subject: [PATCH 2/8] fix(macos): show a single constant menu bar logo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status icon changed shade during every launch. `updateStatusItem` derived `contentTintColor` from the service phase, and startup always walked through several phases before settling: 1. The first paint runs before `serviceController.start()`, when the snapshot still holds its default `.stopped` phase, so the icon was drawn dimmed with `.secondaryLabelColor`. 2. Installing/starting then applied `.controlAccentColor`. 3. `.ready` finally cleared the tint. Because the logo is a template image, the tint was the only thing determining its shade, so this read as the logo spontaneously changing colour — appearing dark-then-light on a light menu bar and the reverse on a dark one. Drop the phase tinting entirely and set the image once at setup. The icon is now constant and matches the menu bar like any other status item. Phase remains visible through the tooltip, the accessibility label, and the panel contents. Also stop rebuilding the image on every snapshot: it never varies, so that only re-rasterised the bezier path. --- macos/Sources/ForgeMenuBar/AppDelegate.swift | 23 +++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/macos/Sources/ForgeMenuBar/AppDelegate.swift b/macos/Sources/ForgeMenuBar/AppDelegate.swift index 10de10a..83bb1c0 100644 --- a/macos/Sources/ForgeMenuBar/AppDelegate.swift +++ b/macos/Sources/ForgeMenuBar/AppDelegate.swift @@ -56,6 +56,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() @@ -239,16 +243,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) From ed00e5e99c8858de008c6cfbddce33b219991917 Mon Sep 17 00:00:00 2001 From: Sandipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:36:48 +0530 Subject: [PATCH 3/8] refactor(macos): match the menu panel to native menu metrics The panel read as an app card rather than a menu bar utility: every row was set in medium weight, rows were 30pt tall against a native 22pt, and the 280pt width left ~110pt of dead space beside the longest label. Rather than estimate from a screenshot, the target values were taken from AppKit directly -- `NSFont.menuFont(ofSize: 0)` reports 13pt at weight 5 (regular), and differencing the reported heights of NSMenus built with N items yields exactly 22pt per item, an 11pt separator slot, 10pt of total chrome, and a 171pt intrinsic width for these labels. body weight medium -> regular prominent rows semibold -> regular, accent colour row height 30pt + 1pt -> 22pt, no inter-row gap panel width 280pt -> 220pt corner radius 12pt -> 10pt vertical padding 6pt -> 5pt toggle switch .small -> .mini key equivalent 12pt -> 13pt, recedes via colour alone Steady-state height drops from ~147pt to ~104pt. The medium weight was introduced on the theory that regular reads thin over a translucent backdrop. It does not: the vibrancy material and `labelColor` already carry the contrast, and the extra weight only made every row look emphasised. Dropping semibold would have left `isProminent` a silent no-op, since weight was the only thing it selected. It now drives the resting title colour instead, which is how native menus signal emphasis. Verified against a render of the live panel: width 219pt, row pitch 21.5pt, separator slot 11pt, no truncation of the longest label, and the switch vertically centred with clearance inside the shorter row. --- macos/Sources/ForgeMenuBar/MenuRenderer.swift | 131 ++++++++++++++---- 1 file changed, 102 insertions(+), 29 deletions(-) diff --git a/macos/Sources/ForgeMenuBar/MenuRenderer.swift b/macos/Sources/ForgeMenuBar/MenuRenderer.swift index 73479d8..8b9d967 100644 --- a/macos/Sources/ForgeMenuBar/MenuRenderer.swift +++ b/macos/Sources/ForgeMenuBar/MenuRenderer.swift @@ -14,7 +14,12 @@ final class PopoverController: NSObject { /// 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. - static let contentWidth: CGFloat = 280 + /// + /// Width is drawn from AppKit: an `NSMenu` built from these exact labels + /// reports an intrinsic width of 171pt. 220pt keeps that headroom for the + /// longer transient strings ("Retry Runtime Installation") without leaving + /// the ~110pt of dead space the previous 280pt had. + 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 @@ -22,9 +27,9 @@ final class PopoverController: NSObject { /// Horizontal inset shared by rows and notes. 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 /// Corner radius of the panel. Matches the system menu silhouette. - private static let cornerRadius: CGFloat = 12 + private static let cornerRadius: CGFloat = 10 /// Gap between the menu bar and the top of the panel. private static let menuBarGap: CGFloat = 6 @@ -129,9 +134,11 @@ final class PopoverController: NSObject { 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: 6, 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 @@ -218,13 +225,14 @@ final class PopoverController: NSObject { 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( @@ -319,7 +327,9 @@ final class PopoverController: NSObject { row.configure( title: title, titleFont: Typography.rowTitle(prominent: isProminent), - trailingText: trailingText + trailingText: trailingText, + toggleState: isToggle ? isOn : nil, + isProminent: isProminent ) row.toolTip = toolTip ?? title row.configureAccessibility( @@ -545,7 +555,10 @@ final class PopoverController: NSObject { } 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 { @@ -557,26 +570,29 @@ final class PopoverController: NSObject { } -/// Type scale for the menu. Sizes track the system menu bar metrics rather -/// than the smaller control sizes the panel previously used, which rendered -/// cramped next to native menus like the one in the reference screenshot. +/// 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 medium. Regular weight at this size reads thin against - /// a translucent backdrop, so medium is the resting weight and semibold is - /// reserved for prominent (call-to-action) rows. + /// 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 weight: NSFont.Weight = prominent ? .semibold : .medium - let base = NSFont.systemFont(ofSize: size, weight: weight) + 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 and checkmarks. A step down and lighter than the title, - /// so the shortcut column recedes. - static let rowTrailing = NSFont.systemFont(ofSize: 12, weight: .regular) + /// 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) @@ -592,13 +608,23 @@ final class MenuPanel: NSPanel { /// A full-width, text-only menu row with a rounded highlight on hover. private final class CommandRowView: NSView { - /// Taller than the old 28pt to match the system menu rhythm and give the - /// 13pt title room to breathe. - static let height: CGFloat = 30 + /// 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? @@ -643,12 +669,28 @@ private final class CommandRowView: NSView { 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( @@ -658,10 +700,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) @@ -671,13 +731,24 @@ 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 @@ -688,7 +759,9 @@ private final class CommandRowView: NSView { 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 } } From 9fdde1339b738249c4cf02b6ba8e6db7375ca425 Mon Sep 17 00:00:00 2001 From: Sandipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:44:14 +0530 Subject: [PATCH 4/8] feat(macos): adopt Liquid Glass backdrop for the menu panel on macOS 26 --- .github/workflows/ci.yml | 11 +- .github/workflows/release.yml | 10 +- macos/README.md | 10 +- macos/Sources/ForgeMenuBar/MenuBackdrop.swift | 109 +++++++++ macos/Sources/ForgeMenuBar/MenuRenderer.swift | 96 ++++---- macos/docs/liquid-glass-adoption.md | 229 ++++++++++++++++++ 6 files changed, 412 insertions(+), 53 deletions(-) create mode 100644 macos/Sources/ForgeMenuBar/MenuBackdrop.swift create mode 100644 macos/docs/liquid-glass-adoption.md 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 b84a7d5..4c37428 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 225e109..6ea2f30 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/MenuBackdrop.swift b/macos/Sources/ForgeMenuBar/MenuBackdrop.swift new file mode 100644 index 0000000..86df15e --- /dev/null +++ b/macos/Sources/ForgeMenuBar/MenuBackdrop.swift @@ -0,0 +1,109 @@ +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. + /// + /// macOS 26 popovers are rounded noticeably more generously than the 10pt + /// that matched the pre-26 menu silhouette, so this tracks the backend + /// instead of being one fixed constant. + static var cornerRadius: CGFloat { + switch Backend.active { + case .glass: return 16 + case .visualEffect: return 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 + // 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 8b9d967..6e4c068 100644 --- a/macos/Sources/ForgeMenuBar/MenuRenderer.swift +++ b/macos/Sources/ForgeMenuBar/MenuRenderer.swift @@ -28,8 +28,6 @@ final class PopoverController: NSObject { private static let rowInset: CGFloat = 12 /// Left edge shared by command titles and supporting notes. private static let labelColumnInset: CGFloat = rowInset - /// Corner radius of the panel. Matches the system menu silhouette. - private static let cornerRadius: CGFloat = 10 /// Gap between the menu bar and the top of the panel. private static let menuBarGap: CGFloat = 6 @@ -53,7 +51,6 @@ final class PopoverController: NSObject { defer: false ) private let contentController = PopoverContentViewController() - private let backdrop = NSVisualEffectView() private let scrollView = NSScrollView() private let bodyStack = NSStackView() private let installationProgress = NSProgressIndicator() @@ -113,23 +110,12 @@ final class PopoverController: NSObject { } private func buildRootView() -> NSView { - let root = backdrop - // `.menu` is the material the system menus themselves use, so the panel - // picks up the same translucency and blur rather than the flatter, - // heavier `.hudWindow` wash. - root.material = .menu - root.blendingMode = .behindWindow - root.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. - root.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. - root.maskImage = Self.roundedMask(radius: Self.cornerRadius) - 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 @@ -152,14 +138,16 @@ final class PopoverController: NSObject { document.addSubview(bodyStack) scrollView.documentView = document - root.addSubview(scrollView) + body.addSubview(scrollView) NSLayoutConstraint.activate([ - root.widthAnchor.constraint(equalToConstant: Self.contentWidth), - 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), @@ -171,21 +159,8 @@ final class PopoverController: NSObject { bodyStack.bottomAnchor.constraint(equalTo: document.bottomAnchor), bodyStack.widthAnchor.constraint(equalToConstant: Self.contentWidth) ]) - return root - } - /// 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 + return MenuBackdrop.makeRoot(content: body) } private func rebuild() { @@ -649,6 +624,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) @@ -751,11 +731,19 @@ private final class CommandRowView: NSView { 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 softer glass highlight is not a saturated accent fill, so + // the label keeps the ordinary selected-text colour. + onHighlight = .selectedMenuItemTextColor + case .visualEffect: + onHighlight = .alternateSelectedControlTextColor + } titleLabel.textColor = onHighlight trailingLabel.textColor = onHighlight.withAlphaComponent(0.75) } else { @@ -823,8 +811,11 @@ private final class CommandRowView: NSView { override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) - let highlight = bounds.insetBy(dx: 5, dy: 1) - let radius: CGFloat = 6 + let isGlass = MenuBackdrop.Backend.active == .glass + // Glass menus inset the highlight further and round it more than the + // pre-26 menus did. + let highlight = bounds.insetBy(dx: isGlass ? 7 : 5, dy: 1) + let radius: CGFloat = isGlass ? 8 : 6 if window?.firstResponder === self { NSColor.keyboardFocusIndicatorColor.setStroke() let focusPath = NSBezierPath(roundedRect: highlight, xRadius: radius, yRadius: radius) @@ -832,10 +823,17 @@ private final class CommandRowView: NSView { focusPath.stroke() } guard isHovered else { return } - // Fully opaque and using the brighter accent rather than the muted - // selection background: a translucent wash would let the blurred - // backdrop through and read as washed-out lavender. - NSColor.controlAccentColor.setFill() + if isGlass { + // Over glass the opaque accent fill below reads as a hard slab: the + // material already carries the contrast, so macOS 26 highlights with + // the softer selection colour instead. + NSColor.selectedContentBackgroundColor.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() } diff --git a/macos/docs/liquid-glass-adoption.md b/macos/docs/liquid-glass-adoption.md new file mode 100644 index 0000000..bcf9938 --- /dev/null +++ b/macos/docs/liquid-glass-adoption.md @@ -0,0 +1,229 @@ +# 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 varies by backend + +`16` on glass, `10` on the legacy path, via `MenuBackdrop.cornerRadius`. The +`PopoverController.cornerRadius` constant is gone. + +### 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, where macOS 26 uses a softer +fill with more inset and a larger radius. Glass now uses +`.selectedContentBackgroundColor` at `radius: 8` and `dx: 7`, with +`.selectedMenuItemTextColor` for the label; the legacy path keeps the opaque +accent at `radius: 6` and `dx: 5`. + +### 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 +- +- From ed57c0bcff289001b3f6fe40e065baf405f0facd Mon Sep 17 00:00:00 2001 From: Sandipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:39:52 +0530 Subject: [PATCH 5/8] fix(macos): revert glass corner radius, neutral highlight, untruncate notes Reviewing the rendered panel rather than the source: - Corner radius goes back to 10pt on both backends. The 16pt glass value was reasoned from the HIG rounding popovers more generously, but at this panel's size it read as bubbled rather than native. Row highlight geometry reverts with it, to dx: 5 / radius: 6 on both paths. - The hover and keyboard-focus fills on glass use unemphasizedSelectedContentBackgroundColor instead of an accent fill and an accent-blue focus ring. The system's own menu bar popovers mark a row with a neutral translucent wash, and the saturated accent was what made this panel read as non-native. The label stays labelColor there; selectedMenuItemTextColor is white and vanished into the wash. - 'Retry Runtime Installation' becomes 'Retry Install', which fits the 220pt panel instead of eliding to 'Retry Runtime Installati...'. - Error notes wrap instead of eliding. maximumNumberOfLines = 2 had no effect because lineBreakMode was .byTruncatingTail, which pins a label to one line; 'Runtime download failed. Check y...' now shows in full. Wrapping also needs preferredMaxLayoutWidth or the label reports a one-line intrinsic height and the second line gets no room. Verified on macOS 14.5 via the legacy path: 10/10 captures, 135 tests. --- macos/Sources/ForgeMenuBar/MenuBackdrop.swift | 14 ++--- macos/Sources/ForgeMenuBar/MenuRenderer.swift | 63 +++++++++++++------ macos/docs/liquid-glass-adoption.md | 26 +++++--- 3 files changed, 67 insertions(+), 36 deletions(-) diff --git a/macos/Sources/ForgeMenuBar/MenuBackdrop.swift b/macos/Sources/ForgeMenuBar/MenuBackdrop.swift index 86df15e..1c9333d 100644 --- a/macos/Sources/ForgeMenuBar/MenuBackdrop.swift +++ b/macos/Sources/ForgeMenuBar/MenuBackdrop.swift @@ -25,15 +25,11 @@ enum MenuBackdrop { /// Corner radius of the panel. /// - /// macOS 26 popovers are rounded noticeably more generously than the 10pt - /// that matched the pre-26 menu silhouette, so this tracks the backend - /// instead of being one fixed constant. - static var cornerRadius: CGFloat { - switch Backend.active { - case .glass: return 16 - case .visualEffect: return 10 - } - } + /// 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. diff --git a/macos/Sources/ForgeMenuBar/MenuRenderer.swift b/macos/Sources/ForgeMenuBar/MenuRenderer.swift index 6e4c068..fcf062f 100644 --- a/macos/Sources/ForgeMenuBar/MenuRenderer.swift +++ b/macos/Sources/ForgeMenuBar/MenuRenderer.swift @@ -16,9 +16,10 @@ final class PopoverController: NSObject { /// 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 that headroom for the - /// longer transient strings ("Retry Runtime Installation") without leaving - /// the ~110pt of dead space the previous 280pt had. + /// 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. @@ -193,7 +194,7 @@ final class PopoverController: NSObject { } 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 )) @@ -355,8 +356,15 @@ final class PopoverController: NSObject { let label = NSTextField(labelWithString: text) 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) @@ -738,9 +746,11 @@ private final class CommandRowView: NSView { let onHighlight: NSColor switch MenuBackdrop.Backend.active { case .glass: - // The softer glass highlight is not a saturated accent fill, so - // the label keeps the ordinary selected-text colour. - onHighlight = .selectedMenuItemTextColor + // 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 } @@ -812,22 +822,35 @@ private final class CommandRowView: NSView { override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) let isGlass = MenuBackdrop.Backend.active == .glass - // Glass menus inset the highlight further and round it more than the - // pre-26 menus did. - let highlight = bounds.insetBy(dx: isGlass ? 7 : 5, dy: 1) - let radius: CGFloat = isGlass ? 8 : 6 + // 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: radius, yRadius: radius) - 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 } if isGlass { - // Over glass the opaque accent fill below reads as a hard slab: the - // material already carries the contrast, so macOS 26 highlights with - // the softer selection colour instead. - NSColor.selectedContentBackgroundColor.setFill() + // 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 diff --git a/macos/docs/liquid-glass-adoption.md b/macos/docs/liquid-glass-adoption.md index bcf9938..c3928dc 100644 --- a/macos/docs/liquid-glass-adoption.md +++ b/macos/docs/liquid-glass-adoption.md @@ -138,20 +138,32 @@ derives its geometry from `contentView`. Still needed for `NSVisualEffectView`; never applied on the glass path. -### 4. Corner radius varies by backend +### 4. Corner radius is one value for both backends -`16` on glass, `10` on the legacy path, via `MenuBackdrop.cornerRadius`. The +`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, where macOS 26 uses a softer -fill with more inset and a larger radius. Glass now uses -`.selectedContentBackgroundColor` at `radius: 8` and `dx: 7`, with -`.selectedMenuItemTextColor` for the label; the legacy path keeps the opaque -accent at `radius: 6` and `dx: 5`. +`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` From 01c46ab77adf8da6fcd3162f3850992d323f607e Mon Sep 17 00:00:00 2001 From: Sandipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:32:58 +0530 Subject: [PATCH 6/8] fix(macos): drop the hairline separator from the menu panel The NSBox separator reads as a hard border cutting across the panel, which is wrong against Liquid Glass: the material has no internal dividers of its own and a drawn line breaks its continuity. The two app-level commands are now fenced off by the same groupSpacer() already used at the other group boundaries, so the grouping survives without a rule through it. --- macos/Sources/ForgeMenuBar/MenuRenderer.swift | 27 ++++--------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/macos/Sources/ForgeMenuBar/MenuRenderer.swift b/macos/Sources/ForgeMenuBar/MenuRenderer.swift index fcf062f..311555c 100644 --- a/macos/Sources/ForgeMenuBar/MenuRenderer.swift +++ b/macos/Sources/ForgeMenuBar/MenuRenderer.swift @@ -224,9 +224,11 @@ final class PopoverController: NSObject { action: #selector(showErrorCommand) )) } - // Separator fencing off the two app-level commands from the - // service-related rows above. - bodyStack.addArrangedSubview(separator()) + // Whitespace rather than a drawn line to fence off the two app-level + // commands from the service rows above. An `NSBox` hairline reads as a + // hard border against Liquid Glass, which has no internal dividers of + // its own; the gap groups the rows without cutting the material. + bodyStack.addArrangedSubview(groupSpacer()) bodyStack.addArrangedSubview(commandRow( // Deliberately not "Check for Updates": the bundled server updates // on its own cadence, so an unqualified label reads as though it @@ -329,25 +331,6 @@ final class PopoverController: NSObject { 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 { From 5afadf6a543dad4133b89b93ae0018e2e1f314a3 Mon Sep 17 00:00:00 2001 From: Sandipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:40:28 +0530 Subject: [PATCH 7/8] fix(macos): clip glass content to the rounded corners cornerRadius rounds the glass itself, but the content view and its rows kept painting square out to the bounds, so the corners showed pointy tabs poking past the curve and a row highlight squared off the top and bottom edge. clipsToBounds makes the content follow the same silhouette, which is what the system popovers do. --- macos/Sources/ForgeMenuBar/MenuBackdrop.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/macos/Sources/ForgeMenuBar/MenuBackdrop.swift b/macos/Sources/ForgeMenuBar/MenuBackdrop.swift index 1c9333d..d9a0958 100644 --- a/macos/Sources/ForgeMenuBar/MenuBackdrop.swift +++ b/macos/Sources/ForgeMenuBar/MenuBackdrop.swift @@ -55,6 +55,12 @@ enum MenuBackdrop { // 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 From bb73496db1327387ee91865591a1ac5dda20d7c7 Mon Sep 17 00:00:00 2001 From: Sandipsinh Rathod <62684960+ssddOnTop@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:57:45 +0530 Subject: [PATCH 8/8] Revert "fix(macos): drop the hairline separator from the menu panel" This reverts commit 01c46ab. The separator was removed while chasing a border the user was seeing, but the real cause was NSGlassEffectView not clipping its content to cornerRadius, fixed separately in 5afadf6. The system popovers and 1Password both keep a hairline between groups, so the line belongs here too; it just needed ends that follow the panel's curve. --- macos/Sources/ForgeMenuBar/MenuRenderer.swift | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/macos/Sources/ForgeMenuBar/MenuRenderer.swift b/macos/Sources/ForgeMenuBar/MenuRenderer.swift index 311555c..fcf062f 100644 --- a/macos/Sources/ForgeMenuBar/MenuRenderer.swift +++ b/macos/Sources/ForgeMenuBar/MenuRenderer.swift @@ -224,11 +224,9 @@ final class PopoverController: NSObject { action: #selector(showErrorCommand) )) } - // Whitespace rather than a drawn line to fence off the two app-level - // commands from the service rows above. An `NSBox` hairline reads as a - // hard border against Liquid Glass, which has no internal dividers of - // its own; the gap groups the rows without cutting the material. - bodyStack.addArrangedSubview(groupSpacer()) + // 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 @@ -331,6 +329,25 @@ final class PopoverController: NSObject { 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 {