diff --git a/.github/scripts/compose-desktop-parity-shots.py b/.github/scripts/compose-desktop-parity-shots.py index e52adb502..e53b59e92 100644 --- a/.github/scripts/compose-desktop-parity-shots.py +++ b/.github/scripts/compose-desktop-parity-shots.py @@ -14,8 +14,14 @@ SURFACES = ( "week-grid", + "week-grid-allday", + "demo-events-banner", + "mod-hold-overlay", + "up-next-banner", "mini-calendar", "event-form", + "event-form-date-picker", + "event-form-time-picker", "command-palette", "settings", "onboarding-welcome", diff --git a/.github/scripts/record-macos-parity-captures.sh b/.github/scripts/record-macos-parity-captures.sh index 18656ec07..a3cb8e145 100755 --- a/.github/scripts/record-macos-parity-captures.sh +++ b/.github/scripts/record-macos-parity-captures.sh @@ -5,7 +5,20 @@ set -euo pipefail ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) OUT="${PARITY_OUTPUT_DIR:-$ROOT/build/desktop-parity/macos}" RESULT="$ROOT/apps/calendar-macos/build/ParityCapture.xcresult" -SURFACES=(week-grid mini-calendar event-form command-palette settings onboarding-welcome) +SURFACES=( + week-grid + week-grid-allday + demo-events-banner + mod-hold-overlay + up-next-banner + mini-calendar + event-form + event-form-date-picker + event-form-time-picker + command-palette + settings + onboarding-welcome +) mkdir -p "$OUT" cd "$ROOT/apps/calendar-macos" 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/NativeGridKeyboardRouter.swift b/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift index 1164c8961..c79dedc4a 100644 --- a/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift +++ b/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift @@ -9,7 +9,6 @@ final class NativeGridKeyboardRouter { private let model: NativeCalendarRootModel private let modHold: ModHoldDetector private let formModHold: ModHoldDetector - private let eventJumpHold: HoldModifierDetector private let viewSwitchIds: Set = [.navDayView, .navWeekView, .navLifeView] init(model: NativeCalendarRootModel, registry: ShortcutRegistry) { @@ -25,12 +24,6 @@ final class NativeGridKeyboardRouter { model?.jumpEventFormField(digit: digit) } } - eventJumpHold = HoldModifierDetectorFactory.eventJumpHold { [weak model] digit in - Task { @MainActor in - model?.focusEventJump(digit: digit) - } - } - let focusIds: Set = [ .editFocusPrev, .editFocusNext, @@ -119,6 +112,7 @@ final class NativeGridKeyboardRouter { func handleFlagsChanged(_ event: NSEvent) { let commandDown = event.modifierFlags.contains(.command) if model.settingsStore.isPresented || model.authStore.isModalPresented || model.pendingLogoutConfirmation + || model.pendingDeleteAccountConfirmation || model.invitationPrompt != nil || model.pendingRsvpChoice != nil { modHold.handleFlagsChanged(modifierDown: false) formModHold.handleFlagsChanged(modifierDown: false) @@ -347,26 +341,10 @@ final class NativeGridKeyboardRouter { return false } - if case .character(let char) = keyEvent.key, char == "f", keyEvent.modifiers.isEmpty { - eventJumpHold.handleModifierDown(isRepeat: keyEvent.isRepeat) - if !keyEvent.isRepeat { - Task { @MainActor [weak self] in - try? await Task.sleep(for: .milliseconds(280)) - self?.syncEventJumpHints() - } - } - } - - if eventJumpHold.handleKeyDown(keyEvent) { - syncEventJumpHints() - return true - } if modHold.handleKeyDown(keyEvent) { return true } - syncEventJumpHints() - if keyEvent.modifiers == .shift, case .character(let letter) = keyEvent.key { model.handleShiftDayJump(letter: letter) return true @@ -534,6 +512,9 @@ final class NativeGridKeyboardRouter { } if keyEvent.modifiers.isEmpty, case .character(let digit) = keyEvent.key, digit.isNumber { + if model.eventJumpSelection.modeActive, model.focusEventJumpIfAvailable(digit: digit) { + return true + } model.handleQuickTimeDigit(digit) if !model.draftStore.quickTimeDigits.isEmpty { return true @@ -798,17 +779,6 @@ final class NativeGridKeyboardRouter { return false } - func handleKeyUp(_ event: NSEvent) { - if event.keyCode == 4 { - eventJumpHold.handleModifierUp() - model.setEventJumpHintsVisible(false) - } - } - - private func syncEventJumpHints() { - model.setEventJumpHintsVisible(eventJumpHold.phase == .hintsVisible) - } - private func performUndo() { if model.draftStore.gridDraft != nil, model.draftStore.status.activity == .keyboardPlace 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..8f72308c4 --- /dev/null +++ b/apps/calendar-macos/Compass/ParityCaptureWindowPolicy.swift @@ -0,0 +1,39 @@ +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 } + // Always use the canonical parity viewport so five day columns are not + // clipped when the host display is smaller than 1280×820. + let target = contentSize + let visible = window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame + 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/DemoSeedFixture.swift b/apps/calendar-macos/CompassData/Sources/CompassData/DemoSeedFixture.swift index 3edc4fe5a..4265787f8 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/DemoSeedFixture.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/DemoSeedFixture.swift @@ -152,7 +152,7 @@ public struct DemoSeedFixture: Sendable { isActive: true, isPrimary: true, isVisible: true, - name: "Work", + name: "Compass", provider: "local", timeZone: IANATimeZone(rawValue: timeZone) ) diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+AccountActions.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+AccountActions.swift index 800aed630..1da0bd9a5 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+AccountActions.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+AccountActions.swift @@ -48,4 +48,33 @@ extension NativeCalendarRootModel { logoutError = "Could not log out. Try again." } } + + public func requestDeleteAccount() { + guard !isDeletingAccount else { return } + deleteAccountError = nil + pendingDeleteAccountConfirmation = true + } + + public func cancelDeleteAccount() { + guard !isDeletingAccount else { return } + pendingDeleteAccountConfirmation = false + } + + public func confirmDeleteAccount() async { + guard pendingDeleteAccountConfirmation, !isDeletingAccount else { return } + deleteAccountError = nil + isDeletingAccount = true + defer { isDeletingAccount = false } + guard isSignedIn else { + deleteAccountError = "Sign in to delete your Compass account." + return + } + do { + try await UserAPI(client: environment.apiClient).deleteAccount() + pendingDeleteAccountConfirmation = false + try await signOut() + } catch { + deleteAccountError = "Could not delete your account. Try again." + } + } } diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+Draft.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+Draft.swift index 0288ac7e0..4395ff13f 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+Draft.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+Draft.swift @@ -118,6 +118,7 @@ extension NativeCalendarRootModel { public func handleQuickTimeDigit(_ digit: Character) { guard viewStore.view != .life, draftStore.gridDraft == nil else { return } + guard eventJumpSelection.modeActive || eventJumpSelection.isActive else { return } guard digit.isNumber else { return } let next = draftStore.quickTimeDigits + String(digit) @@ -150,6 +151,7 @@ extension NativeCalendarRootModel { calendarId: defaultTargetCalendarId() ) draftStore.startGridDraft(activity: .keyboardPlace, draft: draft) + clearEventJumpSelection() rebuildPresentation() focusDraftCard() } diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift index 195b794ff..8f8bea79a 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift @@ -6,6 +6,13 @@ extension NativeCalendarRootModel { public var eventJumpStatusText: String? { guard eventJumpSelection.isActive else { return nil } + if eventJumpSelection.modeActive, + eventJumpSelection.activeDayKeys.isEmpty, + eventJumpSelection.dayJumpBuffer.isEmpty, + eventJumpSelection.announcement == nil + { + return "Event jump on · Esc" + } if eventJumpSelection.dayJumpBuffer == "s", eventJumpSelection.activeDayKeys.isEmpty { return "Shift+U Sunday · Shift+A Saturday · Esc" } @@ -18,8 +25,33 @@ extension NativeCalendarRootModel { return "Event jump · Esc" } + public func toggleEventJumpMode() { + if eventJumpSelection.modeActive { + clearEventJumpSelection() + draftStore.setQuickTimeDigits("") + return + } + eventJumpSelection.setModeActive(true) + recordShortcut(.focusShiftHold) + refreshEventJumpPresentation() + } + + func refreshEventJumpPresentation() { + if eventJumpSelection.modeActive { + eventJumpHintLabels = buildEventJumpHints() + quickTimeSlotHintLabels = buildQuickTimeSlotHints() + } else { + eventJumpHintLabels = [] + quickTimeSlotHintLabels = [] + } + applyFocusPresentation() + } + public func handleShiftDayJump(letter: Character) { guard viewStore.view == .week || viewStore.view == .day else { return } + if !eventJumpSelection.modeActive { + eventJumpSelection.setModeActive(true) + } let lower = String(letter).lowercased() if eventJumpSelection.dayJumpBuffer == "s" { @@ -36,7 +68,7 @@ extension NativeCalendarRootModel { if lower == "s" { eventJumpSelection.setWeekendDisambiguation(announcement: "Weekend") recordShortcut(.focusWeekDay) - applyFocusPresentation() + refreshEventJumpPresentation() return } @@ -62,16 +94,63 @@ extension NativeCalendarRootModel { }) { focusEvent(eventId: first.id.rawValue) } - applyFocusPresentation() + refreshEventJumpPresentation() return } } public func clearEventJumpSelection() { eventJumpSelection.clear() + eventJumpHintLabels = [] + quickTimeSlotHintLabels = [] applyFocusPresentation() } + func buildQuickTimeSlotHints() -> [QuickTimeSlotHint] { + guard eventJumpSelection.modeActive else { return [] } + let focusedDay = QuickTime.quickTimeFocusedColumnDay( + activeDayKeys: eventJumpSelection.activeDayKeys) + let targetDay = QuickTime.quickTimeTargetDay( + startOfView: startOfView, + endOfView: endOfView, + now: referenceNow, + focusedDay: focusedDay + ) + let timedStarts: [Date] = loadedEvents.compactMap { event in + guard case .timed(let timed) = event.schedule else { return nil } + return CompassDateParsing.parseInEffectiveTimeZone(timed.start.rawValue) + } + let timedEnds: [Date] = loadedEvents.compactMap { event in + guard case .timed(let timed) = event.schedule else { return nil } + return CompassDateParsing.parseInEffectiveTimeZone(timed.end.rawValue) + } + let busy = QuickTime.timedEventsToBusyIntervals(startDates: timedStarts, endDates: timedEnds) + let slots = QuickTime.buildQuickTimeSlots(busy: busy, targetDay: targetDay) + let visibleDates = GridVisibleDate.fromKeys(timeGridState.scenario.visibleDateKeys) + let colWidths = timeGridState.resolvedColumnWidths() + let snapshotMetrics = timeGridState.snapshot(colWidths: colWidths).metrics + let gridMetrics = GridMetrics( + colWidths: snapshotMetrics.colWidths, + hourHeight: snapshotMetrics.hourHeight + ) + let columnIndex: Int? = { + let key = CompassDateParsing.formatCalendarDay(targetDay) + return visibleDates.firstIndex(where: { $0.key == key }) + }() + return slots.compactMap { slot in + let frame = EventPositionCalculator.getBusyPeriodPosition( + segmentStart: slot.start, + segmentEnd: slot.end, + measurements: gridMetrics, + visibleDates: visibleDates, + columnIndex: columnIndex, + hasSecondaryTimeZone: timeGridState.hasSecondaryTimeZone + ) + guard frame.width > 0, frame.height > 0 else { return nil } + return QuickTimeSlotHint(sequence: slot.sequence, frame: frame) + } + } + private func eventJumpDayKey(for event: Event) -> String { switch event.schedule { case .timed(let timed): diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift index 21460f7e9..815d5e136 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift @@ -114,7 +114,8 @@ public final class NativeCalendarRootModel { } private var refreshTask: Task? private var focusLayoutCards: [FocusLayoutCard] = [] - private var eventJumpHintLabels: [EventJumpChipHint] = [] + var eventJumpHintLabels: [EventJumpChipHint] = [] + var quickTimeSlotHintLabels: [QuickTimeSlotHint] = [] public var focusedDayColumnCalendarId: String? public var pendingFocusDayColumnCalendarId: String? private var didApplyDemoFixtureScroll = false @@ -158,8 +159,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 +185,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 +225,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() { @@ -358,6 +368,9 @@ public final class NativeCalendarRootModel { await refreshVisibleRange() await refreshSideband() applyParityCapturePresentationIfNeeded() + if UITestLaunchPolicy.showPageJumpHintsForUITest { + setPageJumpHintsVisible(true) + } if isSignedIn { startEventStream() } @@ -377,6 +390,7 @@ public final class NativeCalendarRootModel { pendingScroll = .scrollToNowOrPulse rebuildPresentation() scheduleRefreshVisibleRange() + onParityCapturePresentationApplied?() } public func dismissDemoEventsBanner() { @@ -397,10 +411,14 @@ 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 public internal(set) var pendingLogoutConfirmation = false + public internal(set) var pendingDeleteAccountConfirmation = false + public internal(set) var isDeletingAccount = false + public internal(set) var deleteAccountError: String? public internal(set) var isLoggingOut = false public internal(set) var logoutError: String? @@ -499,6 +517,13 @@ public final class NativeCalendarRootModel { public func updateContentTrackWidth(_ width: CGFloat) { if UITestLaunchPolicy.isParityCapture { applyParityCapturePresentationIfNeeded() + let resolved = max(width, CGFloat(ParityCaptureGeometry.contentTrackWidth)) + if abs(resolved - contentTrackWidth) > 0.5 { + contentTrackWidth = resolved + viewStore.setVisibleDayCount(ParityCaptureGeometry.visibleDayCount) + rebuildPresentation() + scheduleRefreshVisibleRange() + } return } let resolved = max(UITestLaunchPolicy.pinnedWeekGridTrackWidth ?? width, 320) @@ -612,6 +637,8 @@ public final class NativeCalendarRootModel { focusFirstCalendarEvent() case .focusNotice: focusNextNotice() + case .focusShiftHold: + toggleEventJumpMode() case .otherSubscribe: openUpgradeConfirmation() case .navGoToDate: @@ -639,7 +666,7 @@ public final class NativeCalendarRootModel { public func setEventJumpHintsVisible(_ visible: Bool) { if visible { eventJumpHintLabels = buildEventJumpHints() - } else { + } else if !eventJumpSelection.modeActive { eventJumpHintLabels = [] } applyFocusPresentation() @@ -720,6 +747,15 @@ public final class NativeCalendarRootModel { focusEvent(eventId: hint.eventId) } + @discardableResult + public func focusEventJumpIfAvailable(digit: Character) -> Bool { + guard let hint = eventJumpHintLabels.first(where: { $0.label == String(digit) }) else { + return false + } + focusEvent(eventId: hint.eventId) + return true + } + public func consumePendingScroll() -> TimeGridScrollRequest? { defer { pendingScroll = nil } return pendingScroll @@ -850,6 +886,7 @@ public final class NativeCalendarRootModel { focusedEventId: focusStore.focusedEventId?.rawValue, sidebarEditingEventId: sidebarEditingGridEventId(), eventJumpHints: eventJumpHintLabels, + quickTimeSlotHints: quickTimeSlotHintLabels, focusedDayColumnCalendarId: focusedDayColumnCalendarId, pageJumpHintsVisible: focusStore.pageJumpHintsVisible, pageJumpDigitByCalendarId: pageJumpDigitByCalendarId(), @@ -917,17 +954,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) { @@ -998,6 +1050,7 @@ public final class NativeCalendarRootModel { focusedEventId: focusStore.focusedEventId?.rawValue, sidebarEditingEventId: sidebarEditingGridEventId(), eventJumpHints: eventJumpHintLabels, + quickTimeSlotHints: quickTimeSlotHintLabels, focusedDayColumnCalendarId: focusedDayColumnCalendarId, pageJumpHintsVisible: focusStore.pageJumpHintsVisible, pageJumpDigitByCalendarId: pageJumpDigitByCalendarId(), @@ -1005,6 +1058,10 @@ public final class NativeCalendarRootModel { effectiveTimeZone: viewStore.effectiveTimeZone, timeTravelTimeZone: viewStore.timeTravelTimeZone ) + if eventJumpSelection.modeActive { + quickTimeSlotHintLabels = buildQuickTimeSlotHints() + timeGridState.quickTimeSlotHints = quickTimeSlotHintLabels + } let snapshot = timeGridState.snapshot(colWidths: timeGridState.resolvedColumnWidths()) syncFocusRegistry(from: snapshot.cards) focusLayoutCards = snapshot.cards.map { card in @@ -1104,7 +1161,7 @@ public final class NativeCalendarRootModel { ) } - private func buildEventJumpHints() -> [EventJumpChipHint] { + func buildEventJumpHints() -> [EventJumpChipHint] { let digits = ["1", "2", "3", "4", "5", "6", "7", "8", "9"] let timed = focusLayoutCards.filter { !$0.isAllDay }.sorted { $0.frame.top < $1.frame.top } return timed.prefix(digits.count).enumerated().map { index, card in diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/Stores/EventJumpSelectionStore.swift b/apps/calendar-macos/CompassData/Sources/CompassData/Stores/EventJumpSelectionStore.swift index f98cfe027..c50f39da8 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/Stores/EventJumpSelectionStore.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/Stores/EventJumpSelectionStore.swift @@ -3,13 +3,15 @@ import Foundation @MainActor @Observable public final class EventJumpSelectionStore { + /// Web `event-jump.store` `isActive`: bare `f` toggles jump mode for quick-time slots. + public private(set) var modeActive: Bool = false public private(set) var activeDayKeys: [String] = [] public private(set) var announcement: String? /// Typed day-prefix buffer while resolving weekend Shift+S → U/A (web `matchDayJumpKeystroke`). public private(set) var dayJumpBuffer: String = "" public var isActive: Bool { - !activeDayKeys.isEmpty || announcement != nil || !dayJumpBuffer.isEmpty + modeActive || !activeDayKeys.isEmpty || announcement != nil || !dayJumpBuffer.isEmpty } public func selectDay(key: String, announcement: String, buffer: String) { @@ -24,9 +26,16 @@ public final class EventJumpSelectionStore { dayJumpBuffer = "s" } + public func setModeActive(_ active: Bool) { + modeActive = active + if !active { + activeDayKeys = [] + announcement = nil + dayJumpBuffer = "" + } + } + public func clear() { - activeDayKeys = [] - announcement = nil - dayJumpBuffer = "" + setModeActive(false) } } diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/UITestLaunchPolicy.swift b/apps/calendar-macos/CompassData/Sources/CompassData/UITestLaunchPolicy.swift index 36a64d4b5..f67fb24a8 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/UITestLaunchPolicy.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/UITestLaunchPolicy.swift @@ -55,6 +55,16 @@ public enum UITestLaunchPolicy { arguments.contains("-COMPASS_UI_TEST_OPEN_FOCUSED_EVENT_FORM") } + /// Pins page-jump chips visible for mod-hold parity PNGs (no key hold in XCUITest). + public static var showPageJumpHintsForUITest: Bool { + ProcessInfo.processInfo.arguments.contains("-COMPASS_UI_TEST_SHOW_PAGE_JUMP_HINTS") + } + + /// Opens the start-date popover when the event form appears (parity PNG capture). + public static var openEventFormDatePickerForUITest: Bool { + ProcessInfo.processInfo.arguments.contains("-COMPASS_UI_TEST_OPEN_EVENT_FORM_DATE_PICKER") + } + /// Desktop ↔ web screenshot capture (`NativeParityCaptureTests`). public static var isParityCapture: Bool { ProcessInfo.processInfo.environment["PARITY_CAPTURE"] == "1" diff --git a/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelEventJumpTests.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelEventJumpTests.swift index d8702dd40..a838d2efa 100644 --- a/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelEventJumpTests.swift +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelEventJumpTests.swift @@ -4,6 +4,19 @@ import XCTest @MainActor final class NativeCalendarRootModelEventJumpTests: XCTestCase { + func testToggleEventJumpModeShowsQuickTimeSlots() async throws { + let model = try await NativeCalendarRootModel.makeForUnitTests() + EffectiveTimeZone.identifier = "UTC" + model.goToDate(CompassDateParsing.calendarDateInEffectiveTimeZone("2026-05-18")!) + XCTAssertFalse(model.eventJumpSelection.modeActive) + model.toggleEventJumpMode() + XCTAssertTrue(model.eventJumpSelection.modeActive) + XCTAssertFalse(model.timeGridState.quickTimeSlotHints.isEmpty) + model.toggleEventJumpMode() + XCTAssertFalse(model.eventJumpSelection.modeActive) + XCTAssertTrue(model.timeGridState.quickTimeSlotHints.isEmpty) + } + func testShiftDayJumpSelectsMondayColumn() async throws { let fixture = try DemoSeedFixture.load() let environment = try NativeCalendarEnvironment(inMemoryDatabase: true) 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/CompassData/Tests/CompassDataTests/QuickTimeTests.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/QuickTimeTests.swift index 2b12ee148..8cc2ea3f5 100644 --- a/apps/calendar-macos/CompassData/Tests/CompassDataTests/QuickTimeTests.swift +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/QuickTimeTests.swift @@ -31,4 +31,15 @@ final class QuickTimeTests: XCTestCase { XCTAssertEqual(calendar.component(.hour, from: start!), 11) XCTAssertEqual(calendar.component(.minute, from: start!), 30) } + + func testBuildQuickTimeSlotsSkipsBusyHour() { + let day = CompassDateParsing.calendarDateInEffectiveTimeZone("2026-05-20")! + let calendar = EffectiveTimeZone.calendar + let busyStart = calendar.date(bySettingHour: 11, minute: 0, second: 0, of: day)! + let busyEnd = calendar.date(byAdding: .hour, value: 1, to: busyStart)! + let busy = QuickTime.timedEventsToBusyIntervals(startDates: [busyStart], endDates: [busyEnd]) + let slots = QuickTime.buildQuickTimeSlots(busy: busy, targetDay: day) + XCTAssertFalse(slots.contains(where: { $0.sequence == "1100" })) + XCTAssertTrue(slots.contains(where: { $0.sequence == "1200" })) + } } diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/QuickTime.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/QuickTime.swift index 805a3db93..7dd5a7faa 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/QuickTime.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/QuickTime.swift @@ -1,5 +1,27 @@ import Foundation +public struct QuickTimeBusyInterval: Hashable, Sendable { + public var startMs: TimeInterval + public var endMs: TimeInterval + + public init(startMs: TimeInterval, endMs: TimeInterval) { + self.startMs = startMs + self.endMs = endMs + } +} + +public struct QuickTimeSlot: Hashable, Sendable { + public var start: String + public var end: String + public var sequence: String + + public init(start: String, end: String, sequence: String) { + self.start = start + self.end = end + self.sequence = sequence + } +} + /// Mirrors `apps/calendar-web/src/shortcuts/quick-time/quick-time.util.ts`. public enum QuickTime { public static let maxDigits = 4 @@ -33,6 +55,53 @@ public enum QuickTime { return calendar.startOfDay(for: startOfView) } + public static func quickTimeSequenceForHour(_ hour: Int) -> String? { + guard hour != 0 else { return nil } + return String(format: "%02d00", hour) + } + + public static func timedEventsToBusyIntervals( + startDates: [Date], + endDates: [Date] + ) -> [QuickTimeBusyInterval] { + zip(startDates, endDates).map { start, end in + QuickTimeBusyInterval(startMs: start.timeIntervalSince1970, endMs: end.timeIntervalSince1970) + } + } + + public static func buildQuickTimeSlots( + busy: [QuickTimeBusyInterval], + targetDay: Date + ) -> [QuickTimeSlot] { + let calendar = EffectiveTimeZone.calendar + let dayStart = calendar.startOfDay(for: targetDay) + var slots: [QuickTimeSlot] = [] + + for hour in 1 ..< 24 { + guard let sequence = quickTimeSequenceForHour(hour), + let start = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: dayStart), + let end = calendar.date(byAdding: .hour, value: 1, to: start) + else { continue } + + let startMs = start.timeIntervalSince1970 + let endMs = end.timeIntervalSince1970 + let isTaken = busy.contains { period in + period.startMs < endMs && period.endMs > startMs + } + if isTaken { continue } + + slots.append( + QuickTimeSlot( + start: CompassDateParsing.formatLikeDayjs(start), + end: CompassDateParsing.formatLikeDayjs(end), + sequence: sequence + ) + ) + } + + return slots + } + public static func resolveQuickTimeStart(digits: String, targetDay: Date) -> Date? { guard digits.allSatisfy(\.isNumber), !digits.isEmpty, digits.count <= maxDigits else { return nil 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/CompassKit/Sources/CompassKit/Layout/QuickTimeSlotHint.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Layout/QuickTimeSlotHint.swift new file mode 100644 index 000000000..a24a6b52a --- /dev/null +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Layout/QuickTimeSlotHint.swift @@ -0,0 +1,12 @@ +import Foundation + +public struct QuickTimeSlotHint: Hashable, Sendable, Identifiable { + public var id: String { sequence } + public var sequence: String + public var frame: EventPosition + + public init(sequence: String, frame: EventPosition) { + self.sequence = sequence + self.frame = frame + } +} diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Layout/TimeGridState.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Layout/TimeGridState.swift index 3f183fa9f..28d019855 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Layout/TimeGridState.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Layout/TimeGridState.swift @@ -9,6 +9,7 @@ public struct TimeGridState: Sendable { /// Grid card whose event is open in the sidebar form (keyboard focus may be elsewhere). public var sidebarEditingEventId: String? public var eventJumpHints: [EventJumpChipHint] + public var quickTimeSlotHints: [QuickTimeSlotHint] public var focusedDayColumnCalendarId: String? public var pageJumpHintsVisible: Bool public var pageJumpDigitByCalendarId: [String: String] @@ -24,6 +25,7 @@ public struct TimeGridState: Sendable { focusedEventId: String? = nil, sidebarEditingEventId: String? = nil, eventJumpHints: [EventJumpChipHint] = [], + quickTimeSlotHints: [QuickTimeSlotHint] = [], focusedDayColumnCalendarId: String? = nil, pageJumpHintsVisible: Bool = false, pageJumpDigitByCalendarId: [String: String] = [:], @@ -38,6 +40,7 @@ public struct TimeGridState: Sendable { self.focusedEventId = focusedEventId self.sidebarEditingEventId = sidebarEditingEventId self.eventJumpHints = eventJumpHints + self.quickTimeSlotHints = quickTimeSlotHints self.focusedDayColumnCalendarId = focusedDayColumnCalendarId self.pageJumpHintsVisible = pageJumpHintsVisible self.pageJumpDigitByCalendarId = pageJumpDigitByCalendarId 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/Chips/QuickTimeSlotsOverlay.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/Chips/QuickTimeSlotsOverlay.swift new file mode 100644 index 000000000..4d036d1c1 --- /dev/null +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/Chips/QuickTimeSlotsOverlay.swift @@ -0,0 +1,35 @@ +import CompassKit +import SwiftUI + +/// Web `QuickTimeSlots`: dashed hour placeholders while event-jump mode is on. +struct QuickTimeSlotsOverlay: View { + @Environment(\.nativeWebTheme) private var theme + let hints: [QuickTimeSlotHint] + let gridYOffset: CGFloat + let visible: Bool + + var body: some View { + if visible, !hints.isEmpty { + GeometryReader { _ in + ForEach(hints) { hint in + RoundedRectangle(cornerRadius: NativeWebLayoutTokens.radiusDefault) + .strokeBorder( + theme.borderStrongColor, + style: StrokeStyle(lineWidth: 1, dash: [4, 3]) + ) + .frame(width: hint.frame.width, height: hint.frame.height) + .overlay(alignment: .topTrailing) { + ShortcutHintChip(label: hint.sequence) + .padding(2) + } + .position( + x: hint.frame.left + hint.frame.width / 2, + y: gridYOffset + hint.frame.top + hint.frame.height / 2 + ) + } + } + .allowsHitTesting(false) + .accessibilityIdentifier("compass-quick-time-slots") + } + } +} 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/DeleteAccountConfirmationDialogView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/DeleteAccountConfirmationDialogView.swift new file mode 100644 index 000000000..c26a3ba4b --- /dev/null +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/DeleteAccountConfirmationDialogView.swift @@ -0,0 +1,63 @@ +import CompassData +import CompassKit +import SwiftUI + +struct DeleteAccountConfirmationDialogView: View { + @Environment(\.nativeWebTheme) private var theme + @Bindable var model: NativeCalendarRootModel + @State private var typedPhrase = "" + + private static let confirmPhrase = "Delete my Compass account" + + var body: some View { + if model.pendingDeleteAccountConfirmation { + VStack(alignment: .leading, spacing: 16) { + ModalDialogTitle("Delete your Compass account?") + Text( + "This deletes your Compass account and data: your calendars, events, and settings. " + + "It can't be undone.\n\n" + + "If you have a Compass trial or subscription, it is canceled immediately and your " + + "saved payment details are removed. Previous payments aren't refunded automatically.\n\n" + + "Your connected calendars are not affected. Nothing there gets deleted, Compass just " + + "loses its access to them." + ) + .font(.rubik(14)) + .foregroundStyle(theme.textColor) + VStack(alignment: .leading, spacing: 8) { + Text("Type \(Self.confirmPhrase) to confirm") + .font(.rubik(13)) + .foregroundStyle(theme.textMutedColor) + TextField("Confirmation phrase", text: $typedPhrase) + .textFieldStyle(.plain) + .font(.rubik(14)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(theme.surfaceOverlayColor) + .clipShape(RoundedRectangle(cornerRadius: NativeWebLayoutTokens.radiusDefault)) + .overlay { + RoundedRectangle(cornerRadius: NativeWebLayoutTokens.radiusDefault) + .stroke(theme.borderColor, lineWidth: 1) + } + .accessibilityIdentifier("compass-delete-account-phrase") + } + if let error = model.deleteAccountError { + Text(error) + .font(.rubik(14)) + .foregroundStyle(theme.textColor) + } + HStack { + Button("Cancel") { model.cancelDeleteAccount() } + .keyboardShortcut(.cancelAction) + Spacer() + Button(model.isDeletingAccount ? "Deleting…" : "Delete account") { + Task { await model.confirmDeleteAccount() } + } + .keyboardShortcut(.defaultAction) + .disabled(typedPhrase != Self.confirmPhrase || model.isDeletingAccount) + } + } + .accessibilityAddTraits(.isModal) + .modalDialogChrome(maxWidth: 480, identifier: "compass-delete-account-dialog") + } + } +} diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/DemoEventsBannerView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/DemoEventsBannerView.swift index 1861d9838..ddd634f8a 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/DemoEventsBannerView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/DemoEventsBannerView.swift @@ -1,3 +1,4 @@ +import CompassKit import SwiftUI public struct DemoEventsBannerView: View { @@ -10,13 +11,23 @@ public struct DemoEventsBannerView: View { public var body: some View { HStack(spacing: 12) { - Text("Sample events to help you explore. Clear them from the command palette.") - .font(.rubik(12)) - .foregroundStyle(theme.textMutedColor) + HStack(spacing: 6) { + Text("Sample events to help you explore. Clear them from the command palette") + .font(.rubik(12)) + .foregroundStyle(theme.textMutedColor) + ShortcutHintChip(label: KeycapGlyphs.display(["Mod"])) + ShortcutHintChip(label: "K") + } Spacer(minLength: 8) - Text("Okay") - .font(.rubik(12)) - .foregroundStyle(theme.textColor) + HStack(spacing: 6) { + Text("Okay") + .font(.rubik(12)) + .foregroundStyle(theme.textColor) + ShortcutHintChip(label: "O") + } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .accessibilityLabel("Okay") } .padding(.horizontal, 16) .padding(.vertical, 8) diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormCalendarSection.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormCalendarSection.swift index 81f12effc..18766777c 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormCalendarSection.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormCalendarSection.swift @@ -23,7 +23,7 @@ struct EventFormCalendarSection: View { ) ) { ForEach(model.writableEventCalendars, id: \.id) { calendar in - Text(calendar.name).tag(calendar.id) + Text(calendarLabel(calendar)).tag(calendar.id) } } .labelsHidden() @@ -34,4 +34,7 @@ struct EventFormCalendarSection: View { .eventFormSectionChrome(focused: focusField == .calendar) } + private func calendarLabel(_ calendar: CompassCalendar) -> String { + calendar.isPrimary ? "\(calendar.name) (primary)" : calendar.name + } } diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormCompactDateField.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormCompactDateField.swift index 3127949fe..3ba6603ea 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormCompactDateField.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormCompactDateField.swift @@ -1,3 +1,4 @@ +import CompassData import CompassKit import SwiftUI @@ -13,6 +14,9 @@ struct EventFormCompactDateField: View { _date = date self.accessibilityIdentifier = accessibilityIdentifier _text = State(initialValue: EventFormDateFormat.formatLabel(for: date.wrappedValue)) + let openPicker = UITestLaunchPolicy.openEventFormDatePickerForUITest + && accessibilityIdentifier == "compass-event-form-start-date" + _showsCalendar = State(initialValue: openPicker) } var body: some View { @@ -56,6 +60,8 @@ struct EventFormCompactDateField: View { .datePickerStyle(.graphical) .labelsHidden() .padding(12) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("compass-event-form-date-picker") } } diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormScheduleSection.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormScheduleSection.swift index af00a4b0b..b2e4ae8ad 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormScheduleSection.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormScheduleSection.swift @@ -13,7 +13,7 @@ struct EventFormScheduleSection: View { var body: some View { VStack(alignment: .leading, spacing: 6) { - Toggle("All day", isOn: Binding( + Toggle("All day?", isOn: Binding( get: { draft.schedule.kind == .allDay }, set: { model.setDraftAllDay($0) } )) diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormView.swift index f8b6df48a..37c4d52dd 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormView.swift @@ -142,17 +142,10 @@ public struct EventFormView: View { HStack(spacing: 8) { Text("Save") Spacer(minLength: 8) - Text(KeycapGlyphs.display(["Mod", "Enter"])) - .font(.rubik(11, weight: .medium)) - .foregroundStyle(theme.onAccentColor.opacity(0.92)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(theme.onAccentColor.opacity(0.1)) - .overlay { - RoundedRectangle(cornerRadius: 4) - .stroke(theme.onAccentColor.opacity(0.35), lineWidth: 1) - } - .clipShape(RoundedRectangle(cornerRadius: 4)) + HStack(spacing: 4) { + saveKeycap(KeycapGlyphs.display(["Mod"])) + saveKeycap("Enter") + } } .font(.rubik(14, weight: .medium)) .foregroundStyle(theme.onAccentColor) @@ -188,4 +181,18 @@ public struct EventFormView: View { private func isLogisticsFieldFocused(_ field: EventFormField) -> Bool { field == .location || field == .conference || field == .attendees } + + private func saveKeycap(_ label: String) -> some View { + Text(label) + .font(.rubik(11, weight: .medium)) + .foregroundStyle(theme.onAccentColor.opacity(0.92)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(theme.onAccentColor.opacity(0.1)) + .overlay { + RoundedRectangle(cornerRadius: 4) + .stroke(theme.onAccentColor.opacity(0.35), lineWidth: 1) + } + .clipShape(RoundedRectangle(cornerRadius: 4)) + } } 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/RootView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/RootView.swift index a45c8e4fa..dcf373593 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/RootView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/RootView.swift @@ -27,6 +27,11 @@ public struct RootView: View { } content .padding(.leading, contentLeadingInset) + .frame( + minWidth: UITestLaunchPolicy.isParityCapture + ? CGFloat(ParityCaptureGeometry.contentTrackWidth) + : 0 + ) } // Web: Day/Week keep the panel mounted for event details even // when the sidebar preference is closed. @@ -124,6 +129,9 @@ public struct RootView: View { .overlay { LogoutConfirmationDialogView(model: model) } + .overlay { + DeleteAccountConfirmationDialogView(model: model) + } .overlay { DiscardUnsavedDraftDialogView( isPresented: model.pendingDiscardDraftConfirmation, @@ -347,6 +355,11 @@ public struct RootView: View { gridYOffset: gridChipYOffset, visible: !model.timeGridState.eventJumpHints.isEmpty ) + QuickTimeSlotsOverlay( + hints: model.timeGridState.quickTimeSlotHints, + gridYOffset: gridChipYOffset, + visible: model.eventJumpSelection.modeActive + ) } .background(theme.backgroundColor) .accessibilityIdentifier("compass-native-content") diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/Settings/SettingsDataActionsSectionView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/Settings/SettingsDataActionsSectionView.swift index c61dd993b..7a87cab6a 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/Settings/SettingsDataActionsSectionView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/Settings/SettingsDataActionsSectionView.swift @@ -6,15 +6,21 @@ struct SettingsDataActionsSectionView: View { @Bindable var model: NativeCalendarRootModel var body: some View { - VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { Button(model.isExportingLocalData ? "Exporting…" : "Export data") { Task { await model.exportLocalData() } } + .buttonStyle(.bordered) + .tint(theme.borderColor) .accessibilityIdentifier("settings-export-data") .disabled(model.isExportingLocalData) - Text("Export user-created events stored on this Mac. Synced calendar caches and sample events are excluded.") - .font(.rubik(12)) - .foregroundStyle(theme.textMutedColor) + Button("Delete account") { + model.requestDeleteAccount() + } + .buttonStyle(.borderedProminent) + .tint(theme.errorColor) + .foregroundStyle(theme.onAccentColor) + .accessibilityIdentifier("settings-delete-account") if model.isSignedIn { Button("Log out") { model.requestLogout() } .accessibilityIdentifier("settings-log-out") @@ -24,4 +30,3 @@ struct SettingsDataActionsSectionView: View { .accessibilityIdentifier("settings-section-data-actions") } } - 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..cfa7441be 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/Settings/SettingsWindowOverlay.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/Settings/SettingsWindowOverlay.swift @@ -17,15 +17,32 @@ public struct SettingsWindowOverlay: View { theme.panelBackdropColor .ignoresSafeArea() .onTapGesture { model.closeSettingsIfAllowed() } - HStack(alignment: .top, spacing: 0) { - nav - Divider().overlay(theme.borderColor) - content + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center) { + Text("Settings") + .font(.rubik(18)) + .foregroundStyle(theme.textColor) + Spacer(minLength: 8) + Button("Close") { model.closeSettingsIfAllowed() } + .buttonStyle(.plain) + .font(.rubik(14)) + .foregroundStyle(theme.textMutedColor) + ShortcutHintChip(label: "Esc") + .allowsHitTesting(false) + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 8) + HStack(alignment: .top, spacing: 0) { + nav + Divider().overlay(theme.borderColor) + content + } } .frame( - width: min(640, max(0, geometry.size.width - 32)), - height: min(620, max(0, geometry.size.height - 32)) - ) + width: min(640, max(0, geometry.size.width - 32)), + height: min(620, max(0, geometry.size.height - 32)) + ) .overlayPanelChrome() } } @@ -40,9 +57,6 @@ public struct SettingsWindowOverlay: View { private var nav: some View { VStack(alignment: .leading, spacing: 8) { - Text("Settings") - .font(.rubik(18)) - .foregroundStyle(theme.textColor) navButton("Accounts", page: .accounts) if model.settingsHasMeetings { navButton("Meetings", page: .booking, attention: model.bookingStore.bookingNeedsAttention) @@ -55,20 +69,17 @@ public struct SettingsWindowOverlay: View { Text("Hold") .font(.rubik(11)) .foregroundStyle(theme.textMutedColor) - ShortcutHintChip(label: "Mod") + ShortcutHintChip(label: KeycapGlyphs.display(["Mod"])) Text("to see shortcuts.") .font(.rubik(11)) .foregroundStyle(theme.textMutedColor) } .padding(.top, 4) } - Spacer() - Button("Close") { model.closeSettingsIfAllowed() } - .buttonStyle(.plain) - .foregroundStyle(theme.textMutedColor) + Spacer(minLength: 0) } .padding(20) - .frame(width: 180, alignment: .leading) + .frame(width: 128, alignment: .leading) } private func navButton(_ title: String, page: SettingsPage, attention: Bool = false) -> some View { @@ -90,7 +101,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 +110,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 +130,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/Sidebar/AnonymousCalendarRowView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/AnonymousCalendarRowView.swift index f99f85662..770520282 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/AnonymousCalendarRowView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/AnonymousCalendarRowView.swift @@ -11,7 +11,18 @@ struct AnonymousCalendarRowView: View { Button { showsInfo.toggle() } label: { HStack(spacing: 8) { Circle() - .fill(EventCardColorParser.nsColor(hex: calendar.backgroundColor).map(Color.init(nsColor:)) ?? theme.textColor) + .fill( + EventCardColorParser.nsColor(hex: calendar.backgroundColor).map(Color.init(nsColor:)) + ?? theme.textColor + ) + .overlay { + Circle() + .strokeBorder( + EventCardColorParser.nsColor(hex: calendar.backgroundColor).map(Color.init(nsColor:)) + ?? theme.textColor, + lineWidth: 2 + ) + } .frame(width: 14, height: 14) Text("On this device only") .font(.rubik(12)) diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/ShortcutSidebarFooter.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/ShortcutSidebarFooter.swift index 3c53bd964..88314ec37 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/ShortcutSidebarFooter.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/ShortcutSidebarFooter.swift @@ -1,4 +1,5 @@ import CompassData +import CompassKit import SwiftUI struct ShortcutSidebarFooter: View { @@ -26,22 +27,31 @@ struct ShortcutSidebarFooter: View { if isEventFormVisible, !levelsStore.tipsMuted, !suppressTips, !suppressForQuickTime { eventFormSaveHint - } else if !levelsStore.badgeHidden, !suppressTips, !suppressForQuickTime, - let tip = levelsStore.currentTip - { - HStack(spacing: 8) { - Text("Try \(tip.label)") - .font(.rubik(11)) - .foregroundStyle(theme.textMutedColor) - .lineLimit(2) - .accessibilityIdentifier("compass-native-shortcut-tip") - HStack(spacing: 3) { - ForEach(Array(tip.keycaps.enumerated()), id: \.offset) { _, key in - ShortcutHintChip(label: key) - } - } - } + } else if !levelsStore.tipsMuted, !suppressTips, !suppressForQuickTime { + calendarJumpHint + } + } + } + + private var calendarJumpHint: some View { + VStack(alignment: .center, spacing: 2) { + HStack(spacing: 4) { + Text("Hold") + .font(.rubik(11)) + .foregroundStyle(theme.textMutedColor) + ShortcutHintChip(label: KeycapGlyphs.display(["Mod"])) + Text("to see jump targets") + .font(.rubik(11)) + .foregroundStyle(theme.textMutedColor) + } + .frame(maxWidth: .infinity) + .accessibilityIdentifier("compass-native-shortcut-tip") + Button("Hide tips") { + levelsStore.setTipsMuted(true) } + .buttonStyle(.plain) + .font(.rubik(11)) + .foregroundStyle(theme.textMutedColor) } } @@ -50,7 +60,7 @@ struct ShortcutSidebarFooter: View { Text("Enter saves · hold") .font(.rubik(11)) .foregroundStyle(theme.textMutedColor) - ShortcutHintChip(label: "Mod") + ShortcutHintChip(label: KeycapGlyphs.display(["Mod"])) Text("to jump fields") .font(.rubik(11)) .foregroundStyle(theme.textMutedColor) diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/SidebarMonthPicker.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/SidebarMonthPicker.swift index fc5d47fe8..aea06cd61 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/SidebarMonthPicker.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/SidebarMonthPicker.swift @@ -63,11 +63,9 @@ public struct SidebarMonthPicker: View { } .frame(height: 32.333) .background { - if days.contains(where: { isInRange($0, from: selectedDate) }) - || (visibleDayCount > 1 && days.contains(where: { isInRange($0, from: focusedDate ?? selectedDate) })) - { + if rowContainsSelectedWeek(days) { RoundedRectangle(cornerRadius: 4) - .fill(theme.textMutedColor.opacity(0.15)) + .fill(theme.textMutedColor.opacity(0.12)) } } .overlay { @@ -144,8 +142,9 @@ public struct SidebarMonthPicker: View { return formatter.string(from: displayedMonth) } + /// Web `MonthPicker`: Sunday-first columns for every view wider than a single day. private var weekStart: Int { - visibleDayCount == 1 ? 1 : EffectiveTimeZone.calendar.component(.weekday, from: selectedDate) + 1 } private var dayCells: [Date] { @@ -169,6 +168,16 @@ public struct SidebarMonthPicker: View { return date >= start && date < end } + /// Matches web `react-datepicker__week--selected` for the week containing the anchor day. + private func rowContainsSelectedWeek(_ days: [Date]) -> Bool { + let calendar = EffectiveTimeZone.calendar + let anchor = calendar.startOfDay(for: selectedDate) + guard let rowStart = days.first.map({ calendar.startOfDay(for: $0) }), + let rowEnd = days.last.flatMap({ calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: $0)) }) + else { return false } + return anchor >= rowStart && anchor < rowEnd + } + private func shiftMonth(_ offset: Int) { if let next = EffectiveTimeZone.calendar.date(byAdding: .month, value: offset, to: displayedMonth) { displayedMonth = next diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/UpNextBanner.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/UpNextBanner.swift index 0b1dd4a00..a8548a200 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/UpNextBanner.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/UpNextBanner.swift @@ -38,16 +38,22 @@ struct UpNextBanner: View { } } Spacer(minLength: 8) - Button(state.conferenceURL != nil ? "Join" : "Open") { + Button { if state.conferenceURL != nil { onJoin() } else { onOpen() } + } label: { + HStack(spacing: 6) { + Text(state.conferenceURL != nil ? "Join" : "Open") + ShortcutHintChip( + label: state.conferenceURL != nil ? "V" : "N") + } } .buttonStyle(.borderedProminent) .tint(theme.accentSecondaryColor) - Button("Dismiss") { + Button { withAnimation { isClosing = true } @@ -55,9 +61,13 @@ struct UpNextBanner: View { dismissedEventId = upNext.id isClosing = false } + } label: { + Text("×") + .font(.rubik(16)) + .foregroundStyle(theme.textMutedColor) + .padding(.horizontal, 4) } .buttonStyle(.plain) - .foregroundStyle(theme.textMutedColor) .accessibilityLabel("Dismiss") } .padding(.horizontal, 12) 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..d83579907 100644 --- a/apps/calendar-macos/CompassUITests/NativeParityCaptureTests.swift +++ b/apps/calendar-macos/CompassUITests/NativeParityCaptureTests.swift @@ -20,8 +20,14 @@ final class NativeParityCaptureTests: XCTestCase { } captureWeekGrid() + captureWeekGridAllDay() + captureDemoEventsBanner() + captureModHoldOverlay() + captureUpNextBanner() captureMiniCalendar() captureEventForm() + captureEventFormDatePicker() + captureEventFormTimePicker() captureCommandPalette() captureSettings() captureWelcomeOnboarding() @@ -30,14 +36,50 @@ final class NativeParityCaptureTests: XCTestCase { @MainActor private func captureWeekGrid() { let window = launchDemoWindow() - prepareParityGrid(window) + prepareParityGrid(window, dismissDemoBanner: true) NativeImageSnapshotSupport.captureWindow(window, named: "week-grid") } + @MainActor + private func captureWeekGridAllDay() { + let window = launchDemoWindow() + prepareParityGrid(window, dismissDemoBanner: true) + let allDay = window.descendants(matching: .any)["compass-grid-allday"] + XCTAssertTrue(allDay.waitForExistence(timeout: 10)) + NativeImageSnapshotSupport.captureWindow(window, named: "week-grid-allday") + } + + @MainActor + private func captureDemoEventsBanner() { + let window = launchDemoWindow() + prepareParityGrid(window, dismissDemoBanner: false) + XCTAssertTrue( + window.descendants(matching: .any)["compass-native-demo-events-banner"].waitForExistence(timeout: 10)) + NativeImageSnapshotSupport.captureWindow(window, named: "demo-events-banner") + } + + @MainActor + private func captureModHoldOverlay() { + let window = launchDemoWindow(extraLaunchArgs: ["-COMPASS_UI_TEST_SHOW_PAGE_JUMP_HINTS"]) + prepareParityGrid(window, dismissDemoBanner: true) + XCTAssertTrue( + window.descendants(matching: .any)["compass-mod-hold-chips"].waitForExistence(timeout: 10)) + NativeImageSnapshotSupport.captureWindow(window, named: "mod-hold-overlay") + } + + @MainActor + private func captureUpNextBanner() { + let window = launchDemoWindow() + prepareParityGrid(window, dismissDemoBanner: true) + XCTAssertTrue( + window.descendants(matching: .any)["compass-native-up-next-banner"].waitForExistence(timeout: 15)) + NativeImageSnapshotSupport.captureWindow(window, named: "up-next-banner") + } + @MainActor private func captureMiniCalendar() { let window = launchDemoWindow() - prepareParityGrid(window) + prepareParityGrid(window, dismissDemoBanner: true) XCTAssertTrue( window.descendants(matching: .any)["compass-native-month-picker"].waitForExistence(timeout: 10)) window.typeKey("i", modifierFlags: []) @@ -47,17 +89,38 @@ final class NativeParityCaptureTests: XCTestCase { @MainActor private func captureEventForm() { let window = launchDemoWindow() - prepareParityGrid(window) + prepareParityGrid(window, dismissDemoBanner: true) window.typeKey("c", modifierFlags: []) XCTAssertTrue( window.descendants(matching: .any)["compass-event-form"].waitForExistence(timeout: 8)) NativeImageSnapshotSupport.captureWindow(window, named: "event-form") } + @MainActor + private func captureEventFormDatePicker() { + let window = launchDemoWindow(extraLaunchArgs: ["-COMPASS_UI_TEST_OPEN_EVENT_FORM_DATE_PICKER"]) + prepareParityGrid(window, dismissDemoBanner: true) + window.typeKey("c", modifierFlags: []) + XCTAssertTrue( + window.descendants(matching: .any)["compass-event-form-date-picker"].waitForExistence(timeout: 12)) + NativeImageSnapshotSupport.captureWindow(window, named: "event-form-date-picker") + } + + @MainActor + private func captureEventFormTimePicker() { + let window = launchDemoWindow() + prepareParityGrid(window, dismissDemoBanner: true) + window.typeKey("c", modifierFlags: []) + let startTime = window.descendants(matching: .any)["compass-event-form-start"] + XCTAssertTrue(startTime.waitForExistence(timeout: 8)) + startTime.click() + NativeImageSnapshotSupport.captureWindow(window, named: "event-form-time-picker") + } + @MainActor private func captureCommandPalette() { let window = launchDemoWindow() - prepareParityGrid(window) + prepareParityGrid(window, dismissDemoBanner: true) window.typeKey("k", modifierFlags: [.command]) XCTAssertTrue( window.descendants(matching: .any)["compass-native-command-palette"].waitForExistence(timeout: 8)) @@ -67,7 +130,7 @@ final class NativeParityCaptureTests: XCTestCase { @MainActor private func captureSettings() { let window = launchDemoWindow() - prepareParityGrid(window) + prepareParityGrid(window, dismissDemoBanner: true) window.typeKey(",", modifierFlags: [.command]) XCTAssertTrue( window.descendants(matching: .any)["compass-native-settings"].waitForExistence(timeout: 8)) @@ -87,9 +150,9 @@ final class NativeParityCaptureTests: XCTestCase { } @MainActor - private func launchDemoWindow() -> XCUIElement { + private func launchDemoWindow(extraLaunchArgs: [String] = []) -> XCUIElement { let app = XCUIApplication.compassUITest() - app.launchArguments += Self.demoLaunchArgs + app.launchArguments += Self.demoLaunchArgs + extraLaunchArgs app.launchEnvironment["PARITY_CAPTURE"] = "1" app.launch() let window = app.windows["Compass"] @@ -99,9 +162,9 @@ final class NativeParityCaptureTests: XCTestCase { /// Dismiss demo chrome and wait for the shared demo-seed grid event (web parity uses the same fixture). @MainActor - private func prepareParityGrid(_ window: XCUIElement) { + private func prepareParityGrid(_ window: XCUIElement, dismissDemoBanner: Bool) { let okay = window.buttons["Okay"] - if okay.waitForExistence(timeout: 4) { + if dismissDemoBanner, okay.waitForExistence(timeout: 4) { okay.tap() } let wednesdayHeader = window.staticTexts.matching( @@ -113,10 +176,30 @@ 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") + let dismissScrollHint = window.buttons["Dismiss"] + if dismissScrollHint.waitForExistence(timeout: 2) { + dismissScrollHint.tap() + } sleep(1) } diff --git a/e2e/parity/desktop-web-parity.spec.ts b/e2e/parity/desktop-web-parity.spec.ts index fd1b3dba0..def508024 100644 --- a/e2e/parity/desktop-web-parity.spec.ts +++ b/e2e/parity/desktop-web-parity.spec.ts @@ -11,11 +11,18 @@ const outputDir = process.env.PARITY_WEB_OUT ?? join(process.cwd(), "build", "desktop-parity", "web"); +const macChromeUserAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + +/** Parity captures use a Mac Chrome UA so Mod renders as ⌘ like native macOS. */ +const modKey = "Meta"; + test.use({ viewport: PARITY_VIEWPORT, storageState: { cookies: [], origins: [] }, timezoneId: "UTC", colorScheme: "light", + userAgent: macChromeUserAgent, }); test.describe("Desktop web parity captures", () => { @@ -33,6 +40,38 @@ test.describe("Desktop web parity captures", () => { await page.screenshot({ path: join(outputDir, "week-grid.png") }); }); + test("week-grid-allday", async ({ page }) => { + await prepareParityDemoWeek(page); + await page.locator("#allDayRow").scrollIntoViewIfNeeded(); + await page.screenshot({ path: join(outputDir, "week-grid-allday.png") }); + }); + + test("demo-events-banner", async ({ page }) => { + await prepareParityDemoWeek(page, { dismissDemoBanner: false }); + const banner = page + .getByRole("status") + .filter({ hasText: "Sample events to help you explore" }); + await expect(banner).toBeVisible({ timeout: 10_000 }); + await page.screenshot({ path: join(outputDir, "demo-events-banner.png") }); + }); + + test("mod-hold-overlay", async ({ page }) => { + await prepareParityDemoWeek(page); + await page.locator("#mainGrid").focus(); + await page.keyboard.down(modKey); + await page.waitForTimeout(600); + await page.screenshot({ path: join(outputDir, "mod-hold-overlay.png") }); + await page.keyboard.up(modKey); + }); + + test("up-next-banner", async ({ page }) => { + await prepareParityDemoWeek(page); + await expect(page.getByRole("region", { name: /up next/i })).toBeVisible({ + timeout: 15_000, + }); + await page.screenshot({ path: join(outputDir, "up-next-banner.png") }); + }); + test("mini-calendar", async ({ page }) => { await prepareParityDemoWeek(page); await page.keyboard.press("i"); @@ -52,9 +91,32 @@ test.describe("Desktop web parity captures", () => { await page.screenshot({ path: join(outputDir, "event-form.png") }); }); + test("event-form-date-picker", async ({ page }) => { + await prepareParityDemoWeek(page); + await page.locator("#mainGrid").focus(); + await page.keyboard.press("c"); + await page.locator("#eventFormStartDate").click(); + await expect(page.locator(".react-datepicker")).toBeVisible({ + timeout: 10_000, + }); + await page.screenshot({ + path: join(outputDir, "event-form-date-picker.png"), + }); + }); + + test("event-form-time-picker", async ({ page }) => { + await prepareParityDemoWeek(page); + await page.locator("#mainGrid").focus(); + await page.keyboard.press("c"); + await page.locator("#startTimePicker").click(); + await page.screenshot({ + path: join(outputDir, "event-form-time-picker.png"), + }); + }); + test("command-palette", async ({ page }) => { await prepareParityDemoWeek(page); - await page.keyboard.press("Control+K"); + await page.keyboard.press(`${modKey}+K`); await expect(page.getByLabel("Command palette search")).toBeVisible({ timeout: 10_000, }); @@ -63,7 +125,7 @@ test.describe("Desktop web parity captures", () => { test("settings", async ({ page }) => { await prepareParityDemoWeek(page); - await page.keyboard.press("Control+,"); + await page.keyboard.press(`${modKey}+,`); await expect(page.getByRole("button", { name: "Accounts" })).toBeVisible({ timeout: 10_000, }); diff --git a/e2e/parity/seed-demo-fixture.util.ts b/e2e/parity/seed-demo-fixture.util.ts index 4ef5b1397..079a27cbf 100644 --- a/e2e/parity/seed-demo-fixture.util.ts +++ b/e2e/parity/seed-demo-fixture.util.ts @@ -58,17 +58,32 @@ export async function installParityWebEnvironment( }, localStorageSeed); } +type PrepareParityDemoWeekOptions = { + dismissDemoBanner?: boolean; +}; + /** Load demo-seed.json into IndexedDB after the app has opened compass-local. */ -export async function prepareParityDemoWeek(page: Page) { +export async function prepareParityDemoWeek( + page: Page, + options: PrepareParityDemoWeekOptions = {}, +) { + const dismissDemoBanner = options.dismissDemoBanner ?? true; await page.goto("/week", { waitUntil: "domcontentloaded" }); await page.locator("#timedColumns").waitFor({ timeout: 20_000 }); await seedParityDemoEvents(page); await page.reload({ waitUntil: "domcontentloaded" }); await expectDemoGrid(page); + if (dismissDemoBanner) { + const okay = page.getByRole("button", { name: "Okay" }); + if (await okay.isVisible().catch(() => false)) { + await okay.click(); + } + } await page.locator("#mainGrid").focus(); await page.keyboard.press("t"); await page - .getByRole("button", { name: /Exercise/i }) + .getByRole("button", { name: /Exercise|Morning standup/i }) + .first() .waitFor({ timeout: 15_000 }); }