oficina / calendar Public

Refine native calendar layout and accessible date navigation

Co-Authored-By: Codex <codex~paul@keymail.dev>
codex agent, operated by paul@keymail.dev 1db628c6773c8342b104683bf0df773928753b88 parent 8c90813
4 files changed, +128 −31
  • internal/calendar/native_test.go +1 −1
  • native/UX-REVIEW.md +57 −0
  • native/apple/Sources/CalendarKit/CalendarWorkspace.swift +69 −29
  • native/apple/Sources/CalendarKit/EventEditor.swift +1 −1
diff --git a/internal/calendar/native_test.go b/internal/calendar/native_test.go
index 0c847d6..73c8bc7 100644
--- a/internal/calendar/native_test.go
+++ b/internal/calendar/native_test.go
@@ -102,7 +102,7 @@ func TestNativeCalendarRoundTrip(t *testing.T) {
defer os.Remove(path)
if os.Getenv("NATIVE_REVIEW_UI_TESTS") != "" {
- command := exec.CommandContext(t.Context(), "xcodebuild", "-project", "native/ios/CalendarPhone.xcodeproj", "-scheme", "CalendarReviewPhone", "-destination", "platform=iOS Simulator,id="+simulator, "-derivedDataPath", "native/build/ios", "CODE_SIGNING_ALLOWED=NO", "test")
+ command := exec.CommandContext(t.Context(), "xcodebuild", "-project", "native/ios/CalendarPhone.xcodeproj", "-scheme", "CalendarReviewPhone", "-destination", "platform=iOS Simulator,id="+simulator, "-parallel-testing-enabled", "NO", "-derivedDataPath", "native/build/ios", "CODE_SIGNING_ALLOWED=NO", "test")
command.Dir = "../.."
output, err := command.CombinedOutput()
_ = os.WriteFile(filepath.Join(os.TempDir(), "calendar-native-ui-tests.log"), output, 0600)
diff --git a/native/UX-REVIEW.md b/native/UX-REVIEW.md
new file mode 100644
index 0000000..aa38e0e
--- /dev/null
+++ b/native/UX-REVIEW.md
@@ -0,0 +1,57 @@
+# Calendar UX review · 12 September 2026
+
+## CalendarWorkspace.swift
+
+1. **High: keep the agenda reachable at large text sizes.** The month occupied
+ a fixed sibling above the scrolling agenda. It now shares one scrolling List
+ with the agenda. Native phone navigation uses an inline title to give the
+ calendar more room. The selected calendar name appears in the title.
+
+ Before: `VStack { monthGrid; List { agenda } }`
+ After: `List { Section { monthHeader; monthGrid }; agenda }`
+
+ An intermediate LazyVGrid-in-List version crashed in the iOS collection-view
+ layout pass. The final month uses an eager Grid with six GridRows; the
+ subsequent create/inspect/edit XCTest passed. The 42 date identities persist.
+
+2. **Medium: distinguish today without relying on colour.** Today's date is
+ bold and outlined; VoiceOver reports Today and event count. Selection retains
+ the selected trait and uses the Calendar rose, fixing a mismatched blue.
+ Event strips use their calendar's hue and retain the calendar's written name.
+
+ Before: `.background(selected ? Color.accentColor : .clear)`
+ After: rose selection plus today outline/bold and accessible event counts.
+
+3. **Medium: let month controls adapt.** ViewThatFits moves controls below the
+ month title when horizontal space is insufficient; buttons retain 44-point
+ minimum targets. At accessibility sizes weekday initials preserve grid space,
+ while every date's accessible label contains the complete day/date.
+
+4. **Medium: disclose exclusive all-day ends.** Inspection previously labelled
+ the stored exclusive date Ends, suggesting one extra day of duration. It now
+ says Ends before and explains the boundary, matching the editor. All-day
+ inspection omits the irrelevant time-zone row. Stored dates are unchanged.
+
+## EventEditor.swift
+
+**Medium: label the editable notes field for assistive technology.**
+
+Before: `TextEditor(text: $event.notes)`
+After: `TextEditor(text: $event.notes).accessibilityLabel("Notes")`
+
+Priority addressed: agenda reachability and layout stability, then date meaning,
+control adaptation and editor accessibility.
+
+## Method and validation
+
+Applied `swiftui-pro` design, accessibility and navigation references, plus
+`swiftui-native-component-design` semantics/control/accessibility guidance.
+Reviewed actual SwiftUI renders on Mac, iPhone and iPad; checked light mode and
+accessibility-large Dynamic Type in dark mode on iPhone. Real forms/navigation
+are exercised through the isolated authenticated Go fixture and XCTest.
+No backend, auth, model retrieval or permission behavior changed.
+
+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.
diff --git a/native/apple/Sources/CalendarKit/CalendarWorkspace.swift b/native/apple/Sources/CalendarKit/CalendarWorkspace.swift
index fac4088..3dbab40 100644
--- a/native/apple/Sources/CalendarKit/CalendarWorkspace.swift
+++ b/native/apple/Sources/CalendarKit/CalendarWorkspace.swift
@@ -10,7 +10,8 @@ import SwiftUI
@State private var inspecting: CalendarEvent?
@State private var filter: Int64? = 0
@State private var column: NavigationSplitViewColumn = .detail
- @Environment(\.openURL) private var openURL
+ @Environment(\.dynamicTypeSize) private var typeSize
+ private let accent = Color(red: 0.78, green: 0.22, blue: 0.29)
public init(client: NativeHTTP, model: CalendarModel? = nil, signOut: @escaping () -> Void) { self.client = client; self.signOut = signOut; _model = StateObject(wrappedValue: model ?? CalendarModel()) }
public var body: some View {
@@ -30,9 +31,13 @@ import SwiftUI
}
}.navigationTitle("Calendar").navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 340)
} detail: {
- VStack(spacing: 0) {
- monthHeader.padding()
- monthGrid.padding(.horizontal)
+ List {
+ Section {
+ VStack(spacing: 0) {
+ monthHeader
+ monthGrid
+ }.listRowSeparator(.hidden)
+ }
if !model.notice.isEmpty { Label(model.notice, systemImage: "bell.badge").font(.callout).padding() }
if model.loading { ProgressView().padding(8) }
if !model.error.isEmpty {
@@ -40,7 +45,6 @@ import SwiftUI
description: Text(model.error))
Button("Try again") { Task { await model.load(client) } }.padding()
} else {
- List {
Section(model.display(model.date, template: "EEEE d MMMM")) {
let events = model.dayEvents(model.date)
if events.isEmpty {
@@ -52,7 +56,7 @@ import SwiftUI
inspecting = event
} label: {
HStack(alignment: .top, spacing: 14) {
- RoundedRectangle(cornerRadius: 2).fill(.tint).frame(width: 4)
+ RoundedRectangle(cornerRadius: 2).fill(calendarColor(event.calendar_id)).frame(width: 4)
VStack(alignment: .leading, spacing: 5) {
Text(event.title).font(.headline).foregroundStyle(.primary).strikethrough(event.status == "cancelled")
if event.status == "cancelled" { Label("Cancelled", systemImage: "xmark.circle").font(.caption).foregroundStyle(.secondary) }
@@ -67,10 +71,12 @@ import SwiftUI
}.buttonStyle(.plain)
}
}
- }.listStyle(.plain).refreshable { await model.load(client) }
}
- }
- .navigationTitle("Calendar")
+ }.listStyle(.plain).refreshable { await model.load(client) }
+ .navigationTitle(model.calendars.first(where: { $0.id == model.selectedCalendar })?.name ?? "Calendar")
+ #if os(iOS)
+ .navigationBarTitleDisplayMode(.inline)
+ #endif
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("New event", systemImage: "plus") { editing = model.newEvent() }
@@ -92,8 +98,9 @@ import SwiftUI
if event.status == "tentative" { Section { Label("Tentative", systemImage: "questionmark.circle") } }
Section("When") {
LabeledContent("Starts", value: event.all_day ? event.day_start : model.display(Date(timeIntervalSince1970: Double(event.start)), template: event.all_day ? "d MMM yyyy" : "d MMM yyyy jm", zone: event.tz))
- LabeledContent("Ends", value: event.all_day ? event.day_end : model.display(Date(timeIntervalSince1970: Double(event.end)), template: event.all_day ? "d MMM yyyy" : "d MMM yyyy jm", zone: event.tz))
- LabeledContent("Time zone", value: event.tz)
+ LabeledContent(event.all_day ? "Ends before" : "Ends", value: event.all_day ? event.day_end : model.display(Date(timeIntervalSince1970: Double(event.end)), template: event.all_day ? "d MMM yyyy" : "d MMM yyyy jm", zone: event.tz))
+ if event.all_day { Text("The end date is the first day after the event.").font(.callout).foregroundStyle(.secondary) }
+ else { LabeledContent("Time zone", value: event.tz) }
if event.recurring { Label("Repeating event", systemImage: "repeat") }
}
if !event.location.isEmpty { Section("Location") { Text(event.location).textSelection(.enabled) } }
@@ -113,31 +120,64 @@ import SwiftUI
}
private var monthHeader: some View {
+ ViewThatFits(in: .horizontal) {
+ HStack {
+ Text(model.display(model.monthStart, template: "MMMM yyyy")).font(.title2.bold())
+ Spacer()
+ monthControls
+ }
+ VStack(alignment: .leading) {
+ Text(model.display(model.monthStart, template: "MMMM yyyy")).font(.title2.bold())
+ monthControls
+ }
+ }.padding(.vertical)
+ }
+ private var monthControls: some View {
HStack {
- Text(model.display(model.monthStart, template: "MMMM yyyy")).font(.title2.bold())
- Spacer()
Button("Previous month", systemImage: "chevron.left") { move(-1) }.labelStyle(.iconOnly).frame(minWidth: 44, minHeight: 44)
- Button("Today") { model.date = Date() }
+ Button("Today") { model.date = Date() }.frame(minHeight: 44)
Button("Next month", systemImage: "chevron.right") { move(1) }.labelStyle(.iconOnly).frame(minWidth: 44, minHeight: 44)
- }.buttonStyle(.borderless).controlSize(.large)
+ }.buttonStyle(.borderless)
+ }
+ private func calendarColor(_ id: Int64) -> Color {
+ guard let calendar = model.calendars.first(where: { $0.id == id }) else { return accent }
+ return Color(hue: Double(calendar.hue % 360) / 360, saturation: 0.6, brightness: 0.75)
}
private func move(_ amount: Int) { model.date = model.calendar.date(byAdding: .month, value: amount, to: model.date) ?? model.date }
private var monthGrid: some View {
- LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 7), spacing: 4) {
- ForEach(model.weekdayLabels, id: \.self) { Text($0).font(.caption).foregroundStyle(.secondary) }
- ForEach(model.days, id: \.self) { day in
- let selected = model.calendar.isDate(day, inSameDayAs: model.date)
- let current = model.calendar.isDate(day, equalTo: model.monthStart, toGranularity: .month)
- Button { model.date = day } label: {
- VStack(spacing: 3) {
- Text("\(model.calendar.component(.day, from: day))").font(.body.monospacedDigit())
- Circle().fill(model.dayEvents(day).isEmpty ? .clear : selected ? .white : Color.accentColor).frame(width: 4, height: 4)
- }.frame(maxWidth: .infinity, minHeight: 44)
- .foregroundStyle(selected ? .white : current ? Color.primary : Color.secondary)
- .background(selected ? Color.accentColor : .clear, in: RoundedRectangle(cornerRadius: 9))
- }.buttonStyle(.plain).accessibilityLabel(model.display(day, template: "EEEE d MMMM yyyy"))
- .accessibilityAddTraits(selected ? .isSelected : [])
+ Grid(horizontalSpacing: 4, verticalSpacing: 4) {
+ GridRow {
+ ForEach(Array(model.weekdayLabels.enumerated()), id: \.offset) { _, label in
+ Text(typeSize.isAccessibilitySize ? String(label.prefix(1)) : label)
+ .font(.caption).foregroundStyle(.secondary).accessibilityHidden(true)
+ }
+ }
+ ForEach(0..<6) { week in
+ GridRow {
+ ForEach(Array(model.days.dropFirst(week * 7).prefix(7)), id: \.self) { day in
+ dayButton(day)
+ }
+ }
}
}.padding(.bottom, 12)
}
+ private func dayButton(_ day: Date) -> some View {
+ let selected = model.calendar.isDate(day, inSameDayAs: model.date)
+ let today = model.calendar.isDate(day, inSameDayAs: Date())
+ let count = model.dayEvents(day).count
+ let current = model.calendar.isDate(day, equalTo: model.monthStart, toGranularity: .month)
+ return Button { model.date = day } label: {
+ VStack(spacing: 3) {
+ Text("\(model.calendar.component(.day, from: day))")
+ .font(.body.monospacedDigit()).bold(today).lineLimit(1).minimumScaleFactor(0.7)
+ Circle().fill(count == 0 ? .clear : selected ? .white : accent).frame(width: 4, height: 4)
+ }.frame(maxWidth: .infinity, minHeight: 44)
+ .foregroundStyle(selected ? .white : current ? Color.primary : Color.secondary)
+ .background(selected ? accent : .clear, in: RoundedRectangle(cornerRadius: 9))
+ .overlay { RoundedRectangle(cornerRadius: 9).strokeBorder(today ? accent : .clear, lineWidth: 2) }
+ }.buttonStyle(.plain)
+ .accessibilityLabel(model.display(day, template: "EEEE d MMMM yyyy"))
+ .accessibilityValue("\(today ? "Today. " : "")\(count) events")
+ .accessibilityAddTraits(selected ? .isSelected : [])
+ }
}
diff --git a/native/apple/Sources/CalendarKit/EventEditor.swift b/native/apple/Sources/CalendarKit/EventEditor.swift
index f23e590..dd3ab09 100644
--- a/native/apple/Sources/CalendarKit/EventEditor.swift
+++ b/native/apple/Sources/CalendarKit/EventEditor.swift
@@ -47,7 +47,7 @@ struct EventEditor: View {
LabeledContent("Time zone", value: event.tz)
if event.all_day { Text("The end date is the first day after this event.").font(.footnote).foregroundStyle(.secondary) }
}
- Section("Notes") { TextEditor(text: $event.notes).frame(minHeight: 100) }
+ Section("Notes") { TextEditor(text: $event.notes).frame(minHeight: 100).accessibilityLabel("Notes") }
if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } }
}.formStyle(.grouped).environment(\.timeZone, TimeZone(identifier: event.tz) ?? .gmt)
.navigationTitle(event.id == 0 ? "New event" : "Edit event")