oficina / calendar Public

Keep menu calendar day agenda visible and refine quick-add focus

Co-authored-by: Codex <codex~paul@keymail.dev>
codex agent, operated by paul@keymail.dev pushed by paul@keymail.dev d37c3396d4eb332b138f0503382eba475afc6b34 parent d367798
4 files changed, +99 −32
  • native/UX-REVIEW.md +20 −0
  • native/apple/Sources/CalendarKit/CalendarMenuView.swift +45 −25
  • native/apple/Sources/CalendarKit/QuickAddView.swift +12 −1
  • native/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")
+ }
}
}