From 414fff045f8653617e09d22e527d16149f5e4084 Mon Sep 17 00:00:00 2001 From: ggfevans Date: Tue, 9 Jun 2026 02:24:13 -0700 Subject: [PATCH 1/6] Add collapsible session-list sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the left session-list panel collapsible on the Schedule and Videos tabs with a native toggle: a leading titlebar button, a View-menu "Hide Sidebar" item, and the standard ⌃⌘S shortcut, all routed through AppKit's toggleSidebar: responder action. - SessionsSplitViewController: enable canCollapse; sync collapse state across both tabs (mirroring the existing width-sync) and guard the width-persistence path against the collapsed ~0pt width. - TitleBarViewController: add the leading sidebar-toggle button. - AppCoordinator: disable the button on the Explore tab (no sidebar). - MainWindowController: session-only shared collapsed-state flag so a lazily-loaded tab adopts the current state. Collapsed state is session-only (reopens expanded on next launch). Also gitignore CLAUDE.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + WWDC/Bootstrap/AppCoordinator.swift | 4 ++ .../Base/MainWindowController.swift | 4 ++ .../Base/TitleBarViewController.swift | 43 +++++++++++- .../SessionsSplitViewController.swift | 68 ++++++++++++++++++- WWDC/Resources/Base.lproj/Main.storyboard | 7 ++ 6 files changed, 122 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 5091becb2..8587851c8 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ Pods/ Carthage Provisioning TeamID.xcconfig +CLAUDE.md diff --git a/WWDC/Bootstrap/AppCoordinator.swift b/WWDC/Bootstrap/AppCoordinator.swift index d06baf83f..1dcd0829f 100644 --- a/WWDC/Bootstrap/AppCoordinator.swift +++ b/WWDC/Bootstrap/AppCoordinator.swift @@ -257,6 +257,10 @@ final class AppCoordinator: Logging, Signposting { guard let self else { return } self.activeTab = activeTab + // The sidebar toggle only applies to tabs that have a session-list sidebar. + let hasSidebar = (activeTab == .schedule || activeTab == .videos) + self.windowController.titleBarViewController.sidebarToggleButton.isEnabled = hasSidebar + switch activeTab { case .schedule: activeTabSelectedSessionViewModel = scheduleSelectedSessionViewModel diff --git a/WWDC/Controllers/Base/MainWindowController.swift b/WWDC/Controllers/Base/MainWindowController.swift index 3ec51dfbf..83357c3bb 100644 --- a/WWDC/Controllers/Base/MainWindowController.swift +++ b/WWDC/Controllers/Base/MainWindowController.swift @@ -43,6 +43,10 @@ final class MainWindowController: WWDCWindowController { } public var sidebarInitWidth: CGFloat? + /// Shared, session-only collapsed state for the session-list sidebar, so both + /// tabs agree and a lazily-loaded tab adopts the current state. Not persisted. + public var sidebarCollapsed = false + override func loadWindow() { let mask: NSWindow.StyleMask = [.titled, .resizable, .miniaturizable, .closable, .fullSizeContentView] let window = WWDCWindow(contentRect: MainWindowController.defaultRect, styleMask: mask, backing: .buffered, defer: false) diff --git a/WWDC/Controllers/Base/TitleBarViewController.swift b/WWDC/Controllers/Base/TitleBarViewController.swift index a8607fdf7..ff00aa03b 100644 --- a/WWDC/Controllers/Base/TitleBarViewController.swift +++ b/WWDC/Controllers/Base/TitleBarViewController.swift @@ -6,12 +6,15 @@ // Copyright © 2018 Guilherme Rambo. All rights reserved. // -import Foundation +import Cocoa final class TitleBarViewController: NSTitlebarAccessoryViewController { private var horizontalPositioningConstraints = [NSLayoutConstraint]() + /// Approximate inset that clears the window's traffic-light controls. + private static let trafficLightInset: CGFloat = 76 + private var centerOffset: CGFloat = 0 { didSet { horizontalPositioningConstraints.forEach { $0.constant = centerOffset } @@ -32,6 +35,31 @@ final class TitleBarViewController: NSTitlebarAccessoryViewController { return v }() + private lazy var leadingContainer: NSView = { + let v = NSView() + v.translatesAutoresizingMaskIntoConstraints = false + + return v + }() + + /// Sidebar collapse/expand toggle. Fires `toggleSidebar:` up the responder chain + /// so it reaches the active tab's split view controller. Exposed so the coordinator + /// can disable it on tabs that have no sidebar (Explore). + lazy var sidebarToggleButton: NSButton = { + let symbol = NSImage(systemSymbolName: "sidebar.left", accessibilityDescription: "Toggle Sidebar") + let configured = symbol?.withSymbolConfiguration(.init(pointSize: 15, weight: .regular)) + + let b = NSButton(image: configured ?? NSImage(), target: nil, action: #selector(NSSplitViewController.toggleSidebar(_:))) + b.translatesAutoresizingMaskIntoConstraints = false + b.isBordered = false + b.imagePosition = .imageOnly + b.setButtonType(.momentaryChange) + b.imageScaling = .scaleProportionallyDown + b.toolTip = "Toggle Sidebar" + + return b + }() + var statusViewController: NSViewController? { didSet { replace(child: oldValue, with: statusViewController, inContainer: statusContainer) @@ -57,14 +85,25 @@ final class TitleBarViewController: NSTitlebarAccessoryViewController { override func loadView() { let view = NSView() + view.addSubview(leadingContainer) view.addSubview(tabBarContainer) view.addSubview(statusContainer) + leadingContainer.addSubview(sidebarToggleButton) + + leadingContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Self.trafficLightInset).isActive = true + leadingContainer.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true + leadingContainer.heightAnchor.constraint(equalTo: view.heightAnchor).isActive = true + + sidebarToggleButton.leadingAnchor.constraint(equalTo: leadingContainer.leadingAnchor).isActive = true + sidebarToggleButton.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor).isActive = true + sidebarToggleButton.centerYAnchor.constraint(equalTo: leadingContainer.centerYAnchor).isActive = true + let centerXConstraint = tabBarContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor) centerXConstraint.isActive = true horizontalPositioningConstraints.append(centerXConstraint) tabBarContainer.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true - tabBarContainer.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor).isActive = true + tabBarContainer.leadingAnchor.constraint(greaterThanOrEqualTo: leadingContainer.trailingAnchor, constant: 8).isActive = true tabBarContainer.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor).isActive = true statusContainer.leadingAnchor.constraint(equalTo: tabBarContainer.trailingAnchor).isActive = true diff --git a/WWDC/Controllers/Sessions/SessionsSplitViewController.swift b/WWDC/Controllers/Sessions/SessionsSplitViewController.swift index 52ae03e4a..cf7539e5c 100644 --- a/WWDC/Controllers/Sessions/SessionsSplitViewController.swift +++ b/WWDC/Controllers/Sessions/SessionsSplitViewController.swift @@ -23,6 +23,13 @@ final class SessionsSplitViewController: NSSplitViewController { var setupDone = false private var cancellables: Set = [] + /// The split view item hosting the session list sidebar. Retained so its + /// collapsed state can be toggled, observed and synced across tabs. + private var sidebarItem: NSSplitViewItem! + + /// Guards against feedback loops while mirroring collapse state from another tab. + private var isSyncingCollapse = false + init(windowController: MainWindowController, listViewController: SessionsTableViewController) { self.windowController = windowController self.listViewController = listViewController @@ -39,6 +46,14 @@ final class SessionsSplitViewController: NSSplitViewController { self?.syncSplitView(notification: notification) } .store(in: &cancellables) + + NotificationCenter + .default + .publisher(for: .sidebarCollapseSyncNotification) + .sink { [weak self] notification in + self?.applyCollapseSync(notification: notification) + } + .store(in: &cancellables) } required init?(coder: NSCoder) { @@ -51,7 +66,8 @@ final class SessionsSplitViewController: NSSplitViewController { view.wantsLayer = true let listItem = NSSplitViewItem(sidebarWithViewController: listViewController) - listItem.canCollapse = false + listItem.canCollapse = true + sidebarItem = listItem let detailItem = NSSplitViewItem(viewController: detailViewController) addSplitViewItem(listItem) @@ -59,6 +75,24 @@ final class SessionsSplitViewController: NSSplitViewController { listViewController.view.setContentHuggingPriority(.defaultHigh, for: .horizontal) detailViewController.view.setContentHuggingPriority(.defaultLow, for: .horizontal) + + // Mirror collapse state to the other tab's split view so both stay consistent + // during a session. Catches both the toggleSidebar: action and drag-to-collapse. + listItem + .publisher(for: \.isCollapsed) + .dropFirst() + .removeDuplicates() + .sink { [weak self] collapsed in + guard let self, !self.isSyncingCollapse else { return } + // Record the shared session state so the other (possibly not-yet-loaded) tab adopts it. + self.windowController.sidebarCollapsed = collapsed + NotificationCenter.default.post( + name: .sidebarCollapseSyncNotification, + object: self.splitView, + userInfo: ["collapsed": collapsed] + ) + } + .store(in: &cancellables) } override func viewWillAppear() { @@ -68,6 +102,13 @@ final class SessionsSplitViewController: NSSplitViewController { if let sidebarInitWidth = windowController.sidebarInitWidth { splitView.setPosition(sidebarInitWidth, ofDividerAt: 0) } + // Adopt the shared collapsed state in case it changed in another tab before + // this one was lazily loaded. + if sidebarItem.isCollapsed != windowController.sidebarCollapsed { + isSyncingCollapse = true + sidebarItem.isCollapsed = windowController.sidebarCollapsed + isSyncingCollapse = false + } setupDone = true } } @@ -87,8 +128,11 @@ final class SessionsSplitViewController: NSSplitViewController { #endif guard notificationSourceSplitView !== splitView else { - // If own split view is altered, change split view initialisation width for other tabs - windowController.sidebarInitWidth = notificationSourceSplitView.subviews[targetSubviewIndex].bounds.width + // If own split view is altered, change split view initialisation width for other tabs. + // Skip while collapsed so the ~0pt collapsed width is never persisted as the sidebar width. + if !sidebarItem.isCollapsed { + windowController.sidebarInitWidth = notificationSourceSplitView.subviews[targetSubviewIndex].bounds.width + } return } guard splitView.subviews.count > 0, notificationSourceSplitView.subviews.count > 0 else { @@ -108,12 +152,30 @@ final class SessionsSplitViewController: NSSplitViewController { override func splitViewDidResizeSubviews(_ notification: Notification) { guard isResizingSplitView == false else { return } guard setupDone else { return } + // Don't broadcast a width sync for a collapse/expand; collapse state has its own sync. + guard !sidebarItem.isCollapsed else { return } // This notification should only be posted in response to user input NotificationCenter.default.post(name: .sideBarSizeSyncNotification, object: splitView, userInfo: nil) } + + /// Mirrors a collapse/expand performed in another tab's split view onto this one. + private func applyCollapseSync(notification: Notification) { + // The split view item only exists once this tab's view has loaded. A not-yet-loaded + // tab will adopt the shared state via windowController.sidebarCollapsed in viewWillAppear. + guard let sidebarItem else { return } + guard let sourceSplitView = notification.object as? NSSplitView, sourceSplitView !== splitView else { return } + guard let collapsed = notification.userInfo?["collapsed"] as? Bool else { return } + guard sidebarItem.isCollapsed != collapsed else { return } + + // The inactive tab isn't visible, so apply directly without animation. + isSyncingCollapse = true + sidebarItem.isCollapsed = collapsed + isSyncingCollapse = false + } } extension Notification.Name { public static let sideBarSizeSyncNotification = NSNotification.Name("WWDCSplitViewSizeSyncNotification") + public static let sidebarCollapseSyncNotification = NSNotification.Name("WWDCSidebarCollapseSyncNotification") } diff --git a/WWDC/Resources/Base.lproj/Main.storyboard b/WWDC/Resources/Base.lproj/Main.storyboard index c177ae50b..da7554e38 100644 --- a/WWDC/Resources/Base.lproj/Main.storyboard +++ b/WWDC/Resources/Base.lproj/Main.storyboard @@ -557,6 +557,13 @@ + + + + + + + From 5159a3507e5e9762cbaf9361eab0d4de9130b0c4 Mon Sep 17 00:00:00 2001 From: ggfevans Date: Tue, 9 Jun 2026 03:03:02 -0700 Subject: [PATCH 2/6] Fix width-sync re-expanding collapsed sidebar; consolidate sidebar state into struct Part A: Guard the receiver path of syncSplitView(notification:) so a trailing width-sync notification (throttled 250 ms) cannot call setPosition and re-expand THIS tab's sidebar after it was already collapsed. Part B: Merge the two loose shared-state fields (sidebarInitWidth, sidebarCollapsed) on MainWindowController into a single nested SidebarState struct. Updates all call sites in SessionsSplitViewController accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Base/MainWindowController.swift | 11 +++++++---- .../Base/TitleBarViewController.swift | 19 +++++++++++++++++-- .../SessionsSplitViewController.swift | 18 +++++++++++------- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/WWDC/Controllers/Base/MainWindowController.swift b/WWDC/Controllers/Base/MainWindowController.swift index 83357c3bb..1686042f1 100644 --- a/WWDC/Controllers/Base/MainWindowController.swift +++ b/WWDC/Controllers/Base/MainWindowController.swift @@ -41,11 +41,14 @@ final class MainWindowController: WWDCWindowController { return NSScreen.main?.visibleFrame.insetBy(dx: 50, dy: 120) ?? NSRect(x: 0, y: 0, width: 1200, height: 600) } - public var sidebarInitWidth: CGFloat? - /// Shared, session-only collapsed state for the session-list sidebar, so both - /// tabs agree and a lazily-loaded tab adopts the current state. Not persisted. - public var sidebarCollapsed = false + /// Shared, session-only sidebar layout state so both tabs agree and a + /// lazily-loaded tab adopts the current state. Not persisted across launches. + struct SidebarState { + var initialWidth: CGFloat? + var collapsed = false + } + var sidebarState = SidebarState() override func loadWindow() { let mask: NSWindow.StyleMask = [.titled, .resizable, .miniaturizable, .closable, .fullSizeContentView] diff --git a/WWDC/Controllers/Base/TitleBarViewController.swift b/WWDC/Controllers/Base/TitleBarViewController.swift index ff00aa03b..ba4a30f48 100644 --- a/WWDC/Controllers/Base/TitleBarViewController.swift +++ b/WWDC/Controllers/Base/TitleBarViewController.swift @@ -12,9 +12,15 @@ final class TitleBarViewController: NSTitlebarAccessoryViewController { private var horizontalPositioningConstraints = [NSLayoutConstraint]() - /// Approximate inset that clears the window's traffic-light controls. + /// Fallback leading inset used until the traffic-light buttons can be measured. private static let trafficLightInset: CGFloat = 76 + /// Gap between the rightmost traffic-light (zoom) button and the sidebar toggle. + private static let trafficLightGap: CGFloat = 8 + + /// Leading constraint for the toggle button; its constant tracks the traffic lights. + private var leadingButtonConstraint: NSLayoutConstraint? + private var centerOffset: CGFloat = 0 { didSet { horizontalPositioningConstraints.forEach { $0.constant = centerOffset } @@ -91,7 +97,9 @@ final class TitleBarViewController: NSTitlebarAccessoryViewController { leadingContainer.addSubview(sidebarToggleButton) - leadingContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Self.trafficLightInset).isActive = true + let leadingConstraint = leadingContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Self.trafficLightInset) + leadingConstraint.isActive = true + leadingButtonConstraint = leadingConstraint leadingContainer.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true leadingContainer.heightAnchor.constraint(equalTo: view.heightAnchor).isActive = true @@ -124,6 +132,13 @@ final class TitleBarViewController: NSTitlebarAccessoryViewController { let windowBounds = window.convertFromScreen(window.frame) let localWindowBounds = view.convert(windowBounds, from: nil) centerOffset = localWindowBounds.midX - view.bounds.midX + + // Tuck the sidebar toggle right up against the rightmost traffic-light (zoom) button. + if let zoomButton = window.standardWindowButton(.zoomButton), let buttonSuperview = zoomButton.superview { + let frameInWindow = buttonSuperview.convert(zoomButton.frame, to: nil) + let maxXInView = view.convert(frameInWindow, from: nil).maxX + leadingButtonConstraint?.constant = maxXInView + Self.trafficLightGap + } } func replace(child: NSViewController?, with newChild: NSViewController?, inContainer container: NSView) { diff --git a/WWDC/Controllers/Sessions/SessionsSplitViewController.swift b/WWDC/Controllers/Sessions/SessionsSplitViewController.swift index cf7539e5c..0f0b74fc7 100644 --- a/WWDC/Controllers/Sessions/SessionsSplitViewController.swift +++ b/WWDC/Controllers/Sessions/SessionsSplitViewController.swift @@ -85,7 +85,7 @@ final class SessionsSplitViewController: NSSplitViewController { .sink { [weak self] collapsed in guard let self, !self.isSyncingCollapse else { return } // Record the shared session state so the other (possibly not-yet-loaded) tab adopts it. - self.windowController.sidebarCollapsed = collapsed + self.windowController.sidebarState.collapsed = collapsed NotificationCenter.default.post( name: .sidebarCollapseSyncNotification, object: self.splitView, @@ -99,14 +99,14 @@ final class SessionsSplitViewController: NSSplitViewController { super.viewWillAppear() if !setupDone { - if let sidebarInitWidth = windowController.sidebarInitWidth { - splitView.setPosition(sidebarInitWidth, ofDividerAt: 0) + if let initWidth = windowController.sidebarState.initialWidth { + splitView.setPosition(initWidth, ofDividerAt: 0) } // Adopt the shared collapsed state in case it changed in another tab before // this one was lazily loaded. - if sidebarItem.isCollapsed != windowController.sidebarCollapsed { + if sidebarItem.isCollapsed != windowController.sidebarState.collapsed { isSyncingCollapse = true - sidebarItem.isCollapsed = windowController.sidebarCollapsed + sidebarItem.isCollapsed = windowController.sidebarState.collapsed isSyncingCollapse = false } setupDone = true @@ -131,10 +131,14 @@ final class SessionsSplitViewController: NSSplitViewController { // If own split view is altered, change split view initialisation width for other tabs. // Skip while collapsed so the ~0pt collapsed width is never persisted as the sidebar width. if !sidebarItem.isCollapsed { - windowController.sidebarInitWidth = notificationSourceSplitView.subviews[targetSubviewIndex].bounds.width + windowController.sidebarState.initialWidth = notificationSourceSplitView.subviews[targetSubviewIndex].bounds.width } return } + // If THIS tab's sidebar is already collapsed, skip applying the incoming width. + // Width-sync is throttled 250 ms so a trailing notification can arrive after + // the collapse-sync; calling setPosition here would re-expand the sidebar. + guard !sidebarItem.isCollapsed else { return } guard splitView.subviews.count > 0, notificationSourceSplitView.subviews.count > 0 else { return } @@ -162,7 +166,7 @@ final class SessionsSplitViewController: NSSplitViewController { /// Mirrors a collapse/expand performed in another tab's split view onto this one. private func applyCollapseSync(notification: Notification) { // The split view item only exists once this tab's view has loaded. A not-yet-loaded - // tab will adopt the shared state via windowController.sidebarCollapsed in viewWillAppear. + // tab will adopt the shared state via windowController.sidebarState.collapsed in viewWillAppear. guard let sidebarItem else { return } guard let sourceSplitView = notification.object as? NSSplitView, sourceSplitView !== splitView else { return } guard let collapsed = notification.userInfo?["collapsed"] as? Bool else { return } From 73d24822dd6957ce969f9ddc1bb8f9cb2176c922 Mon Sep 17 00:00:00 2001 From: ggfevans Date: Tue, 9 Jun 2026 03:09:24 -0700 Subject: [PATCH 3/6] Add dynamic sidebar-toggle positioning against traffic-light buttons - Position sidebarToggleButton dynamically by measuring the zoom button's frame in viewWillLayout(), replacing the static trafficLightInset fallback with a live maxX + trafficLightGap constant once the window is available. Refinements (R2): - Efficiency: guard constraint assignment behind an abs() > 0.5 epsilon check so repeated identical layout passes don't dirty the layout engine during live window resize. - Robustness: extract symbol creation into a private static makeToggleSymbol() helper that assertionFailure()s in debug builds if the system symbol is missing, rather than silently falling back to a blank NSImage(). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Base/TitleBarViewController.swift | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/WWDC/Controllers/Base/TitleBarViewController.swift b/WWDC/Controllers/Base/TitleBarViewController.swift index ba4a30f48..bb806a132 100644 --- a/WWDC/Controllers/Base/TitleBarViewController.swift +++ b/WWDC/Controllers/Base/TitleBarViewController.swift @@ -48,14 +48,21 @@ final class TitleBarViewController: NSTitlebarAccessoryViewController { return v }() + private static func makeToggleSymbol() -> NSImage { + guard let image = NSImage(systemSymbolName: "sidebar.left", accessibilityDescription: "Toggle Sidebar") else { + assertionFailure("Missing system symbol: sidebar.left") + return NSImage() + } + // withSymbolConfiguration returns nil if the configuration can't be applied; + // fall back to the unconfigured symbol rather than a blank image. + return image.withSymbolConfiguration(.init(pointSize: 15, weight: .regular)) ?? image + } + /// Sidebar collapse/expand toggle. Fires `toggleSidebar:` up the responder chain /// so it reaches the active tab's split view controller. Exposed so the coordinator /// can disable it on tabs that have no sidebar (Explore). lazy var sidebarToggleButton: NSButton = { - let symbol = NSImage(systemSymbolName: "sidebar.left", accessibilityDescription: "Toggle Sidebar") - let configured = symbol?.withSymbolConfiguration(.init(pointSize: 15, weight: .regular)) - - let b = NSButton(image: configured ?? NSImage(), target: nil, action: #selector(NSSplitViewController.toggleSidebar(_:))) + let b = NSButton(image: Self.makeToggleSymbol(), target: nil, action: #selector(NSSplitViewController.toggleSidebar(_:))) b.translatesAutoresizingMaskIntoConstraints = false b.isBordered = false b.imagePosition = .imageOnly @@ -134,10 +141,14 @@ final class TitleBarViewController: NSTitlebarAccessoryViewController { centerOffset = localWindowBounds.midX - view.bounds.midX // Tuck the sidebar toggle right up against the rightmost traffic-light (zoom) button. - if let zoomButton = window.standardWindowButton(.zoomButton), let buttonSuperview = zoomButton.superview { + if let constraint = leadingButtonConstraint, + let zoomButton = window.standardWindowButton(.zoomButton), + let buttonSuperview = zoomButton.superview { let frameInWindow = buttonSuperview.convert(zoomButton.frame, to: nil) - let maxXInView = view.convert(frameInWindow, from: nil).maxX - leadingButtonConstraint?.constant = maxXInView + Self.trafficLightGap + let newConstant = view.convert(frameInWindow, from: nil).maxX + Self.trafficLightGap + if abs(constraint.constant - newConstant) > 0.5 { + constraint.constant = newConstant + } } } From d9dfe77240631f1b2cbec435b435609736a65a5c Mon Sep 17 00:00:00 2001 From: ggfevans Date: Tue, 9 Jun 2026 03:14:03 -0700 Subject: [PATCH 4/6] Move sidebar-toggle enable/disable to a dedicated tab observer The isEnabled state of the titlebar sidebar-toggle button was previously computed inside the CombineLatest3 sink whose primary purpose is session- selection routing. This caused the button state to be recomputed on every session selection change, not just on tab switches. Extract it into its own tabController.$activeTabVar subscription so it fires only when the active tab changes, at the correct altitude. Co-Authored-By: Claude Opus 4.8 (1M context) --- WWDC/Bootstrap/AppCoordinator.swift | 13 +++++++++---- WWDC/Controllers/Base/MainWindowController.swift | 3 +++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/WWDC/Bootstrap/AppCoordinator.swift b/WWDC/Bootstrap/AppCoordinator.swift index 1dcd0829f..082abbc11 100644 --- a/WWDC/Bootstrap/AppCoordinator.swift +++ b/WWDC/Bootstrap/AppCoordinator.swift @@ -248,6 +248,15 @@ final class AppCoordinator: Logging, Signposting { videosController.listViewController.$selectedSession.assign(to: &self.$videosSelectedSessionViewModel) scheduleController.splitViewController.listViewController.$selectedSession.assign(to: &self.$scheduleSelectedSessionViewModel) + // Enable the sidebar toggle only on tabs that have a session-list sidebar. + tabController.$activeTabVar + .receive(on: DispatchQueue.main) + .sink { [weak self] activeTab in + guard let self else { return } + self.windowController.titleBarViewController.sidebarToggleButton.isEnabled = activeTab.hasSidebar + } + .store(in: &cancellables) + Publishers.CombineLatest3( tabController.$activeTabVar, $videosSelectedSessionViewModel, @@ -257,10 +266,6 @@ final class AppCoordinator: Logging, Signposting { guard let self else { return } self.activeTab = activeTab - // The sidebar toggle only applies to tabs that have a session-list sidebar. - let hasSidebar = (activeTab == .schedule || activeTab == .videos) - self.windowController.titleBarViewController.sidebarToggleButton.isEnabled = hasSidebar - switch activeTab { case .schedule: activeTabSelectedSessionViewModel = scheduleSelectedSessionViewModel diff --git a/WWDC/Controllers/Base/MainWindowController.swift b/WWDC/Controllers/Base/MainWindowController.swift index 1686042f1..b2d381fbe 100644 --- a/WWDC/Controllers/Base/MainWindowController.swift +++ b/WWDC/Controllers/Base/MainWindowController.swift @@ -23,6 +23,9 @@ enum MainWindowTab: Int, WWDCTab { } var hidesWindowTitleBar: Bool { self == .explore } + + /// Whether this tab shows the collapsible session-list sidebar. + var hasSidebar: Bool { self == .schedule || self == .videos } } extension Notification.Name { From 3468162e0db0632b18f24d934871445b1d9a36d9 Mon Sep 17 00:00:00 2001 From: ggfevans Date: Tue, 9 Jun 2026 03:59:18 -0700 Subject: [PATCH 5/6] Harden sidebar-toggle symbol fallback; rename collapse-sync notification - makeToggleSymbol() no longer returns a blank NSImage on failure: it logs the missing system symbol via OSLog and returns a visible text-glyph fallback so the toggle button always renders. assertionFailure retained for development. - Rename .sidebarCollapseSyncNotification -> .sideBarCollapseSyncNotification to match the existing .sideBarSizeSyncNotification camelCase. Identifier-only rename; the underlying notification string value is unchanged (no behaviour change). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Base/TitleBarViewController.swift | 23 ++++++++++++++++++- .../SessionsSplitViewController.swift | 6 ++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/WWDC/Controllers/Base/TitleBarViewController.swift b/WWDC/Controllers/Base/TitleBarViewController.swift index bb806a132..391ea3c3b 100644 --- a/WWDC/Controllers/Base/TitleBarViewController.swift +++ b/WWDC/Controllers/Base/TitleBarViewController.swift @@ -7,6 +7,7 @@ // import Cocoa +import OSLog final class TitleBarViewController: NSTitlebarAccessoryViewController { @@ -48,16 +49,36 @@ final class TitleBarViewController: NSTitlebarAccessoryViewController { return v }() + private static let log = Logger(subsystem: Bundle.main.bundleIdentifier ?? "io.wwdc.app", category: "TitleBarViewController") + private static func makeToggleSymbol() -> NSImage { guard let image = NSImage(systemSymbolName: "sidebar.left", accessibilityDescription: "Toggle Sidebar") else { assertionFailure("Missing system symbol: sidebar.left") - return NSImage() + log.error("Missing system symbol sidebar.left; falling back to a text glyph for the sidebar toggle") + return fallbackToggleSymbol() } // withSymbolConfiguration returns nil if the configuration can't be applied; // fall back to the unconfigured symbol rather than a blank image. return image.withSymbolConfiguration(.init(pointSize: 15, weight: .regular)) ?? image } + /// Visible last-resort glyph so the toggle button never renders blank if the + /// system symbol is unavailable. + private static func fallbackToggleSymbol() -> NSImage { + let glyph = "◧" as NSString + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 15, weight: .regular), + .foregroundColor: NSColor.labelColor + ] + let glyphSize = glyph.size(withAttributes: attributes) + let image = NSImage(size: NSSize(width: ceil(glyphSize.width), height: ceil(glyphSize.height))) + image.lockFocus() + glyph.draw(at: .zero, withAttributes: attributes) + image.unlockFocus() + image.isTemplate = true + return image + } + /// Sidebar collapse/expand toggle. Fires `toggleSidebar:` up the responder chain /// so it reaches the active tab's split view controller. Exposed so the coordinator /// can disable it on tabs that have no sidebar (Explore). diff --git a/WWDC/Controllers/Sessions/SessionsSplitViewController.swift b/WWDC/Controllers/Sessions/SessionsSplitViewController.swift index 0f0b74fc7..82ceececb 100644 --- a/WWDC/Controllers/Sessions/SessionsSplitViewController.swift +++ b/WWDC/Controllers/Sessions/SessionsSplitViewController.swift @@ -49,7 +49,7 @@ final class SessionsSplitViewController: NSSplitViewController { NotificationCenter .default - .publisher(for: .sidebarCollapseSyncNotification) + .publisher(for: .sideBarCollapseSyncNotification) .sink { [weak self] notification in self?.applyCollapseSync(notification: notification) } @@ -87,7 +87,7 @@ final class SessionsSplitViewController: NSSplitViewController { // Record the shared session state so the other (possibly not-yet-loaded) tab adopts it. self.windowController.sidebarState.collapsed = collapsed NotificationCenter.default.post( - name: .sidebarCollapseSyncNotification, + name: .sideBarCollapseSyncNotification, object: self.splitView, userInfo: ["collapsed": collapsed] ) @@ -181,5 +181,5 @@ final class SessionsSplitViewController: NSSplitViewController { extension Notification.Name { public static let sideBarSizeSyncNotification = NSNotification.Name("WWDCSplitViewSizeSyncNotification") - public static let sidebarCollapseSyncNotification = NSNotification.Name("WWDCSidebarCollapseSyncNotification") + public static let sideBarCollapseSyncNotification = NSNotification.Name("WWDCSidebarCollapseSyncNotification") } From 9c3db9b0bf16eada0f637ca863f2351a31ca35f8 Mon Sep 17 00:00:00 2001 From: ggfevans Date: Tue, 9 Jun 2026 04:01:44 -0700 Subject: [PATCH 6/6] Guard version-dependent subview index in syncSplitView targetSubviewIndex varies by Xcode/macOS (0, 2, or 3). Both the sender and receiver branches indexed subviews[targetSubviewIndex] guarded only by count > 0, which is insufficient for index 2/3 and could trap out-of-range on layouts with fewer subviews. Replace with an indices.contains bounds check and guard the sender-branch read as well. Co-Authored-By: Claude Opus 4.8 (1M context) --- WWDC/Controllers/Sessions/SessionsSplitViewController.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/WWDC/Controllers/Sessions/SessionsSplitViewController.swift b/WWDC/Controllers/Sessions/SessionsSplitViewController.swift index 82ceececb..1d87667de 100644 --- a/WWDC/Controllers/Sessions/SessionsSplitViewController.swift +++ b/WWDC/Controllers/Sessions/SessionsSplitViewController.swift @@ -130,7 +130,7 @@ final class SessionsSplitViewController: NSSplitViewController { guard notificationSourceSplitView !== splitView else { // If own split view is altered, change split view initialisation width for other tabs. // Skip while collapsed so the ~0pt collapsed width is never persisted as the sidebar width. - if !sidebarItem.isCollapsed { + if !sidebarItem.isCollapsed, notificationSourceSplitView.subviews.indices.contains(targetSubviewIndex) { windowController.sidebarState.initialWidth = notificationSourceSplitView.subviews[targetSubviewIndex].bounds.width } return @@ -139,7 +139,8 @@ final class SessionsSplitViewController: NSSplitViewController { // Width-sync is throttled 250 ms so a trailing notification can arrive after // the collapse-sync; calling setPosition here would re-expand the sidebar. guard !sidebarItem.isCollapsed else { return } - guard splitView.subviews.count > 0, notificationSourceSplitView.subviews.count > 0 else { + guard splitView.subviews.indices.contains(targetSubviewIndex), + notificationSourceSplitView.subviews.indices.contains(targetSubviewIndex) else { return } guard splitView.subviews[targetSubviewIndex].bounds.width != notificationSourceSplitView.subviews[targetSubviewIndex].bounds.width else {