diff --git a/apps/calendar-macos/Compass/AppDelegate.swift b/apps/calendar-macos/Compass/AppDelegate.swift index 1ea3968bd..f38be2b93 100644 --- a/apps/calendar-macos/Compass/AppDelegate.swift +++ b/apps/calendar-macos/Compass/AppDelegate.swift @@ -58,8 +58,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { installRootContent(on: window) if UITestLaunchPolicy.isParityCapture { // Parity XCUITest must match web 1280×820; never restore a narrow autosaved frame. - window.setContentSize(NSSize(width: 1280, height: 820)) - window.center() + ParityCaptureWindowPolicy.scheduleReapply(to: window) } else { // Installing the content controller resizes the window, so restore the saved frame afterwards. window.setFrameUsingName(Self.windowFrameName) diff --git a/apps/calendar-macos/Compass/NativeRootController.swift b/apps/calendar-macos/Compass/NativeRootController.swift index 1d433e4c2..a31e214bf 100644 --- a/apps/calendar-macos/Compass/NativeRootController.swift +++ b/apps/calendar-macos/Compass/NativeRootController.swift @@ -90,6 +90,9 @@ final class NativeRootController: NSHostingController { configureNativeServices() configureKeyboard() configureResume() + model.onParityCapturePresentationApplied = { [weak self] in + ParityCaptureWindowPolicy.applyIfNeeded(to: self?.view.window) + } Task { await model.start() deepLinkRouter.markConsumerReady() @@ -105,6 +108,7 @@ final class NativeRootController: NSHostingController { override func viewDidAppear() { super.viewDidAppear() + ParityCaptureWindowPolicy.scheduleReapply(to: view.window) // AppKit hands launch focus to the first key view, the header Previous // button, which then draws a ring and swallows Space and typing. The // grid owns the keyboard through the router, so start with no control diff --git a/apps/calendar-macos/Compass/ParityCaptureWindowPolicy.swift b/apps/calendar-macos/Compass/ParityCaptureWindowPolicy.swift new file mode 100644 index 000000000..4409d5462 --- /dev/null +++ b/apps/calendar-macos/Compass/ParityCaptureWindowPolicy.swift @@ -0,0 +1,41 @@ +import AppKit +import CompassData +import CompassKit + +/// Keeps the main window at web parity dimensions during XCUITest PNG export. +enum ParityCaptureWindowPolicy { + private static let contentSize = NSSize( + width: ParityCaptureGeometry.viewportWidth, + height: ParityCaptureGeometry.viewportHeight + ) + + @MainActor + static func applyIfNeeded(to window: NSWindow?) { + guard UITestLaunchPolicy.isParityCapture, let window else { return } + let visible = window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame + var target = contentSize + if let visible { + target.width = min(target.width, visible.width - 8) + target.height = min(target.height, visible.height - 8) + } + let contentRect = NSRect(origin: .zero, size: target) + let frame = window.frameRect(forContentRect: contentRect) + if abs(window.frame.width - frame.width) > 2 || abs(window.frame.height - frame.height) > 2 { + var origin = frame.origin + if let visible { + origin.x = visible.midX - frame.width / 2 + origin.y = visible.midY - frame.height / 2 + } + window.setFrame(NSRect(origin: origin, size: frame.size), display: true) + } + } + + @MainActor + static func scheduleReapply(to window: NSWindow?) { + guard UITestLaunchPolicy.isParityCapture, window != nil else { return } + applyIfNeeded(to: window) + DispatchQueue.main.async { + applyIfNeeded(to: window) + } + } +} diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift index 21460f7e9..de0be24b7 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift @@ -158,8 +158,12 @@ public final class NativeCalendarRootModel { self.demoPresentation = demoPresentation self.now = now self.overlayStores = overlayStores - let pinnedTimeZone = demoPresentation?.timeZone ?? CompassDevicePreferences.readPinnedTimeZone() - EffectiveTimeZone.identifier = pinnedTimeZone ?? TimeZone.current.identifier + // Demo matches web parity: effective UTC via browser timezone, not a pinned override. + let demoTimeZone = demoPresentation?.timeZone + let pinnedTimeZone = + demoPresentation == nil ? CompassDevicePreferences.readPinnedTimeZone() : nil + EffectiveTimeZone.identifier = + demoTimeZone ?? pinnedTimeZone ?? TimeZone.current.identifier eventsStore = environment.eventsStore hiddenEventsStore = environment.hiddenEventsStore calendarVisibilityStore = CalendarVisibilityStore() @@ -180,7 +184,8 @@ public final class NativeCalendarRootModel { view: .week, anchorDate: anchor, visibleDayCount: CalendarWindowMath.weekDayCount, - pinnedTimeZone: pinnedTimeZone + pinnedTimeZone: pinnedTimeZone, + browserTimeZone: demoTimeZone ?? TimeZone.current.identifier ) if demoPresentation == nil { viewStore.setTimeTravelTimeZone(CompassDevicePreferences.readTimeTravelTimeZone()) @@ -219,7 +224,11 @@ public final class NativeCalendarRootModel { overlayStores.legend.onOpenChanged = { [weak self] open in self?.onShortcutsLegendAccessibilityProbeChanged?(open) } - rebuildPresentation() + if UITestLaunchPolicy.isParityCapture { + applyParityCapturePresentationIfNeeded() + } else { + rebuildPresentation() + } } public func closeSettingsIfAllowed() { @@ -377,6 +386,7 @@ public final class NativeCalendarRootModel { pendingScroll = .scrollToNowOrPulse rebuildPresentation() scheduleRefreshVisibleRange() + onParityCapturePresentationApplied?() } public func dismissDemoEventsBanner() { @@ -397,6 +407,7 @@ public final class NativeCalendarRootModel { await syncConnectionsStore.reloadFromMetadata() } + public var onParityCapturePresentationApplied: (() -> Void)? public var onShowAbout: (() -> Void)? public var onExportLocalData: ((Data, String) async throws -> Bool)? public internal(set) var isExportingLocalData = false @@ -917,17 +928,32 @@ public final class NativeCalendarRootModel { private func goToToday() { let today = referenceNow let calendar = EffectiveTimeZone.calendar + var anchorChanged = false if viewStore.visibleDayCount == 1 { - viewStore.setAnchorDate(today) - } else { + if !calendar.isDate(viewStore.anchorDate, inSameDayAs: today) { + viewStore.setAnchorDate(today) + anchorChanged = true + } + } else if !isReferenceDayInVisibleWindow(today) { viewStore.setAnchorDate(CalendarWindowMath.sundayWeekViewStart(containing: today)) + anchorChanged = true } monthPickerMonth = today - rebuildPresentation() - scheduleRefreshVisibleRange() + if anchorChanged { + rebuildPresentation() + scheduleRefreshVisibleRange() + } + // Web `goToTodayInWeek` always runs scroll-to-now / now-cue even when the anchor is unchanged. pendingScroll = .scrollToNowOrPulse } + /// Matches web `useWeek` `isCurrentWeek` before `goToToday` moves the anchor. + private func isReferenceDayInVisibleWindow(_ day: Date) -> Bool { + let calendar = EffectiveTimeZone.calendar + let dayStart = calendar.startOfDay(for: day) + return dayStart >= startOfView && dayStart <= endOfView + } + private func shiftMonth(by months: Int) { let calendar = EffectiveTimeZone.calendar if let next = calendar.date(byAdding: .month, value: months, to: monthPickerMonth) { diff --git a/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelParityPresentationTests.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelParityPresentationTests.swift new file mode 100644 index 000000000..68325a415 --- /dev/null +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelParityPresentationTests.swift @@ -0,0 +1,59 @@ +import CompassData +import CompassKit +import XCTest + +@MainActor +final class NativeCalendarRootModelParityPresentationTests: XCTestCase { + private var priorParityEnv: String? + + override func setUp() { + super.setUp() + priorParityEnv = ProcessInfo.processInfo.environment["PARITY_CAPTURE"] + setenv("PARITY_CAPTURE", "1", 1) + } + + override func tearDown() { + if let priorParityEnv { + setenv("PARITY_CAPTURE", priorParityEnv, 1) + } else { + unsetenv("PARITY_CAPTURE") + } + super.tearDown() + } + + func testParityCaptureUsesDemoSundayWeekAndFiveColumns() async throws { + let fixture = try DemoSeedFixture.load() + let environment = try NativeCalendarEnvironment(inMemoryDatabase: true) + let model = NativeCalendarRootModel(environment: environment, demoPresentation: fixture) + await model.start() + + XCTAssertEqual( + CompassDateParsing.formatCalendarDay(model.viewStore.anchorDate), + "2026-06-07" + ) + XCTAssertEqual(model.viewStore.visibleDayCount, ParityCaptureGeometry.visibleDayCount) + XCTAssertEqual( + Double(model.contentTrackWidth), + ParityCaptureGeometry.contentTrackWidth, + accuracy: 0.5 + ) + XCTAssertEqual( + model.timeGridState.scenario.visibleDateKeys.first, + "2026-06-07" + ) + XCTAssertEqual(model.timeGridState.scenario.visibleDateKeys.count, 5) + } + + func testGoToTodayDoesNotShiftAnchorWhenDemoDayAlreadyVisible() async throws { + let fixture = try DemoSeedFixture.load() + let environment = try NativeCalendarEnvironment(inMemoryDatabase: true) + let model = NativeCalendarRootModel(environment: environment, demoPresentation: fixture) + await model.start() + let anchorBefore = model.viewStore.anchorDate + + model.handleShortcut(.navToday) + + XCTAssertEqual(model.viewStore.anchorDate, anchorBefore) + XCTAssertEqual(model.consumePendingScroll(), .scrollToNowOrPulse) + } +} diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Layout/ParityCaptureGeometry.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Layout/ParityCaptureGeometry.swift index 1b8253ae7..6404513b2 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Layout/ParityCaptureGeometry.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Layout/ParityCaptureGeometry.swift @@ -5,6 +5,7 @@ import Foundation /// boot track math (1280×820, sidebar open @ 345px, content pl-8). public enum ParityCaptureGeometry { public static let viewportWidth: Double = 1280 + public static let viewportHeight: Double = 820 public static let contentLeadingInset: Double = 32 public static let sidebarDividerWidth: Double = 32 public static let defaultSidebarWidth: Double = 345 diff --git a/apps/calendar-macos/CompassKitTests/ParityCaptureGeometryTests.swift b/apps/calendar-macos/CompassKitTests/ParityCaptureGeometryTests.swift index f346efec5..78f7caa52 100644 --- a/apps/calendar-macos/CompassKitTests/ParityCaptureGeometryTests.swift +++ b/apps/calendar-macos/CompassKitTests/ParityCaptureGeometryTests.swift @@ -4,6 +4,8 @@ import XCTest final class ParityCaptureGeometryTests: XCTestCase { func testContentTrackWidthMatchesWebBootShell() { // index.html: track = innerWidth - 32 - (32 + sidebarWidth) + XCTAssertEqual(ParityCaptureGeometry.viewportHeight, 820) + XCTAssertEqual(ParityCaptureGeometry.viewportHeight, 820) XCTAssertEqual(ParityCaptureGeometry.contentTrackWidth, 871, accuracy: 0.1) XCTAssertEqual(ParityCaptureGeometry.visibleDayCount, 5) } diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/CommandPalette/CommandPaletteOverlay.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/CommandPalette/CommandPaletteOverlay.swift index 520870b3c..e60a717b4 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/CommandPalette/CommandPaletteOverlay.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/CommandPalette/CommandPaletteOverlay.swift @@ -164,7 +164,7 @@ public struct CommandPaletteOverlay: View { Text("Navigate") .font(.rubik(11)) .foregroundStyle(theme.textMutedColor) - footerKeycap("↩") + footerKeycap("Enter") Text("Select") .font(.rubik(11)) .foregroundStyle(theme.textMutedColor) diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/Onboarding/WelcomeModalView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/Onboarding/WelcomeModalView.swift index 8cc198808..4bcbaa70b 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/Onboarding/WelcomeModalView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/Onboarding/WelcomeModalView.swift @@ -32,7 +32,7 @@ struct WelcomeModalView: View { } private var panel: some View { - VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 20) { headerRow stepContent stepDots @@ -40,7 +40,7 @@ struct WelcomeModalView: View { WelcomeLinksView() } } - .padding(32) + .padding(28) .frame(width: 480) .background(theme.surfacePanelColor) .clipShape(RoundedRectangle(cornerRadius: 12)) diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/Settings/SettingsUnsignedAccountsSectionView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/Settings/SettingsUnsignedAccountsSectionView.swift new file mode 100644 index 000000000..7de064e58 --- /dev/null +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/Settings/SettingsUnsignedAccountsSectionView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// Web `AccountsSection` empty state for signed-out settings. +struct SettingsUnsignedAccountsSectionView: View { + @Environment(\.nativeWebTheme) private var theme + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Accounts") + .font(.rubik(15)) + .foregroundStyle(theme.textColor) + Text("No accounts connected yet.") + .font(.rubik(13)) + .foregroundStyle(theme.textMutedColor) + Text( + "Pick Google, Microsoft, or Apple. Or keep using this device only from the calendar list." + ) + .font(.rubik(13)) + .foregroundStyle(theme.textMutedColor) + } + .accessibilityIdentifier("settings-section-accounts") + } +} diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/Settings/SettingsWindowOverlay.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/Settings/SettingsWindowOverlay.swift index 076a48711..8a17d9902 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/Settings/SettingsWindowOverlay.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/Settings/SettingsWindowOverlay.swift @@ -90,7 +90,7 @@ public struct SettingsWindowOverlay: View { private var content: some View { VStack(alignment: .leading, spacing: 0) { ScrollView { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 12) { if model.settingsStore.page == .billing { billingContent } else if model.settingsStore.page == .booking { @@ -99,12 +99,12 @@ public struct SettingsWindowOverlay: View { accountsContent } } - .padding(20) + .padding(16) } if model.settingsStore.page == .accounts { Divider().overlay(theme.borderColor) SettingsDataActionsSectionView(model: model) - .padding(20) + .padding(16) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -119,11 +119,13 @@ public struct SettingsWindowOverlay: View { settingsStore: model.settingsStore, calendars: model.calendars, hasConnectedAccount: !model.syncConnectionsStore.connections.isEmpty) - SettingsThemeSectionView(settingsStore: model.settingsStore) - SettingsLaunchAtLoginSectionView(settingsStore: model.settingsStore) - SettingsQuickAddHotkeySectionView(settingsStore: model.settingsStore) if model.isSignedIn { + SettingsThemeSectionView(settingsStore: model.settingsStore) + SettingsLaunchAtLoginSectionView(settingsStore: model.settingsStore) + SettingsQuickAddHotkeySectionView(settingsStore: model.settingsStore) SettingsAccountsSectionView(syncStore: model.syncConnectionsStore) + } else { + SettingsUnsignedAccountsSectionView() } } } diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/TimeGrid/TimeGridView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/TimeGrid/TimeGridView.swift index 12ab7ee36..5d825911c 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/TimeGrid/TimeGridView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/TimeGrid/TimeGridView.swift @@ -146,6 +146,7 @@ public final class TimeGridView: NSView { } private func scrollToNowOrPulse() { + clampWeekHorizontalScrollOrigin() guard let snapshot, let nowLine = snapshot.nowLine else { return } // The scroll document is the timed grid alone; the header is pinned above it. let targetY = clampedScrollY(GridScrollMath.scrollToNowDocumentY( @@ -159,6 +160,8 @@ public final class TimeGridView: NSView { origin.y = targetY scrollView.contentView.scroll(to: origin) scrollView.reflectScrolledClipView(scrollView.contentView) + clampWeekHorizontalScrollOrigin() + syncPinnedHeaderScrollX() if nowLineVisible { pulseNowCue() } } @@ -259,13 +262,28 @@ public final class TimeGridView: NSView { /// Day view scrolls horizontally; the pinned header follows the clip view's x. @objc private func clipViewBoundsDidChange(_ notification: Notification) { + clampWeekHorizontalScrollOrigin() syncPinnedHeaderScrollX() } private func syncPinnedHeaderScrollX() { + guard state.layoutMode == .day else { + pinnedHeaderView.setBoundsOrigin(.zero) + return + } pinnedHeaderView.setBoundsOrigin(NSPoint(x: scrollView.contentView.bounds.origin.x, y: 0)) } + /// Week view never scrolls horizontally; a non-zero x clips the gutter and first column. + private func clampWeekHorizontalScrollOrigin() { + guard state.layoutMode == .week else { return } + var origin = scrollView.contentView.bounds.origin + guard origin.x != 0 else { return } + origin.x = 0 + scrollView.contentView.scroll(to: origin) + scrollView.reflectScrolledClipView(scrollView.contentView) + } + private func relayout() { let colWidths = state.resolvedColumnWidths() let snapshot = state.snapshot(colWidths: colWidths) @@ -305,6 +323,8 @@ public final class TimeGridView: NSView { renderTimedGrid(snapshot: snapshot, hourHeight: hourHeight, gridHeight: gridHeight) renderCards(snapshot: snapshot) updateNowLine() + clampWeekHorizontalScrollOrigin() + syncPinnedHeaderScrollX() } private func renderHourGutter( @@ -693,7 +713,8 @@ public final class TimeGridView: NSView { width: state.hasSecondaryTimeZone ? columnWidth : snapshot.metrics.marginLeft, headerHeight: headerHeight, palette: palette, - accessibilityLabel: "Calendar timezone: \(effectiveAbbrev)") + accessibilityLabel: "Calendar timezone: \(effectiveAbbrev)", + accessibilityIdentifier: "compass-grid-timezone-effective") } private func addTimezoneButton( @@ -702,7 +723,8 @@ public final class TimeGridView: NSView { width: Double, headerHeight: Double, palette: (background: NSColor, surface: NSColor, surfaceRaised: NSColor, border: NSColor, text: NSColor, textMuted: NSColor), - accessibilityLabel: String + accessibilityLabel: String, + accessibilityIdentifier: String? = nil ) { let button = NSButton(title: title, target: self, action: #selector(openTimeTravel(_:))) button.isBordered = false @@ -710,6 +732,9 @@ public final class TimeGridView: NSView { button.contentTintColor = palette.textMuted button.frame = NSRect(x: x, y: 4, width: width, height: headerHeight - 8) button.setAccessibilityLabel(accessibilityLabel) + if let accessibilityIdentifier { + button.setAccessibilityIdentifier(accessibilityIdentifier) + } headerRowView.addSubview(button) } diff --git a/apps/calendar-macos/CompassUITests/NativeParityCaptureTests.swift b/apps/calendar-macos/CompassUITests/NativeParityCaptureTests.swift index 0075506e9..2f1d58fdb 100644 --- a/apps/calendar-macos/CompassUITests/NativeParityCaptureTests.swift +++ b/apps/calendar-macos/CompassUITests/NativeParityCaptureTests.swift @@ -113,7 +113,23 @@ final class NativeParityCaptureTests: XCTestCase { XCTAssertTrue( waitForGridColumnCount(window, atLeast: 5, timeout: 20), "Expected five day columns in parity week grid") + let timedGrid = window.descendants(matching: .any)["compass-grid-timed"] + if timedGrid.waitForExistence(timeout: 5) { + timedGrid.click() + } window.typeKey("t", modifierFlags: []) + let sundayColumn = window.descendants(matching: .any)["compass-grid-column-2026-06-07"] + XCTAssertTrue( + sundayColumn.waitForExistence(timeout: 20), + "Expected Sunday demo week column (2026-06-07) after nav today") + XCTAssertTrue(sundayColumn.isHittable, "Expected Sunday column visible, not scrolled off-screen") + let timezoneHeader = window.descendants(matching: .any)["compass-grid-timezone-effective"] + XCTAssertTrue( + timezoneHeader.waitForExistence(timeout: 10), + "Expected UTC timezone header on parity week grid") + XCTAssertTrue( + timezoneHeader.label.contains("UTC"), + "Expected effective timezone label to include UTC, got: \(timezoneHeader.label)") XCTAssertTrue( window.buttons["Morning standup"].waitForExistence(timeout: 20), "Expected demo-seed timed event on the week grid")