diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift index a7c4cce407..d2216f8bc1 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel+EventJump.swift @@ -5,27 +5,57 @@ extension NativeCalendarRootModel { public var eventJumpSelection: EventJumpSelectionStore { overlayStores.eventJumpSelection } public var eventJumpStatusText: String? { - if let announcement = eventJumpSelection.announcement, eventJumpSelection.isActive { + guard eventJumpSelection.isActive else { return nil } + if eventJumpSelection.dayJumpBuffer == "s", eventJumpSelection.activeDayKeys.isEmpty { + return "Shift+U Sunday · Shift+A Saturday · Esc" + } + if let announcement = eventJumpSelection.announcement { if eventJumpSelection.activeDayKeys.isEmpty { - return "Event jump · Esc" + return "Event jump · \(announcement) · Esc" } return "Jump · \(announcement) · C timed · Shift+C all-day · Esc" } - return nil + return "Event jump · Esc" } public func handleShiftDayJump(letter: Character) { guard viewStore.view == .week || viewStore.view == .day else { return } + let lower = String(letter).lowercased() + + if eventJumpSelection.dayJumpBuffer == "s" { + if lower == "u" { + selectJumpDay(prefix: "su") + return + } + if lower == "a" { + selectJumpDay(prefix: "sa") + return + } + } + + if lower == "s" { + eventJumpSelection.setWeekendDisambiguation(announcement: "Weekend") + recordShortcut(.focusWeekDay) + applyFocusPresentation() + return + } + guard let prefix = DayJumpPrefixes.dayPrefix(forShiftedLetter: letter), prefix != "s" else { return } + selectJumpDay(prefix: prefix) + } + private func selectJumpDay(prefix: String) { let calendar = EffectiveTimeZone.calendar for dayKey in visibleDateKeysForJump() { guard let date = CompassDateParsing.calendarDateInEffectiveTimeZone(dayKey) else { continue } let weekday = calendar.component(.weekday, from: date) - 1 guard DayJumpPrefixes.prefix(forWeekday: weekday) == prefix else { continue } + let name = DayJumpPrefixes.displayName(forPrefix: prefix) eventJumpSelection.selectDay( key: dayKey, - announcement: "\(DayJumpPrefixes.displayName(forPrefix: prefix)) selected") + announcement: "\(name) selected", + buffer: prefix + ) recordShortcut(.focusWeekDay) if let first = loadedEvents.first(where: { event in eventJumpDayKey(for: event) == dayKey diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/Stores/EventJumpSelectionStore.swift b/apps/calendar-macos/CompassData/Sources/CompassData/Stores/EventJumpSelectionStore.swift index f410894bb0..f98cfe0275 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/Stores/EventJumpSelectionStore.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/Stores/EventJumpSelectionStore.swift @@ -5,18 +5,28 @@ import Foundation public final class EventJumpSelectionStore { 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 + !activeDayKeys.isEmpty || announcement != nil || !dayJumpBuffer.isEmpty } - public func selectDay(key: String, announcement: String) { + public func selectDay(key: String, announcement: String, buffer: String) { activeDayKeys = [key] self.announcement = announcement + dayJumpBuffer = buffer + } + + public func setWeekendDisambiguation(announcement: String) { + activeDayKeys = [] + self.announcement = announcement + dayJumpBuffer = "s" } public func clear() { activeDayKeys = [] announcement = nil + dayJumpBuffer = "" } } diff --git a/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelEventJumpTests.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelEventJumpTests.swift index 47059a6e57..4783c26399 100644 --- a/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelEventJumpTests.swift +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelEventJumpTests.swift @@ -17,4 +17,19 @@ final class NativeCalendarRootModelEventJumpTests: XCTestCase { XCTAssertEqual(model.eventJumpSelection.announcement, "Monday selected") XCTAssertNotNil(model.eventJumpStatusText) } + + func testShiftDayJumpWeekendDisambiguation() async throws { + let fixture = try DemoSeedFixture.load() + let environment = try NativeCalendarEnvironment(inMemoryDatabase: true) + let model = NativeCalendarRootModel(environment: environment, demoPresentation: fixture) + await model.start() + model.viewStore.view = .week + + model.handleShiftDayJump(letter: "S") + XCTAssertEqual(model.eventJumpSelection.dayJumpBuffer, "s") + XCTAssertEqual(model.eventJumpStatusText, "Shift+U Sunday · Shift+A Saturday · Esc") + + model.handleShiftDayJump(letter: "A") + XCTAssertEqual(model.eventJumpSelection.announcement, "Saturday selected") + } } diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/CommandPalette/CommandPaletteOverlay.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/CommandPalette/CommandPaletteOverlay.swift index d5d249fdc4..14c9b7eba1 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/CommandPalette/CommandPaletteOverlay.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/CommandPalette/CommandPaletteOverlay.swift @@ -47,9 +47,10 @@ public struct CommandPaletteOverlay: View { } ForEach(sections) { section in if !section.heading.isEmpty { - Text(section.heading) - .font(.rubik(12)) + Text(section.heading.uppercased()) + .font(.rubik(11, weight: .medium)) .foregroundStyle(theme.textMutedColor) + .tracking(0.4) } ForEach(section.items) { item in paletteRow(item, query: query, selected: item.id == selectedId) @@ -75,9 +76,7 @@ public struct CommandPaletteOverlay: View { if let selectedId { proxy.scrollTo(selectedId, anchor: .top) } } } - Text("↑ ↓ Navigate ↵ Select Esc Close") - .font(.rubik(11)) - .foregroundStyle(theme.textMutedColor) + paletteFooterHints .accessibilityLabel("Up and Down navigate, Return selects, Escape closes") } .padding(16) @@ -119,11 +118,12 @@ public struct CommandPaletteOverlay: View { } .padding(.vertical, 6) .padding(.horizontal, 8) - .background(selected ? theme.accentColor.opacity(0.12) : theme.surfaceColor) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .background(selected ? theme.accentColor.opacity(0.08) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: NativeWebLayoutTokens.radiusDefault)) .overlay { if selected { - RoundedRectangle(cornerRadius: 8) + RoundedRectangle(cornerRadius: NativeWebLayoutTokens.radiusDefault) + .inset(by: 0.5) .stroke(theme.accentColor, lineWidth: 1) } } @@ -150,4 +150,26 @@ public struct CommandPaletteOverlay: View { } return KeycapGlyphs.display(parts) } + + private var paletteFooterHints: some View { + HStack(spacing: 6) { + footerKeycap("↑") + footerKeycap("↓") + Text("Navigate") + .font(.rubik(11)) + .foregroundStyle(theme.textMutedColor) + footerKeycap("↩") + Text("Select") + .font(.rubik(11)) + .foregroundStyle(theme.textMutedColor) + footerKeycap("Esc") + Text("Close") + .font(.rubik(11)) + .foregroundStyle(theme.textMutedColor) + } + } + + private func footerKeycap(_ label: String) -> some View { + ShortcutHintChip(label: label) + } } diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/EventJumpStatusLine.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/EventJumpStatusLine.swift index 3ef6e921ee..15cc26f13c 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/EventJumpStatusLine.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/Sidebar/EventJumpStatusLine.swift @@ -15,6 +15,8 @@ struct EventJumpStatusLine: View { .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .accessibilityIdentifier("compass-native-event-jump-status") + .transition(.opacity.combined(with: .move(edge: .bottom))) + .animation(.easeInOut(duration: 0.2), value: text) } } } diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/TimeGrid/TimeGridView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/TimeGrid/TimeGridView.swift index 33bb1d2d42..0d9c57d687 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/TimeGrid/TimeGridView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/TimeGrid/TimeGridView.swift @@ -175,23 +175,30 @@ public final class TimeGridView: NSView { guard let header = subview as? NSTextField, header.identifier?.rawValue == todayKey else { continue } - pulseHeaderBackground(header, accent: accent) + pulseHeaderLabel(header, accent: accent) } } else { onNowCuePulse?() } } - private func pulseHeaderBackground(_ view: NSView, accent: NSColor) { - view.wantsLayer = true - view.layer?.add( - nowCuePulseAnimation( - keyPath: "backgroundColor", - from: NSColor.clear.cgColor, - to: accent.withAlphaComponent(0.18).cgColor - ), - forKey: "nowCuePulse" - ) + private func pulseHeaderLabel(_ header: NSTextField, accent: NSColor) { + header.wantsLayer = true + let shimmer = CABasicAnimation(keyPath: "opacity") + shimmer.fromValue = 1 + shimmer.toValue = 0.55 + shimmer.duration = 0.7 + shimmer.autoreverses = true + shimmer.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + header.layer?.add(shimmer, forKey: "nowCueLabelShimmer") + if let attributed = header.attributedStringValue.mutableCopy() as? NSMutableAttributedString { + attributed.addAttribute(.foregroundColor, value: accent, range: NSRange(location: 0, length: attributed.length)) + header.attributedStringValue = attributed + DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak header] in + guard let header else { return } + header.layer?.opacity = 1 + } + } } private func nowCuePulseAnimation(keyPath: String, from: Any?, to: Any?) -> CABasicAnimation { diff --git a/docs/features/desktop-web-parity-checklist.md b/docs/features/desktop-web-parity-checklist.md index 021efbae04..178165ac37 100644 --- a/docs/features/desktop-web-parity-checklist.md +++ b/docs/features/desktop-web-parity-checklist.md @@ -84,10 +84,10 @@ This checklist compares **apps/calendar-web** (reference UI) with **apps/calenda | All-day row above timed scroll | ✅ | `TimeGridView.renderAllDayColumns` | | Now line position | ✅ | Shared now math | | Now line **visual** (gradient + 1px rim) | ❌ | Web: `accentGradient` + `c-now-line-rim`; macOS: solid 2px accent | -| Now-cue pulse on **`t`** (today) | 🟡 | Web 700ms pulse + label shimmer; macOS 700ms header flag, 350ms line animation, shimmer partial on day view | +| Now-cue pulse on **`t`** (today) | 🟡 | Web 700ms label shimmer; macOS week header opacity pulse + day view switcher pulse | | Click grid teaches shortcut | ✅ | Pointer hint overlay | | Day column header focus | 🟡 | AppKit exterior ring vs web `c-focus-ring` | -| Shift+C all-day tip strip | 🟡 | Verify animation + copy vs web `useShiftHoldEventHints` all-day path | +| Shift+C all-day tip strip | 🟡 | Sidebar jump line animates; full web tip stack still partial | ### 2.2 Event chips @@ -95,7 +95,7 @@ This checklist compares **apps/calendar-web** (reference UI) with **apps/calenda | --- | --- | --- | | Timed / all-day card shell | ✅ | `EventCardView`, palette tests | | Jump / hold-F digit hints | ✅ | `eventJumpHold`, chips overlay | -| Shift+M weekday column jump | ❌ | Web `useShiftHoldEventHints.ts`; macOS not wired (`focus-week-day`) | +| Shift+M weekday column jump | 🟡 | `handleShiftDayJump`; weekend Shift+S→U/A added; digit jump + quick-time slots still web-only | | Sidebar edit ring | 🟡 | Inset focus layer vs web outline | ### 2.3 Sidebar event form @@ -106,7 +106,7 @@ This checklist compares **apps/calendar-web** (reference UI) with **apps/calenda | Date field **MM.DD.YY** closed display | ❌ | Web `EventFormDateField.tsx`; macOS `DatePicker` locale format | | Datepicker open Enter/Space/ArrowDown | 🟡 | Audit `EventFormScheduleSection.swift` vs web | | Time pickers | 🟡 | Visual parity pass needed | -| Location / Meet / guests grouping | 🟡 | Sections exist; spacing/dividers vs web `FormCard` | +| Location / Meet / guests grouping | ✅ | #4607 single `EventFormCard` + description card (web #4599) | | Save bar: accent fill, on-accent text, space-between, **⌘Enter** hint | ❌ | Web `SaveSection.tsx` + `c-button-save-bar`; macOS inverted text/background + Enter chip only | | Mod+digit field jump + hold-Mod chips | ✅ | `ModHoldChipsOverlay`, layout snapshots | | Sticky form actions row (icons + shortcuts) | 🟡 | Web `FormActionsRow.tsx`; macOS `EventFormToolbarView` SF Symbols, fewer keycaps | @@ -172,10 +172,10 @@ This checklist compares **apps/calendar-web** (reference UI) with **apps/calenda | Navigation / create / events / recent | ✅ | | | Fuzzy search + highlight | ❌ | Web `HighlightedLabel` + `filterSections` | | Row icons (Phosphor) | ❌ | Plain text rows | -| Keycaps (`ShortcutKeys`) | 🟡 | Plain shortcut strings, not `KeycapGlyphs` chips | -| Selected row ring inset | 🟡 | Web `ring-accent ring-inset`; macOS fill tint | -| **Missing rows** vs web | ❌ | Undo/redo, theme, timezone, notifications, booking, feedback, onboarding URL, meeting page, accounts shortcuts, demo events, shortcut tips hooks (`CommandPalette.tsx` vs `CommandPaletteCatalog.swift`) | -| Footer hint | 🟡 | Text arrows vs web keycaps | +| Keycaps (`ShortcutKeys`) | 🟡 | Row shortcuts use `KeycapGlyphs`; Phosphor row icons still missing | +| Selected row ring inset | ✅ | Accent inset ring + lighter fill (web `ring-inset`) | +| **Missing rows** vs web | 🟡 | Undo/redo wired in catalog; still missing theme, notifications, booking, feedback, shortcut-tip rows vs web `CommandPalette.tsx` | +| Footer hint | ✅ | Keycap chips for navigate / select / close | ### 2.11 Command palette (desktop quick-add variant) @@ -238,9 +238,9 @@ Legend: **Mod** = ⌘ on macOS. Web bindings from `APP_SHORTCUT_BINDINGS` / `KEY | ID | Binding | Status | Notes | | --- | --- | --- | --- | | `focus-sidebar` | I | ✅ | Month picker | -| `focus-event` | U | ❌ | Dispatched but `handleShortcut` no-op; web `onFocusCalendar` / seed focus | +| `focus-event` | U | ✅ | `focusFirstCalendarEvent()` seeds nearest grid event | | `focus-shift-hold` | F (+ digits) | 🟡 | Hold-F jump chips; not full web jump mode | -| `focus-week-day` | Shift+M… | ❌ | Web column state machine missing | +| `focus-week-day` | Shift+M… | 🟡 | Column select + first event; weekend S→U/A; digit hints after day pick ❌ | | `focus-notice` | P | 🟡 | Subset of notices | | `focus-calendar-digit` | 1–9 in list | ✅ | | | `focus-page-jump` | Mod+digit (hold hints) | ✅ | | @@ -259,7 +259,7 @@ Legend: **Mod** = ⌘ on macOS. Web bindings from `APP_SHORTCUT_BINDINGS` / `KEY | `edit-menu` | ✅ | Context menu | | Arrow move focus | ✅ | | | Shift+Tab cycle edge | ✅ | | -| Undo/redo | ✅ | Not in palette on macOS | +| Undo/redo | ✅ | Palette rows + Cmd+Z | ### 3.5 Other @@ -320,6 +320,7 @@ Each PR should link a child issue, include before/after screenshots (web Chromiu | 2026-10-19 | §7 visual evidence table (web CI captures; macOS/compare pending runner) | | 2026-10-19 | §7 updated from workflow run 39299882448 (macOS + compare artifacts) | | 2026-10-19 | §5 queue: event form logistics grouping (web #4599); CI fixes on main (palette Range, e2e/parity shard) | +| 2026-10-19 | §7 from workflow run 39327804343 (#4607 branch): macOS capture + compare artifact; logistics card on event form | --- @@ -327,15 +328,15 @@ Each PR should link a child issue, include before/after screenshots (web Chromiu Automated captures: workflow `desktop-web-parity-screenshots.yml` (PR #4596). Compare artifact `desktop-parity-compare` pairs `web/` and `macos/` PNGs per surface. -| Surface | Web CI | macOS CI | Match | Diff notes (compare run 39299882448, pre–v2 merge on macOS) | +| Surface | Web CI | macOS CI | Match | Diff notes (compare run 39331068008 on `main` post-#4607) | | --- | --- | --- | --- | --- | -| Week grid | ✅ | ✅ | 🟡 | macOS day view vs web week columns; web shows events + now column; #4604 now line on main | -| Mini-calendar | ✅ | ✅ | 🟡 | Selected day ring matches; month chrome and spacing differ | -| Event form | ✅ | ✅ | 🟡 | macOS slash dates + steppers vs web MM.DD.YY chips; #4603 targets form parity | -| Command palette | ✅ | ✅ | 🟡 | macOS flat list vs web icons, keycaps, selection ring; #4602 in queue | -| Settings | ✅ | ✅ | 🟡 | Layout close; verify row density after theme PR | -| Onboarding welcome | ✅ | ✅ | 🟡 | Copy aligned; button/footer spacing differs slightly | +| Week grid | ✅ | ✅ | 🟡 | Capture uses macOS day view vs web week; demo events only on web shot; sidebar lacks web “Hold Mod jump targets” tip line | +| Mini-calendar | ✅ | ✅ | 🟡 | Selected-day ring OK; web shows selected-week wash + denser header type | +| Event form | ✅ | ✅ | 🟡 | Logistics card grouped (#4607); dates still locale steppers vs MM.DD.YY; save bar / Enter hint gaps | +| Command palette | ✅ | ✅ | 🟡 | Undo/redo rows on macOS; web still has Phosphor icons + more nav rows; native keycaps + inset ring improved | +| Settings | ✅ | ✅ | 🟡 | macOS sidebar shell vs web modal; accounts copy differs (anonymous seed) | +| Onboarding welcome | ✅ | ✅ | 🟡 | Copy aligned; footer keycap spacing differs | -**Keyboard / tips (non-screenshot):** #4605 Shift+weekday column jump + sidebar tip strip; partial vs web full `useShiftHoldEventHints` (weekend `s`→u/a, digit jump, quick-time slots still ❌). +**Keyboard / tips (non-screenshot):** Shift+weekday column jump wired; weekend Shift+S→U/A on macOS; digit jump after day pick and quick-time slots still ❌ vs web `useShiftHoldEventHints`. -Re-run compare after #4602–#4605 merge; promote rows to ✅ when side-by-side PNGs match within tolerance. +Re-run `desktop-web-parity-screenshots.yml` on `main` after #4607 merge for canonical compare PNGs.