krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

Commit d75d05b065

d75d05b065710e481f34d56cf3585ad807c4c106

parent: 69c34c3ded

Unsigned

cmc <hello@cleberg.net> · 2026-10-18 06:51 UTC

Contrast and larger text (!20)

- Night red brightened to 6.2:1 on black; dimmed secondary text never below 90% in night mode (5.1:1) and undimmed with Increase Contrast. Day-mode dimmed text is 4.8:1 or better.
- Canvas labels (sky, orrery, aurora map, Library drawings) use text styles; edge labels flip; time labels fit; orrery labels capped at xxLarge with the spacecraft list stacking at accessibility sizes.

Checked in the simulator at the "accessibility large" text size with Increase Contrast on. Audit: 44 issues, of which the contrast flags are text under the translucent tab bar and the system Done button, and the Dynamic Type notes also appear on system controls.

Layout: unified · split

App/Sources/Dimmed.swift added +21
@@ -0,0 +1,21 @@
1import SwiftUI
2
3extension EnvironmentValues {
4 /// True while the Sky tab draws in night mode; dimmed text stays above 90% so red text keeps AA contrast.
5 @Entry var nightMode = false
6}
7
8/// Secondary text: dimmed by `opacity`, but never below 90% in night mode and not at all with Increase Contrast.
9struct Dimmed: ViewModifier {
10 var opacity: Double
11 @Environment(\.colorSchemeContrast) private var contrast
12 @Environment(\.nightMode) private var night
13
14 func body(content: Content) -> some View {
15 content.opacity(contrast == .increased ? 1 : night ? max(opacity, 0.9) : opacity)
16 }
17}
18
19extension View {
20 func dimmed(_ opacity: Double) -> some View { modifier(Dimmed(opacity: opacity)) }
21}
App/Sources/LaunchesScreen.swift +8 −8
@@ -30,7 +30,7 @@ struct LaunchesScreen: View {
30 .buttonStyle(.plain) 30 .buttonStyle(.plain)
31 } 31 }
32 Text("Launch data: The Space Devs, Launch Library 2.") 32 Text("Launch data: The Space Devs, Launch Library 2.")
33 .font(.caption2).opacity(0.5).padding(.vertical, 16) 33 .font(.caption2).dimmed(0.5).padding(.vertical, 16)
34 } else if error == nil { 34 } else if error == nil {
35 ProgressView().tint(ink).padding(.top, 40).frame(maxWidth: .infinity) 35 ProgressView().tint(ink).padding(.top, 40).frame(maxWidth: .infinity)
36 } 36 }
@@ -57,16 +57,16 @@ struct LaunchesScreen: View {
57 private func row(_ l: Launch, index: Int) -> some View { 57 private func row(_ l: Launch, index: Int) -> some View {
58 VStack(alignment: .leading, spacing: 6) { 58 VStack(alignment: .leading, spacing: 6) {
59 HStack(alignment: .firstTextBaseline) { 59 HStack(alignment: .firstTextBaseline) {
60 Text(String(format: "%02d", index)).font(.caption2.monospacedDigit()).opacity(0.5) 60 Text(String(format: "%02d", index)).font(.caption2.monospacedDigit()).dimmed(0.5)
61 Text(l.net.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())) 61 Text(l.net.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()))
62 .font(.caption2).opacity(0.7) 62 .font(.caption2).dimmed(0.7)
63 Spacer() 63 Spacer()
64 StatusTag(status: l.status, ink: ink, accent: accent) 64 StatusTag(status: l.status, ink: ink, accent: accent)
65 } 65 }
66 Text(l.rocket).font(.title2.weight(.medium)) 66 Text(l.rocket).font(.title2.weight(.medium))
67 if let m = l.mission { Text(m).font(.subheadline) } 67 if let m = l.mission { Text(m).font(.subheadline) }
68 HStack { 68 HStack {
69 Text("\(l.provider) · \(l.location)").font(.caption2).opacity(0.7) 69 Text("\(l.provider) · \(l.location)").font(.caption2).dimmed(0.7)
70 Spacer() 70 Spacer()
71 Countdown(to: l.net).font(.footnote.weight(.medium).monospacedDigit()) 71 Countdown(to: l.net).font(.footnote.weight(.medium).monospacedDigit())
72 } 72 }
@@ -121,7 +121,7 @@ struct LaunchDetail: View {
121 VStack(alignment: .leading, spacing: 10) { 121 VStack(alignment: .leading, spacing: 10) {
122 HStack { 122 HStack {
123 StatusTag(status: launch.status, ink: ink, accent: accent) 123 StatusTag(status: launch.status, ink: ink, accent: accent)
124 Text(launch.statusName).font(.caption).opacity(0.7) 124 Text(launch.statusName).font(.caption).dimmed(0.7)
125 } 125 }
126 Text(launch.rocket).font(.title.weight(.medium)) 126 Text(launch.rocket).font(.title.weight(.medium))
127 if let m = launch.mission { Text(m).font(.body) } 127 if let m = launch.mission { Text(m).font(.body) }
@@ -139,9 +139,9 @@ struct LaunchDetail: View {
139 } 139 }
140 .font(.subheadline) 140 .font(.subheadline)
141 followButton.padding(.top, 8) 141 followButton.padding(.top, 8)
142 if let message { Text(message).font(.caption).opacity(0.7) } 142 if let message { Text(message).font(.caption).dimmed(0.7) }
143 if let d = launch.missionDescription { 143 if let d = launch.missionDescription {
144 Text(d).font(.subheadline).opacity(0.85).padding(.top, 8) 144 Text(d).font(.subheadline).dimmed(0.85).padding(.top, 8)
145 } 145 }
146 } 146 }
147 .padding(20) 147 .padding(20)
@@ -200,7 +200,7 @@ struct LaunchDetail: View {
200 200
201 private func fact(_ label: String, _ value: String) -> some View { 201 private func fact(_ label: String, _ value: String) -> some View {
202 GridRow { 202 GridRow {
203 Text(label).opacity(0.6) 203 Text(label).dimmed(0.6)
204 Text(value) 204 Text(value)
205 } 205 }
206 } 206 }
App/Sources/LibraryScreen.swift +10 −10
@@ -19,7 +19,7 @@ struct LibraryScreen: View {
19 HStack { 19 HStack {
20 Text(section.title).font(.body) 20 Text(section.title).font(.body)
21 Spacer() 21 Spacer()
22 Text("\(pages.count)").font(.footnote.monospacedDigit()).opacity(0.5) 22 Text("\(pages.count)").font(.footnote.monospacedDigit()).dimmed(0.5)
23 } 23 }
24 } 24 }
25 .listRowBackground(pageBackground) 25 .listRowBackground(pageBackground)
@@ -66,7 +66,7 @@ struct PageRow: View {
66 var body: some View { 66 var body: some View {
67 VStack(alignment: .leading, spacing: 2) { 67 VStack(alignment: .leading, spacing: 2) {
68 Text(page.title).font(.body) 68 Text(page.title).font(.body)
69 Text(page.subtitle).font(.caption).opacity(0.7) 69 Text(page.subtitle).font(.caption).dimmed(0.7)
70 } 70 }
71 .padding(.vertical, 2) 71 .padding(.vertical, 2)
72 } 72 }
@@ -78,9 +78,9 @@ struct LibraryPageView: View {
78 var body: some View { 78 var body: some View {
79 ScrollView { 79 ScrollView {
80 VStack(alignment: .leading, spacing: 0) { 80 VStack(alignment: .leading, spacing: 0) {
81 Text(page.section.title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).opacity(0.6) 81 Text(page.section.title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
82 Text(page.title).font(.largeTitle.weight(.medium)).padding(.top, 4) 82 Text(page.title).font(.largeTitle.weight(.medium)).padding(.top, 4)
83 Text(page.subtitle).font(.subheadline).opacity(0.75).padding(.top, 2) 83 Text(page.subtitle).font(.subheadline).dimmed(0.75).padding(.top, 2)
84 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 16) 84 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 16)
85 85
86 PageDrawing(page: page, ink: ink, accent: accent) 86 PageDrawing(page: page, ink: ink, accent: accent)
@@ -108,7 +108,7 @@ struct LibraryPageView: View {
108 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { 108 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
109 ForEach(page.facts, id: \.self) { f in 109 ForEach(page.facts, id: \.self) { f in
110 GridRow(alignment: .firstTextBaseline) { 110 GridRow(alignment: .firstTextBaseline) {
111 Text(f.label).font(.footnote).opacity(0.6) 111 Text(f.label).font(.footnote).dimmed(0.6)
112 Text(f.value).font(.subheadline.monospacedDigit()) 112 Text(f.value).font(.subheadline.monospacedDigit())
113 } 113 }
114 .accessibilityElement(children: .combine) 114 .accessibilityElement(children: .combine)
@@ -122,23 +122,23 @@ struct LibraryPageView: View {
122 AsyncImage(url: image.url) { phase in 122 AsyncImage(url: image.url) { phase in
123 switch phase { 123 switch phase {
124 case .success(let img): img.resizable().scaledToFit() 124 case .success(let img): img.resizable().scaledToFit()
125 case .failure: Color.black.frame(height: 120).overlay(Text("Image unavailable").font(.caption).opacity(0.6)) 125 case .failure: Color.black.frame(height: 120).overlay(Text("Image unavailable").font(.caption).dimmed(0.6))
126 default: Color.black.frame(height: 200).overlay(ProgressView().tint(ink)) 126 default: Color.black.frame(height: 200).overlay(ProgressView().tint(ink))
127 } 127 }
128 } 128 }
129 Text(image.caption).font(.footnote).opacity(0.75) 129 Text(image.caption).font(.footnote).dimmed(0.75)
130 Link(destination: image.page) { 130 Link(destination: image.page) {
131 Text("\(image.credit) · \(image.licence)").font(.caption2).opacity(0.55) 131 Text("\(image.credit) · \(image.licence)").font(.caption2).dimmed(0.55)
132 } 132 }
133 } 133 }
134 } 134 }
135 135
136 private var sources: some View { 136 private var sources: some View {
137 VStack(alignment: .leading, spacing: 6) { 137 VStack(alignment: .leading, spacing: 6) {
138 Text("Sources").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).opacity(0.6) 138 Text("Sources").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
139 ForEach(Array(page.sources.enumerated()), id: \.offset) { i, s in 139 ForEach(Array(page.sources.enumerated()), id: \.offset) { i, s in
140 Link(destination: s.url) { 140 Link(destination: s.url) {
141 Text("\(i + 1). \(s.title)").font(.caption).underline().opacity(0.8) 141 Text("\(i + 1). \(s.title)").font(.caption).underline().dimmed(0.8)
142 } 142 }
143 } 143 }
144 } 144 }
App/Sources/MoonriseApp.swift +2 −1
@@ -1,3 +1,4 @@
1import SkyRenderer
1import SwiftUI 2import SwiftUI
2import UIKit 3import UIKit
3import UserNotifications 4import UserNotifications
@@ -22,7 +23,7 @@ struct MoonriseApp: App {
22 Tab("Library", systemImage: "books.vertical", value: "library") { LibraryScreen() } 23 Tab("Library", systemImage: "books.vertical", value: "library") { LibraryScreen() }
23 } 24 }
24 // Red tab highlight while the Sky tab is in night mode. 25 // Red tab highlight while the Sky tab is in night mode.
25 .tint(tab == "sky" && nightActive ? Color(red: 0.85, green: 0.12, blue: 0.08) : nil) 26 .tint(tab == "sky" && nightActive ? SkyStyle.nightRed : nil)
26 .preferredColorScheme(.dark) 27 .preferredColorScheme(.dark)
27 .fullScreenCover(isPresented: .constant(!onboarded)) { 28 .fullScreenCover(isPresented: .constant(!onboarded)) {
28 OnboardingScreen().preferredColorScheme(.dark) 29 OnboardingScreen().preferredColorScheme(.dark)
App/Sources/PageDrawing.swift +6 −6
@@ -55,7 +55,7 @@ struct PageDrawing: View {
55 if page.id != "earth" { 55 if page.id != "earth" {
56 let ec = CGPoint(x: size.width - er - 6, y: size.height - er - 16) 56 let ec = CGPoint(x: size.width - er - 6, y: size.height - er - 16)
57 context.fill(Path(ellipseIn: CGRect(x: ec.x - er, y: ec.y - er, width: 2 * er, height: 2 * er)), with: .color(Color(red: 0.35, green: 0.6, blue: 0.95))) 57 context.fill(Path(ellipseIn: CGRect(x: ec.x - er, y: ec.y - er, width: 2 * er, height: 2 * er)), with: .color(Color(red: 0.35, green: 0.6, blue: 0.95)))
58 context.draw(Text("Earth").font(.system(size: 9)).foregroundStyle(ink.opacity(0.6)), at: CGPoint(x: size.width - 2, y: size.height - 6), anchor: .trailing) 58 context.draw(Text("Earth").font(.caption2).foregroundStyle(ink.opacity(0.6)), at: CGPoint(x: size.width - 2, y: size.height - 6), anchor: .trailing)
59 } 59 }
60 } 60 }
61 .frame(height: 200) 61 .frame(height: 200)
@@ -121,7 +121,7 @@ struct PageDrawing: View {
121 return VStack(spacing: 8) { 121 return VStack(spacing: 8) {
122 Circle().fill(color).frame(width: 90, height: 90) 122 Circle().fill(color).frame(width: 90, height: 90)
123 if let sp = page.drawing?.spectralType { 123 if let sp = page.drawing?.spectralType {
124 Text("Spectral type \(sp) · B−V \(String(format: "%.2f", bv))").font(.system(size: 11)).opacity(0.6) 124 Text("Spectral type \(sp) · B−V \(String(format: "%.2f", bv))").font(.caption2).opacity(0.6)
125 } 125 }
126 } 126 }
127 .frame(height: 140) 127 .frame(height: 140)
@@ -151,8 +151,8 @@ struct PageDrawing: View {
151 context.fill(Path(ellipseIn: rect), with: .color(ink.opacity(0.3))) 151 context.fill(Path(ellipseIn: rect), with: .color(ink.opacity(0.3)))
152 } 152 }
153 context.stroke(Path(ellipseIn: CGRect(x: m.x - mr, y: m.y - mr, width: 2 * mr, height: 2 * mr)), with: .color(ink.opacity(0.5)), style: StrokeStyle(lineWidth: 0.8, dash: [3, 3])) 153 context.stroke(Path(ellipseIn: CGRect(x: m.x - mr, y: m.y - mr, width: 2 * mr, height: 2 * mr)), with: .color(ink.opacity(0.5)), style: StrokeStyle(lineWidth: 0.8, dash: [3, 3]))
154 context.draw(Text("\(Int(size.rounded()))′").font(.system(size: 10)).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: c.x, y: canvas.height - 10)) 154 context.draw(Text("\(Int(size.rounded()))′").font(.caption2).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: c.x, y: canvas.height - 10))
155 context.draw(Text("Full Moon").font(.system(size: 10)).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: m.x, y: canvas.height - 10)) 155 context.draw(Text("Full Moon").font(.caption2).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: m.x, y: canvas.height - 10))
156 } 156 }
157 .frame(height: 170) 157 .frame(height: 170)
158 .accessibilityLabel("\(page.title) apparent size compared with the full Moon") 158 .accessibilityLabel("\(page.title) apparent size compared with the full Moon")
@@ -164,11 +164,11 @@ struct PageDrawing: View {
164 let d = page.drawing! 164 let d = page.drawing!
165 return HStack(spacing: 10) { 165 return HStack(spacing: 10) {
166 Text((d.status ?? "").uppercased()) 166 Text((d.status ?? "").uppercased())
167 .font(.system(size: 10, weight: .medium)).tracking(0.6) 167 .font(.caption2.weight(.medium)).tracking(0.6)
168 .padding(.horizontal, 6).padding(.vertical, 2) 168 .padding(.horizontal, 6).padding(.vertical, 2)
169 .foregroundStyle(d.status == "active" ? Color.black : ink) 169 .foregroundStyle(d.status == "active" ? Color.black : ink)
170 .background(d.status == "active" ? ink : ink.opacity(0.15), in: .rect(cornerRadius: 2)) 170 .background(d.status == "active" ? ink : ink.opacity(0.15), in: .rect(cornerRadius: 2))
171 if let launch = d.launch { Text("Launched \(launch)").font(.system(size: 13).monospacedDigit()) } 171 if let launch = d.launch { Text("Launched \(launch)").font(.footnote.monospacedDigit()) }
172 Spacer() 172 Spacer()
173 } 173 }
174 } 174 }
App/Sources/SkyScreen.swift +7 −5
@@ -23,7 +23,7 @@ struct SkyScreen: View {
23 23
24 private var night: Bool { nightMode == "on" || (nightMode == "auto" && model.sunAltitude < -12) } 24 private var night: Bool { nightMode == "on" || (nightMode == "auto" && model.sunAltitude < -12) }
25 private var style: SkyStyle { night ? .night : SkyStyle() } 25 private var style: SkyStyle { night ? .night : SkyStyle() }
26 private var ink: Color { night ? Color(red: 0.85, green: 0.12, blue: 0.08) : Color(red: 0.95, green: 0.95, blue: 0.93) } 26 private var ink: Color { night ? SkyStyle.nightRed : Color(red: 0.95, green: 0.95, blue: 0.93) }
27 27
28 var body: some View { 28 var body: some View {
29 GeometryReader { geo in 29 GeometryReader { geo in
@@ -56,6 +56,7 @@ struct SkyScreen: View {
56 } 56 }
57 } 57 }
58 .foregroundStyle(ink) 58 .foregroundStyle(ink)
59 .environment(\.nightMode, night)
59 .sheet(isPresented: $showSettings) { 60 .sheet(isPresented: $showSettings) {
60 SettingsScreen { model.applyLocationSettings() } 61 SettingsScreen { model.applyLocationSettings() }
61 .preferredColorScheme(.dark) 62 .preferredColorScheme(.dark)
@@ -66,6 +67,7 @@ struct SkyScreen: View {
66 } 67 }
67 .sheet(isPresented: $showToday) { 68 .sheet(isPresented: $showToday) {
68 TodaySheet(model: model, ink: ink, background: style.ground) 69 TodaySheet(model: model, ink: ink, background: style.ground)
70 .environment(\.nightMode, night)
69 .presentationDetents([.medium, .large]) 71 .presentationDetents([.medium, .large])
70 } 72 }
71 .onChange(of: model.offsetHours) { select(selection) } 73 .onChange(of: model.offsetHours) { select(selection) }
@@ -126,11 +128,11 @@ struct SkyScreen: View {
126 Text(model.offsetHours == 0 ? "Tonight" : "Sky at \(model.displayedDate.formatted(date: .omitted, time: .shortened))") 128 Text(model.offsetHours == 0 ? "Tonight" : "Sky at \(model.displayedDate.formatted(date: .omitted, time: .shortened))")
127 .font(.title2.weight(.medium)) 129 .font(.title2.weight(.medium))
128 Text(model.placeName) 130 Text(model.placeName)
129 .font(.caption2).opacity(0.7) 131 .font(.caption2).dimmed(0.7)
130 #if DEBUG 132 #if DEBUG
131 TimelineView(.periodic(from: .now, by: 1)) { _ in 133 TimelineView(.periodic(from: .now, by: 1)) { _ in
132 Text(stats.summary(build: model.lastBuild)) 134 Text(stats.summary(build: model.lastBuild))
133 .font(.caption2.monospacedDigit()).opacity(0.7) 135 .font(.caption2.monospacedDigit()).dimmed(0.7)
134 } 136 }
135 #endif 137 #endif
136 } 138 }
@@ -177,7 +179,7 @@ struct SkyScreen: View {
177 case .body: 179 case .body:
178 if let d = details { bodyFacts(d) } else { ProgressView().tint(ink) } 180 if let d = details { bodyFacts(d) } else { ProgressView().tint(ink) }
179 case .constellation(let id): 181 case .constellation(let id):
180 Text("Constellation · \(id)").font(.footnote).opacity(0.7) 182 Text("Constellation · \(id)").font(.footnote).dimmed(0.7)
181 } 183 }
182 if let page = libraryPage(for: object) { 184 if let page = libraryPage(for: object) {
183 Button { libraryPage = page } label: { 185 Button { libraryPage = page } label: {
@@ -223,7 +225,7 @@ struct SkyScreen: View {
223 return Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 4) { 225 return Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 4) {
224 ForEach(rows, id: \.0) { row in 226 ForEach(rows, id: \.0) { row in
225 GridRow { 227 GridRow {
226 Text(row.0).opacity(0.7) 228 Text(row.0).dimmed(0.7)
227 Text(row.1).monospacedDigit() 229 Text(row.1).monospacedDigit()
228 } 230 }
229 .font(.footnote) 231 .font(.footnote)
App/Sources/SunScreen.swift +12 −12
@@ -28,8 +28,8 @@ struct SunScreen: View {
28 } 28 }
29 .pickerStyle(.segmented) 29 .pickerStyle(.segmented)
30 .padding(.top, 12) 30 .padding(.top, 12)
31 Text(wavelength.caption).font(.footnote).opacity(0.75).padding(.top, 8) 31 Text(wavelength.caption).font(.footnote).dimmed(0.75).padding(.top, 8)
32 Text(SolarImage.credit).font(.caption2).opacity(0.5).padding(.top, 4) 32 Text(SolarImage.credit).font(.caption2).dimmed(0.5).padding(.top, 4)
33 33
34 section("Aurora") { auroraMap } 34 section("Aurora") { auroraMap }
35 section("Solar flares, last 24 h") { flares } 35 section("Solar flares, last 24 h") { flares }
@@ -38,7 +38,7 @@ struct SunScreen: View {
38 if failed { 38 if failed {
39 Text("Couldn't load space weather. Pull to retry.").font(.footnote).foregroundStyle(accent).padding(.top, 12) 39 Text("Couldn't load space weather. Pull to retry.").font(.footnote).foregroundStyle(accent).padding(.top, 12)
40 } 40 }
41 Text("Space weather data: NOAA Space Weather Prediction Center. Coastlines: Natural Earth.").font(.caption2).opacity(0.5).padding(.vertical, 16) 41 Text("Space weather data: NOAA Space Weather Prediction Center. Coastlines: Natural Earth.").font(.caption2).dimmed(0.5).padding(.vertical, 16)
42 } 42 }
43 .padding(.horizontal, 20) 43 .padding(.horizontal, 20)
44 } 44 }
@@ -64,7 +64,7 @@ struct SunScreen: View {
64 AsyncImage(url: wavelength.url(size: 1024)) { phase in 64 AsyncImage(url: wavelength.url(size: 1024)) { phase in
65 switch phase { 65 switch phase {
66 case .success(let img): img.resizable().scaledToFit() 66 case .success(let img): img.resizable().scaledToFit()
67 case .failure: Color.black.overlay(Text("Image unavailable").font(.footnote).opacity(0.6)) 67 case .failure: Color.black.overlay(Text("Image unavailable").font(.footnote).dimmed(0.6))
68 default: Color.black.overlay(ProgressView().tint(ink)) 68 default: Color.black.overlay(ProgressView().tint(ink))
69 } 69 }
70 } 70 }
@@ -75,7 +75,7 @@ struct SunScreen: View {
75 75
76 private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View { 76 private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
77 VStack(alignment: .leading, spacing: 10) { 77 VStack(alignment: .leading, spacing: 10) {
78 Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).opacity(0.6) 78 Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
79 content() 79 content()
80 } 80 }
81 .padding(.top, 24) 81 .padding(.top, 24)
@@ -94,9 +94,9 @@ struct SunScreen: View {
94 .accessibilityElement() 94 .accessibilityElement()
95 .accessibilityLabel(auroraDescription(ovation)) 95 .accessibilityLabel(auroraDescription(ovation))
96 Text("Chance of visible aurora overhead, OVATION model, for \(ovation.forecastTime.formatted(date: .omitted, time: .shortened)). Brighter green is more likely.") 96 Text("Chance of visible aurora overhead, OVATION model, for \(ovation.forecastTime.formatted(date: .omitted, time: .shortened)). Brighter green is more likely.")
97 .font(.caption2).opacity(0.6) 97 .font(.caption2).dimmed(0.6)
98 } else { 98 } else {
99 Text("Loading").font(.footnote).opacity(0.6) 99 Text("Loading").font(.footnote).dimmed(0.6)
100 } 100 }
101 } 101 }
102 102
@@ -127,7 +127,7 @@ struct SunScreen: View {
127 .foregroundStyle(c.0 == "C" ? ink.opacity(0.25) : accent.opacity(0.6)) 127 .foregroundStyle(c.0 == "C" ? ink.opacity(0.25) : accent.opacity(0.6))
128 .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [3, 3])) 128 .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [3, 3]))
129 .annotation(position: .top, alignment: .leading) { 129 .annotation(position: .top, alignment: .leading) {
130 Text(c.0).font(.caption2).opacity(0.7) 130 Text(c.0).font(.caption2).dimmed(0.7)
131 } 131 }
132 } 132 }
133 } 133 }
@@ -141,14 +141,14 @@ struct SunScreen: View {
141 } 141 }
142 .frame(height: 160) 142 .frame(height: 160)
143 } else { 143 } else {
144 Text("Loading").font(.footnote).opacity(0.6) 144 Text("Loading").font(.footnote).dimmed(0.6)
145 } 145 }
146 } 146 }
147 147
148 @ViewBuilder 148 @ViewBuilder
149 private var kpChart: some View { 149 private var kpChart: some View {
150 if kp.isEmpty { 150 if kp.isEmpty {
151 Text("Loading").font(.footnote).opacity(0.6) 151 Text("Loading").font(.footnote).dimmed(0.6)
152 } else { 152 } else {
153 Chart(kp) { bar in 153 Chart(kp) { bar in
154 BarMark(x: .value("Start", bar.start, unit: .hour), y: .value("Kp", bar.kp), width: .ratio(0.8)) 154 BarMark(x: .value("Start", bar.start, unit: .hour), y: .value("Kp", bar.kp), width: .ratio(0.8))
@@ -169,13 +169,13 @@ struct SunScreen: View {
169 } 169 }
170 .frame(height: 140) 170 .frame(height: 140)
171 Text("Solid: observed. Faint: forecast. Red: storm level (Kp 5 or more).") 171 Text("Solid: observed. Faint: forecast. Red: storm level (Kp 5 or more).")
172 .font(.caption2).opacity(0.6) 172 .font(.caption2).dimmed(0.6)
173 } 173 }
174 } 174 }
175 175
176 private func stat(_ label: String, _ value: String) -> some View { 176 private func stat(_ label: String, _ value: String) -> some View {
177 VStack(alignment: .leading, spacing: 2) { 177 VStack(alignment: .leading, spacing: 2) {
178 Text(label).font(.caption2).opacity(0.6) 178 Text(label).font(.caption2).dimmed(0.6)
179 Text(value).font(.title3.weight(.medium).monospacedDigit()) 179 Text(value).font(.title3.weight(.medium).monospacedDigit())
180 } 180 }
181 } 181 }
App/Sources/SystemScreen.swift +48 −23
@@ -7,6 +7,7 @@ struct SystemScreen: View {
7 @State private var offsetDays = 0.0 7 @State private var offsetDays = 0.0
8 @State private var playing = false 8 @State private var playing = false
9 @State private var libraryPage: LibraryPage? 9 @State private var libraryPage: LibraryPage?
10 @Environment(\.dynamicTypeSize) private var typeSize
10 11
11 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) 12 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
12 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) 13 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
@@ -25,7 +26,9 @@ struct SystemScreen: View {
25 Text("Solar system").font(.title.weight(.medium)) 26 Text("Solar system").font(.title.weight(.medium))
26 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 16) 27 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 16)
27 28
29 // Labels in this dense diagram stop growing at xxLarge; the table below scales fully.
28 OrreryView(date: date, ink: ink, accent: accent) 30 OrreryView(date: date, ink: ink, accent: accent)
31 .dynamicTypeSize(...DynamicTypeSize.xxLarge)
29 .accessibilityElement() 32 .accessibilityElement()
30 .accessibilityLabel("Orrery for \(date.formatted(.dateTime.year().month(.wide))). Planets and spacecraft distances are listed below.") 33 .accessibilityLabel("Orrery for \(date.formatted(.dateTime.year().month(.wide))). Planets and spacecraft distances are listed below.")
31 34
@@ -36,7 +39,7 @@ struct SystemScreen: View {
36 .accessibilityLabel(playing ? "Pause" : "Play") 39 .accessibilityLabel(playing ? "Pause" : "Play")
37 Text(date.formatted(.dateTime.year().month(.abbreviated))) 40 Text(date.formatted(.dateTime.year().month(.abbreviated)))
38 .font(.subheadline.weight(.medium).monospacedDigit()) 41 .font(.subheadline.weight(.medium).monospacedDigit())
39 .frame(width: 80, alignment: .leading) 42 .fixedSize()
40 Slider(value: $offsetDays, in: range).tint(ink) 43 Slider(value: $offsetDays, in: range).tint(ink)
41 Button { playing = false; offsetDays = 0 } label: { 44 Button { playing = false; offsetDays = 0 } label: {
42 Text("Today").frame(minWidth: 44, minHeight: 44).contentShape(Rectangle()) 45 Text("Today").frame(minWidth: 44, minHeight: 44).contentShape(Rectangle())
@@ -47,37 +50,45 @@ struct SystemScreen: View {
47 .padding(.top, 12) 50 .padding(.top, 12)
48 51
49 Text("Distances beyond 1 AU are drawn on a logarithmic scale. Rings: planet orbits, then 50, 100 and 200 AU.") 52 Text("Distances beyond 1 AU are drawn on a logarithmic scale. Rings: planet orbits, then 50, 100 and 200 AU.")
50 .font(.caption2).opacity(0.6).padding(.top, 8) 53 .font(.caption2).dimmed(0.6).padding(.top, 8)
51 54
52 section("Spacecraft") { 55 section("Spacecraft") {
53 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { 56 if typeSize.isAccessibilitySize {
54 GridRow { 57 VStack(alignment: .leading, spacing: 12) {
55 Text("").gridColumnAlignment(.leading) 58 ForEach(Spacecraft.all) { craft in
56 Text("From Sun").opacity(0.6) 59 VStack(alignment: .leading, spacing: 2) {
57 Text("Light time").opacity(0.6) 60 craftName(craft)
61 Text(craftDistance(craft)).monospacedDigit().dimmed(0.7)
62 }
63 .font(.subheadline)
64 }
58 } 65 }
59 .font(.caption2) 66 } else {
60 ForEach(Spacecraft.all) { craft in 67 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
61 GridRow { 68 GridRow {
62 if let page = Library.page(spacecraft: craft.name) { 69 Text("").gridColumnAlignment(.leading)
63 Button { libraryPage = page } label: { Text(craft.name).underline().frame(minHeight: 44, alignment: .leading).contentShape(Rectangle()) }.buttonStyle(.plain) 70 Text("From Sun").dimmed(0.6)
64 } else { 71 Text("Light time").dimmed(0.6)
65 Text(craft.name) 72 }
66 } 73 .font(.caption2)
67 if let d = craft.distance(at: date) { 74 ForEach(Spacecraft.all) { craft in
68 Text(String(format: "%.1f AU", d)).monospacedDigit() 75 GridRow {
69 Text(lightTime(au: d)).monospacedDigit() 76 craftName(craft)
70 } else { 77 if let d = craft.distance(at: date) {
71 Text("launch \(craft.launch.prefix(4))").opacity(0.5) 78 Text(String(format: "%.1f AU", d)).monospacedDigit()
72 Text("") 79 Text(lightTime(au: d)).monospacedDigit()
80 } else {
81 Text("launch \(craft.launch.prefix(4))").dimmed(0.5)
82 Text("")
83 }
73 } 84 }
85 .font(.subheadline)
74 } 86 }
75 .font(.subheadline)
76 } 87 }
77 } 88 }
78 } 89 }
79 Text("Spacecraft positions: NASA/JPL Horizons. Pioneer 10 and 11 positions after contact was lost are predictions.") 90 Text("Spacecraft positions: NASA/JPL Horizons. Pioneer 10 and 11 positions after contact was lost are predictions.")
80 .font(.caption2).opacity(0.5).padding(.vertical, 16) 91 .font(.caption2).dimmed(0.5).padding(.vertical, 16)
81 } 92 }
82 .padding(.horizontal, 20) 93 .padding(.horizontal, 20)
83 } 94 }
@@ -95,6 +106,20 @@ struct SystemScreen: View {
95 } 106 }
96 } 107 }
97 108
109 @ViewBuilder
110 private func craftName(_ craft: Spacecraft) -> some View {
111 if let page = Library.page(spacecraft: craft.name) {
112 Button { libraryPage = page } label: { Text(craft.name).underline().frame(minHeight: 44, alignment: .leading).contentShape(Rectangle()) }.buttonStyle(.plain)
113 } else {
114 Text(craft.name)
115 }
116 }
117
118 private func craftDistance(_ craft: Spacecraft) -> String {
119 guard let d = craft.distance(at: date) else { return "launch \(craft.launch.prefix(4))" }
120 return String(format: "%.1f AU from the Sun · light time %@", d, lightTime(au: d))
121 }
122
98 /// One-way light time from the Sun's distance, as hours and minutes. 123 /// One-way light time from the Sun's distance, as hours and minutes.
99 private func lightTime(au: Double) -> String { 124 private func lightTime(au: Double) -> String {
100 let seconds = au * 499.004784 125 let seconds = au * 499.004784
@@ -104,7 +129,7 @@ struct SystemScreen: View {
104 129
105 private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View { 130 private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
106 VStack(alignment: .leading, spacing: 10) { 131 VStack(alignment: .leading, spacing: 10) {
107 Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).opacity(0.6) 132 Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
108 content() 133 content()
109 } 134 }
110 .padding(.top, 24) 135 .padding(.top, 24)
App/Sources/TodaySheet.swift +7 −7
@@ -18,7 +18,7 @@ struct TodaySheet: View {
18 } 18 }
19 section("Space weather") { weather } 19 section("Space weather") { weather }
20 Text("Space weather data: NOAA Space Weather Prediction Center.") 20 Text("Space weather data: NOAA Space Weather Prediction Center.")
21 .font(.caption2).opacity(0.5).padding(.top, 8) 21 .font(.caption2).dimmed(0.5).padding(.top, 8)
22 } 22 }
23 .padding(.horizontal, 20) 23 .padding(.horizontal, 20)
24 .padding(.top, 18) 24 .padding(.top, 18)
@@ -29,7 +29,7 @@ struct TodaySheet: View {
29 29
30 private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View { 30 private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
31 VStack(alignment: .leading, spacing: 8) { 31 VStack(alignment: .leading, spacing: 8) {
32 Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).opacity(0.6) 32 Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
33 content() 33 content()
34 } 34 }
35 .frame(maxWidth: .infinity, alignment: .leading) 35 .frame(maxWidth: .infinity, alignment: .leading)
@@ -86,7 +86,7 @@ struct TodaySheet: View {
86 ("Updated", time(w.observedAt)), 86 ("Updated", time(w.observedAt)),
87 ]) 87 ])
88 } else { 88 } else {
89 Text("Loading").font(.footnote).opacity(0.6) 89 Text("Loading").font(.footnote).dimmed(0.6)
90 } 90 }
91 } 91 }
92 92
@@ -94,7 +94,7 @@ struct TodaySheet: View {
94 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { 94 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
95 ForEach(items, id: \.0) { item in 95 ForEach(items, id: \.0) { item in
96 GridRow { 96 GridRow {
97 Text(item.0).opacity(0.7) 97 Text(item.0).dimmed(0.7)
98 Text(item.1).monospacedDigit() 98 Text(item.1).monospacedDigit()
99 } 99 }
100 .font(.subheadline) 100 .font(.subheadline)
@@ -121,9 +121,9 @@ struct TodayBar: View {
121 scrubber 121 scrubber
122 Button(action: onExpand) { 122 Button(action: onExpand) {
123 HStack { 123 HStack {
124 Text(summary).font(.footnote).opacity(0.8) 124 Text(summary).font(.footnote).dimmed(0.8)
125 Spacer() 125 Spacer()
126 Image(systemName: "chevron.up").font(.caption.weight(.medium)).opacity(0.6) 126 Image(systemName: "chevron.up").font(.caption.weight(.medium)).dimmed(0.6)
127 } 127 }
128 .frame(minHeight: 44) 128 .frame(minHeight: 44)
129 .contentShape(Rectangle()) 129 .contentShape(Rectangle())
@@ -157,7 +157,7 @@ struct TodayBar: View {
157 HStack(spacing: 12) { 157 HStack(spacing: 12) {
158 Text(model.displayedDate.formatted(date: .omitted, time: .shortened)) 158 Text(model.displayedDate.formatted(date: .omitted, time: .shortened))
159 .font(.footnote.monospacedDigit()) 159 .font(.footnote.monospacedDigit())
160 .frame(width: 70, alignment: .leading) 160 .fixedSize()
161 Slider(value: $model.offsetHours, in: -12...12, step: 0.25).tint(ink) 161 Slider(value: $model.offsetHours, in: -12...12, step: 0.25).tint(ink)
162 Button { model.offsetHours = 0 } label: { 162 Button { model.offsetHours = 0 } label: {
163 Text("Now").frame(minWidth: 44, minHeight: 44).contentShape(Rectangle()) 163 Text("Now").frame(minWidth: 44, minHeight: 44).contentShape(Rectangle())
Packages/MoonriseKit/Sources/SkyRenderer/Orrery.swift +3 −3
@@ -100,7 +100,7 @@ public struct OrreryView: View {
100 let r = CGFloat(s.radius(au)) 100 let r = CGFloat(s.radius(au))
101 context.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)), 101 context.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)),
102 with: .color(ink.opacity(0.12)), style: StrokeStyle(lineWidth: 0.5, dash: [2, 4])) 102 with: .color(ink.opacity(0.12)), style: StrokeStyle(lineWidth: 0.5, dash: [2, 4]))
103 context.draw(Text("\(Int(au)) AU").font(.system(size: 9)).foregroundStyle(ink.opacity(0.4)), at: CGPoint(x: c.x, y: c.y - r - 7)) 103 context.draw(Text("\(Int(au)) AU").font(.caption2).foregroundStyle(ink.opacity(0.6)), at: CGPoint(x: c.x, y: c.y - r - 7))
104 } 104 }
105 for (_, au) in Self.orbits { 105 for (_, au) in Self.orbits {
106 let r = CGFloat(s.radius(au)) 106 let r = CGFloat(s.radius(au))
@@ -117,7 +117,7 @@ public struct OrreryView: View {
117 context.stroke(line, with: .color(accent.opacity(0.7)), lineWidth: 0.8) 117 context.stroke(line, with: .color(accent.opacity(0.7)), lineWidth: 0.8)
118 let p = s.point(last) 118 let p = s.point(last)
119 context.fill(Path(ellipseIn: CGRect(x: p.x - 2.5, y: p.y - 2.5, width: 5, height: 5)), with: .color(accent)) 119 context.fill(Path(ellipseIn: CGRect(x: p.x - 2.5, y: p.y - 2.5, width: 5, height: 5)), with: .color(accent))
120 context.draw(Text(craft.name).font(.system(size: 9)).foregroundStyle(accent), at: CGPoint(x: p.x + 6, y: p.y), anchor: .leading) 120 context.draw(Text(craft.name).font(.caption2).foregroundStyle(accent), at: CGPoint(x: p.x + 6, y: p.y), anchor: .leading)
121 } 121 }
122 122
123 for body in [SolarBody.mercury, .venus, .mars, .jupiter, .saturn, .uranus, .neptune] { 123 for body in [SolarBody.mercury, .venus, .mars, .jupiter, .saturn, .uranus, .neptune] {
@@ -126,7 +126,7 @@ public struct OrreryView: View {
126 let r: CGFloat = [.jupiter, .saturn].contains(body) ? 4 : 3 126 let r: CGFloat = [.jupiter, .saturn].contains(body) ? 4 : 3
127 context.fill(Path(ellipseIn: CGRect(x: p.x - r, y: p.y - r, width: 2 * r, height: 2 * r)), with: .color(ink)) 127 context.fill(Path(ellipseIn: CGRect(x: p.x - r, y: p.y - r, width: 2 * r, height: 2 * r)), with: .color(ink))
128 if [.jupiter, .saturn, .uranus, .neptune].contains(body) { 128 if [.jupiter, .saturn, .uranus, .neptune].contains(body) {
129 context.draw(Text(body.rawValue.capitalized).font(.system(size: 9)).foregroundStyle(ink.opacity(0.8)), at: CGPoint(x: p.x, y: p.y + 10)) 129 context.draw(Text(body.rawValue.capitalized).font(.caption2).foregroundStyle(ink.opacity(0.8)), at: CGPoint(x: p.x, y: p.y + 10))
130 } 130 }
131 } 131 }
132 if let earth = try? SkyEngine.heliocentricEarth(at: date) { 132 if let earth = try? SkyEngine.heliocentricEarth(at: date) {
Packages/MoonriseKit/Sources/SkyRenderer/PolarMap.swift +1 −1
@@ -94,7 +94,7 @@ public struct AuroraMap: View {
94 94
95 if let you = p.point(lon: observer.lon, lat: observer.lat) { 95 if let you = p.point(lon: observer.lon, lat: observer.lat) {
96 context.fill(Path(ellipseIn: CGRect(x: you.x - 4, y: you.y - 4, width: 8, height: 8)), with: .color(ink)) 96 context.fill(Path(ellipseIn: CGRect(x: you.x - 4, y: you.y - 4, width: 8, height: 8)), with: .color(ink))
97 context.draw(Text("You").font(.system(size: 10)).foregroundStyle(ink), at: CGPoint(x: you.x, y: you.y + 12)) 97 context.draw(Text("You").font(.caption2).foregroundStyle(ink), at: CGPoint(x: you.x, y: you.y + 12))
98 } 98 }
99 } 99 }
100 .aspectRatio(1, contentMode: .fit) 100 .aspectRatio(1, contentMode: .fit)
Packages/MoonriseKit/Sources/SkyRenderer/SkyView.swift +13 −6
@@ -45,15 +45,18 @@ public struct SkyStyle: Sendable {
45 45
46 public init() {} 46 public init() {}
47 47
48 /// Night-mode red: 6.2:1 against black, 5.1:1 at 90% opacity (WCAG AA needs 4.5:1).
49 public static let nightRed = Color(red: 1.0, green: 0.27, blue: 0.2)
50
48 /// Red on black, to keep night vision. 51 /// Red on black, to keep night vision.
49 public static let night: SkyStyle = { 52 public static let night: SkyStyle = {
50 var s = SkyStyle() 53 var s = SkyStyle()
51 let red = Color(red: 0.85, green: 0.12, blue: 0.08) 54 let red = SkyStyle.nightRed
52 s.background = .black 55 s.background = .black
53 s.ground = Color(red: 0.06, green: 0, blue: 0) 56 s.ground = Color(red: 0.06, green: 0, blue: 0)
54 s.line = red.opacity(0.35) 57 s.line = red.opacity(0.35)
55 s.label = red 58 s.label = red
56 s.muted = red.opacity(0.7) 59 s.muted = red.opacity(0.9)
57 s.moonLight = red 60 s.moonLight = red
58 s.moonDark = Color(red: 0.12, green: 0, blue: 0) 61 s.moonDark = Color(red: 0.12, green: 0, blue: 0)
59 s.planet = red 62 s.planet = red
@@ -135,11 +138,15 @@ public struct SkyView: View {
135 } else { 138 } else {
136 let r = max(2.0, 3.6 - 0.35 * b.magnitude) 139 let r = max(2.0, 3.6 - 0.35 * b.magnitude)
137 context.fill(Path(ellipseIn: CGRect(x: pt.x - r, y: pt.y - r, width: 2 * r, height: 2 * r)), with: .color(style.planet)) 140 context.fill(Path(ellipseIn: CGRect(x: pt.x - r, y: pt.y - r, width: 2 * r, height: 2 * r)), with: .color(style.planet))
141 // Label to the right, or to the left when it would run off the edge (large text sizes).
142 let name = b.body.rawValue.capitalized
143 let width = context.resolve(Text(name).font(.caption2)).measure(in: p.size).width
144 let side: Double = pt.x + r + 18 + width > p.size.width ? -1 : 1
138 var tick = Path() 145 var tick = Path()
139 tick.move(to: CGPoint(x: pt.x + r + 3, y: pt.y)) 146 tick.move(to: CGPoint(x: pt.x + side * (r + 3), y: pt.y))
140 tick.addLine(to: CGPoint(x: pt.x + r + 15, y: pt.y)) 147 tick.addLine(to: CGPoint(x: pt.x + side * (r + 15), y: pt.y))
141 context.stroke(tick, with: .color(style.muted), lineWidth: 0.6) 148 context.stroke(tick, with: .color(style.muted), lineWidth: 0.6)
142 label(&context, b.body.rawValue.capitalized, at: CGPoint(x: pt.x + r + 18, y: pt.y), anchor: .leading) 149 label(&context, name, at: CGPoint(x: pt.x + side * (r + 18), y: pt.y), anchor: side > 0 ? .leading : .trailing)
143 } 150 }
144 } 151 }
145 } 152 }
@@ -172,7 +179,7 @@ public struct SkyView: View {
172 } 179 }
173 180
174 func label(_ context: inout GraphicsContext, _ text: String, at point: CGPoint, anchor: UnitPoint, color: Color? = nil) { 181 func label(_ context: inout GraphicsContext, _ text: String, at point: CGPoint, anchor: UnitPoint, color: Color? = nil) {
175 let t = Text(text).font(.system(size: 11, weight: .regular)).foregroundStyle(color ?? style.label) 182 let t = Text(text).font(.caption2).foregroundStyle(color ?? style.label)
176 context.draw(t, at: point, anchor: anchor) 183 context.draw(t, at: point, anchor: anchor)
177 } 184 }
178} 185}