Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
41 changes: 21 additions & 20 deletions docs/features/desktop-web-parity-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,18 +84,18 @@ 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

| Item | Status | Notes |
| --- | --- | --- |
| 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
Expand All @@ -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 |
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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) | ✅ | |
Expand All @@ -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

Expand Down Expand Up @@ -320,22 +320,23 @@ 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 |

---

## 7. Visual parity evidence (1280×820, Light Beach, demo seed)

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.
Loading