Keep menu calendar day agenda visible and refine quick-add focus
Co-authored-by: Codex <codex~paul@keymail.dev>
4 files changed,
+99
−32
native/UX-REVIEW.md+20 −0native/apple/Sources/CalendarKit/CalendarMenuView.swift+45 −25native/apple/Sources/CalendarKit/QuickAddView.swift+12 −1native/apple/Sources/CalendarReview/Review.swift+22 −6
diff --git a/native/UX-REVIEW.md b/native/UX-REVIEW.md| index aa38e0e..5068a18 100644 |
| --- a/native/UX-REVIEW.md |
| +++ b/native/UX-REVIEW.md |
| @@ -55,3 +55,23 @@ Independent reviewer: `/root/canary_release_review`, dispatched as `gpt-6-astra` |
| with high reasoning; runtime identity not independently verified. Screenshots |
| and source corrections were reviewed independently. Full VoiceOver walkthrough, |
| localization and every accessibility size remain hands-on acceptance work. |
| + |
| +## Menu-bar agenda refinement · 13 September |
| + |
| +Pinned the compact month and selected-day heading above a dedicated scrolling |
| +agenda. Day selection updates the existing observed model immediately and resets |
| +the agenda scroll position. The header shows the selected day's event count |
| +only after a successful load. Previously the month and events shared one scroll |
| +region, hiding the agenda and making day selection appear ineffective. |
| + |
| +Mac quick-add now uses a plain native multiline field inside a padded surface |
| +with a thin inset focus outline, avoiding the rounded field's clipped outer |
| +focus ring. Expanded quick-add remains scrollable; panel height is bounded by |
| +the screen's available height while retaining space for the day agenda. |
| + |
| +Validation: Mac build and signature verification; authenticated fixture renders |
| +for a timed day, the following all-day event, and an empty day. The optional |
| +NATIVE_REVIEW_DAY_SEQUENCE=1 review harness changes the same published selection |
| +used by the month buttons, asserts each result and captures the live view without |
| +reloading. This verifies reactive rendering, not physical mouse input. |
| +Artifacts: /tmp/calendar-menu-dynamic.png, its .all-day.png and .empty.png suffixes. |
diff --git a/native/apple/Sources/CalendarKit/CalendarMenuView.swift b/native/apple/Sources/CalendarKit/CalendarMenuView.swift| index bbac6a4..29048fa 100644 |
| --- a/native/apple/Sources/CalendarKit/CalendarMenuView.swift |
| +++ b/native/apple/Sources/CalendarKit/CalendarMenuView.swift |
| @@ -9,6 +9,9 @@ public struct CalendarMenuView: View { |
| @Environment(\.openWindow) private var openWindow |
| @Environment(\.openSettings) private var openSettings |
| public init(client: NativeHTTP, model: CalendarModel) { self.client = client; self.model = model } |
| + private var quickExpanded: Bool { !model.quickText.isEmpty || !model.quickSavedNotice.isEmpty } |
| + private var panelHeight: CGFloat { min(quickExpanded ? 760 : 640, (NSScreen.main?.visibleFrame.height ?? 800) - 16) } |
| + private var quickHeight: CGFloat { quickExpanded ? min(220, max(84, panelHeight - 530)) : 84 } |
| public var body: some View { |
| VStack(spacing: 0) { |
| HStack { |
| @@ -17,37 +20,54 @@ public struct CalendarMenuView: View { |
| Button("Open Calendar", systemImage: "arrow.up.forward.app") { openWindow(id: "calendar"); NSApplication.shared.activate(ignoringOtherApps: true) }.labelStyle(.iconOnly) |
| Button("Settings", systemImage: "gear") { openSettings(); NSApplication.shared.activate(ignoringOtherApps: true) }.labelStyle(.iconOnly) |
| }.padding() |
| - ScrollView { |
| - VStack(alignment: .leading, spacing: 12) { |
| - CalendarMonthView(model: model).controlSize(.small) |
| - Text(model.display(model.date, template: "EEEE d MMMM")).font(.headline) |
| - if model.loading { ProgressView("Loading events…") } |
| - if !model.error.isEmpty { |
| - Text(model.error).foregroundStyle(.secondary) |
| - Button("Try again") { Task { await model.load(client) } } |
| - } else if model.dayEvents(model.date).isEmpty && !model.loading { |
| - Text("No events on this day").foregroundStyle(.secondary) |
| - } |
| - ForEach(model.dayEvents(model.date), id: \.identity) { event in |
| - VStack(alignment: .leading, spacing: 4) { |
| - Text(event.title).font(.headline).strikethrough(event.status == "cancelled") |
| - Text(event.all_day ? "All day" : model.display(Date(timeIntervalSince1970: Double(event.start)), template: "jm")) |
| - Text(event.calendar_name).font(.caption).foregroundStyle(.secondary) |
| - if event.status == "cancelled" { Label("Cancelled", systemImage: "xmark.circle") } |
| - if event.status == "tentative" { Label("Tentative", systemImage: "questionmark.circle") } |
| - }.accessibilityElement(children: .combine) |
| - Divider() |
| - } |
| - }.padding(.horizontal) |
| - } |
| + CalendarMonthView(model: model).controlSize(.small).padding(.horizontal) |
| + Divider() |
| + HStack { |
| + Text(model.display(model.date, template: "EEEE d MMMM")).font(.headline) |
| + Spacer() |
| + if !model.loading && model.error.isEmpty { |
| + Text("\(model.dayEvents(model.date).count)").foregroundStyle(.secondary) |
| + .accessibilityLabel("\(model.dayEvents(model.date).count) events") |
| + } |
| + }.padding(.horizontal).padding(.vertical, 10) |
| + ScrollViewReader { scroll in |
| + ScrollView { |
| + VStack(alignment: .leading, spacing: 10) { |
| + Color.clear.frame(height: 0).id("agendaTop") |
| + if model.loading { ProgressView("Loading events…") } |
| + if !model.error.isEmpty { |
| + Text(model.error).foregroundStyle(.secondary) |
| + Button("Try again") { Task { await model.load(client) } } |
| + } else if model.dayEvents(model.date).isEmpty && !model.loading { |
| + Text("No events on this day").foregroundStyle(.secondary) |
| + } |
| + ForEach(model.dayEvents(model.date), id: \.identity) { event in |
| + HStack(alignment: .top, spacing: 10) { |
| + RoundedRectangle(cornerRadius: 2).fill(Color.accentColor).frame(width: 3) |
| + VStack(alignment: .leading, spacing: 4) { |
| + Text(event.title).font(.headline).strikethrough(event.status == "cancelled") |
| + Text(event.all_day ? "All day" : model.display(Date(timeIntervalSince1970: Double(event.start)), template: "jm")) |
| + Text(event.calendar_name).font(.caption).foregroundStyle(.secondary) |
| + if event.status == "cancelled" { Label("Cancelled", systemImage: "xmark.circle") } |
| + if event.status == "tentative" { Label("Tentative", systemImage: "questionmark.circle") } |
| + } |
| + Spacer(minLength: 0) |
| + }.fixedSize(horizontal: false, vertical: true) |
| + .accessibilityElement(children: .combine) |
| + Divider() |
| + } |
| + }.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal) |
| + }.accessibilityIdentifier("menuDayAgenda") |
| + .onChange(of: model.date) { _, _ in scroll.scrollTo("agendaTop", anchor: .top) } |
| + }.frame(minHeight: 100) |
| Divider().padding(.vertical, 8) |
| - QuickAddView(client: client, model: model).frame(height: model.quickText.isEmpty && model.quickSavedNotice.isEmpty ? 84 : 220).padding(.horizontal) |
| + QuickAddView(client: client, model: model).frame(height: quickHeight).padding(.horizontal) |
| HStack { |
| Text(model.zone).font(.caption).foregroundStyle(.secondary) |
| Spacer() |
| Button("Quit Calendar") { NSApplication.shared.terminate(nil) } |
| }.padding() |
| - }.frame(width: 380, height: 640) |
| + }.frame(width: 380, height: panelHeight) |
| .onAppear { Task { await model.load(client) } } |
| .onChange(of: model.monthStart) { _, _ in Task { await model.load(client) } } |
| .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in |
diff --git a/native/apple/Sources/CalendarKit/QuickAddView.swift b/native/apple/Sources/CalendarKit/QuickAddView.swift| index 84c3461..1aef824 100644 |
| --- a/native/apple/Sources/CalendarKit/QuickAddView.swift |
| +++ b/native/apple/Sources/CalendarKit/QuickAddView.swift |
| @@ -25,7 +25,18 @@ struct QuickAddView: View { |
| ScrollView { |
| VStack(alignment: .leading, spacing: 12) { |
| TextField("Lunch tomorrow at 1pm for an hour", text: $model.quickText, axis: .vertical) |
| - .textFieldStyle(.roundedBorder).lineLimit(2...4) |
| + .lineLimit(2...4) |
| + #if os(macOS) |
| + .textFieldStyle(.plain) |
| + .padding(10) |
| + .background(.background, in: RoundedRectangle(cornerRadius: 8)) |
| + .overlay { |
| + RoundedRectangle(cornerRadius: 8) |
| + .strokeBorder(focused ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1) |
| + } |
| + #else |
| + .textFieldStyle(.roundedBorder) |
| + #endif |
| .accessibilityLabel("Describe an event").accessibilityIdentifier("quickAddText") |
| .focused($focused).disabled(model.quickSaving) |
| Text(model.zone.replacingOccurrences(of: "_", with: " ")).font(.footnote).foregroundStyle(.secondary) |
diff --git a/native/apple/Sources/CalendarReview/Review.swift b/native/apple/Sources/CalendarReview/Review.swift| index 055c468..32f754d 100644 |
| --- a/native/apple/Sources/CalendarReview/Review.swift |
| +++ b/native/apple/Sources/CalendarReview/Review.swift |
| @@ -25,11 +25,27 @@ struct Input: Decodable { let origin: URL; let credential: NativeSession; let ou |
| window.center() |
| window.orderFrontRegardless() |
| try await Task.sleep(for: .seconds(2)) |
| - let capture = Process() |
| - capture.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") |
| - capture.arguments = ["-x", "-o", "-l", String(window.windowNumber), input.output] |
| - try capture.run() |
| - capture.waitUntilExit() |
| - guard capture.terminationStatus == 0 else { fatalError("Window capture failed") } |
| + func capture(_ output: String) throws { |
| + let capture = Process() |
| + capture.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") |
| + capture.arguments = ["-x", "-o", "-l", String(window.windowNumber), output] |
| + try capture.run() |
| + capture.waitUntilExit() |
| + guard capture.terminationStatus == 0 else { fatalError("Window capture failed") } |
| + } |
| + try capture(input.output) |
| + if menu && ProcessInfo.processInfo.environment["NATIVE_REVIEW_DAY_SEQUENCE"] == "1" { |
| + // Exercise the same published selection updated by the month buttons, |
| + // without reloading: timed day -> all-day event -> empty day. |
| + guard model.dayEvents(model.date).contains(where: { !$0.all_day }) else { fatalError("Missing timed fixture") } |
| + model.date = model.calendar.date(byAdding: .day, value: 1, to: model.date)! |
| + try await Task.sleep(for: .seconds(1)) |
| + guard model.dayEvents(model.date).contains(where: { $0.all_day }) else { fatalError("Missing all-day fixture") } |
| + try capture(input.output + ".all-day.png") |
| + model.date = model.calendar.date(byAdding: .day, value: 1, to: model.date)! |
| + try await Task.sleep(for: .seconds(1)) |
| + guard model.dayEvents(model.date).isEmpty else { fatalError("Empty day retained events") } |
| + try capture(input.output + ".empty.png") |
| + } |
| } |
| } |