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 6c4b28384d

6c4b28384d65e09486e0ad62f2ac9d10401cc80c

parent: a64782b360

Unsigned

cmc <hello@cleberg.net> · 2026-10-20 02:48 UTC

Sun tab caption sizes, shared aurora formula, eclipse activity layouts (!64)

Three follow-ups from merging !55–!62.

## Sun tab Dynamic Type

The audit flags every on-screen `.caption2` text as "Dynamic Type font sizes are partially unsupported", the same as 'SPACECRAFT' on System, 'UP TONIGHT' on Library, and the launch row numbers. !61 put `.caption2` text above the fold on Sun. Stacking the strip alone left the count unchanged at 10.

- R/S/G strip stacks vertically at accessibility sizes (`typeSize.isAccessibilitySize`, as in `SystemScreen`). Checked in the simulator at accessibility-extra-large.
- Strip labels ('Radio', 'Radiation', 'Geomagnetic', '24 h max …') and the 'NOAA SWPC, HH:MM' line: `.caption2` → `.caption`.
- 'NOAA SCALES' is still flagged. It uses the shared section header style, which every screen flags the same way.

Audit (`AccessibilityAuditTests`, iPhone 17 Pro, iOS 27.0):

| Screen | main (a64782b) | strip stacked only | this MR |
|---|---|---|---|
| Sun | 10 (8 Dynamic Type, 2 contrast) | 10 | 3 ('NOAA SCALES', 2 contrast) |
| Sky | 0* | 4 | 3 |
| Settings | 0* | 3 | 3 |
| System | 7 | 7 | 7 |
| News & launches | 23 | 23 | 23 |
| Library | 14 | 14 | 14 |
| Library page: Saturn | 13 | 13 | 13 |
| Total | 67 | 74 | 66 |

\* Sky and Settings recorded nothing on that run. Their counts change between runs ('draw … ms' debug label on Sky). Sun is the only screen this MR changes.

## Aurora formula

`SunSummary` uses `Geomagnetic.visibleFrom` and the new `Geomagnetic.kpNeeded(geomagneticLatitude:)`, which `kpNeeded(_ observer:)` now calls. Its private copies are deleted, along with `visibleLatitudeMatchesServer`, whose assertion is the same as `GeomagneticTests`. The 0 clamp only changes negative values. Those already fell through the `> 9` and `> 5` checks, so the summary wording is unchanged.

## Eclipse Live Activity

`.supplementalActivityFamilies([.small])` with a small layout (kind, then C2 or Max countdown), and `IslandWidth` on compact leading (sun icon when limited). The lock screen layout is unchanged. Built only, not viewed on Watch, CarPlay or a limited-width island.

## Tests

- `swift test` (Packages/MoonriseKit): pass
- `npm test` (server): 27/27 pass
- `xcodegen generate` + `xcodebuild test -scheme Moonrise` (iPhone 17 Pro, iOS 27.0): pass

Layout: unified · split

App/Sources/SunScreen.swift +10 −6
@@ -13,6 +13,7 @@ struct SunScreen: View {
1313 @State private var imageID = Date.now
1414 @State private var picture: Picture?
1515 @State private var imageFailed = false
16 @Environment(\.dynamicTypeSize) private var typeSize
1617
1718 private var kp: [KpBar] { data?.kp ?? [] }
1819 private var xray: [XRaySample] { data?.xray ?? [] }
@@ -29,7 +30,7 @@ struct SunScreen: View {
2930 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12)
3031 if let summary, let data {
3132 Text(summary)
32 Text(SunFeeds.source(data)).font(.caption2).dimmed(0.5).padding(.top, 4)
33 Text(SunFeeds.source(data)).font(.caption).dimmed(0.5).padding(.top, 4)
3334 } else if !failed {
3435 Text("Loading").font(.footnote).dimmed(0.6)
3536 }
@@ -137,12 +138,15 @@ struct SunScreen: View {
137138 }
138139 }
139140
141 /// Three columns, stacked at accessibility text sizes.
140142 private func scalesStrip(_ s: NOAAScales) -> some View {
141 HStack(alignment: .top, spacing: 12) {
143 let stacked = typeSize.isAccessibilitySize
144 let layout = stacked ? AnyLayout(VStackLayout(alignment: .leading, spacing: 12)) : AnyLayout(HStackLayout(alignment: .top, spacing: 12))
145 return layout {
142146 scale("R", "Radio", s.current.r, s.past24h.r)
143 Rectangle().fill(ink.opacity(0.25)).frame(width: 0.5)
147 Rectangle().fill(ink.opacity(0.25)).frame(width: stacked ? nil : 0.5, height: stacked ? 0.5 : nil)
144148 scale("S", "Radiation", s.current.s, s.past24h.s)
145 Rectangle().fill(ink.opacity(0.25)).frame(width: 0.5)
149 Rectangle().fill(ink.opacity(0.25)).frame(width: stacked ? nil : 0.5, height: stacked ? 0.5 : nil)
146150 scale("G", "Geomagnetic", s.current.g, s.past24h.g)
147151 }
148152 .fixedSize(horizontal: false, vertical: true)
@@ -150,11 +154,11 @@ struct SunScreen: View {
150154
151155 private func scale(_ letter: String, _ title: String, _ level: Int, _ past: Int) -> some View {
152156 VStack(alignment: .leading, spacing: 2) {
153 Text(title).font(.caption2).dimmed(0.6)
157 Text(title).font(.caption).dimmed(0.6)
154158 Text("\(letter)\(level)").font(.title.weight(.medium).monospacedDigit())
155159 .foregroundStyle(level >= 1 ? accent : ink)
156160 Text(NOAAScales.levelName(level)).font(.footnote)
157 Text("24 h max \(letter)\(past)").font(.caption2).dimmed(0.6)
161 Text("24 h max \(letter)\(past)").font(.caption).dimmed(0.6)
158162 }
159163 .frame(maxWidth: .infinity, alignment: .leading)
160164 .accessibilityElement(children: .combine)
App/Widgets/EclipseLiveActivity.swift +82 −47
@@ -8,44 +8,9 @@ struct EclipseLiveActivity: Widget {
88
99 var body: some WidgetConfiguration {
1010 ActivityConfiguration(for: EclipseActivityAttributes.self) { context in
11 let a = context.attributes
12 VStack(alignment: .leading, spacing: 6) {
13 Text("\(a.kind) eclipse · \(a.site)").font(.system(size: 15, weight: .medium))
14 HStack(alignment: .firstTextBaseline) {
15 if let c2 = a.c2, let c3 = a.c3 {
16 VStack(alignment: .leading, spacing: 0) {
17 Text(a.kind == "Total" ? "Totality in" : "Annularity in").font(.system(size: 12)).opacity(0.7)
18 countdown(to: c2).font(.system(size: 34, weight: .medium).monospacedDigit())
19 }
20 Spacer()
21 VStack(alignment: .trailing, spacing: 0) {
22 Text("Lasts").font(.system(size: 12)).opacity(0.7)
23 Text(timerInterval: c2...c3, countsDown: true).font(.system(size: 34, weight: .medium).monospacedDigit())
24 .foregroundStyle(accent).multilineTextAlignment(.trailing)
25 }
26 } else {
27 VStack(alignment: .leading, spacing: 0) {
28 Text("Maximum in").font(.system(size: 12)).opacity(0.7)
29 countdown(to: a.peak).font(.system(size: 34, weight: .medium).monospacedDigit())
30 }
31 }
32 }
33 ProgressView(timerInterval: a.c1...a.c4, countsDown: false, label: { EmptyView() }, currentValueLabel: { EmptyView() })
34 .tint(ink)
35 HStack {
36 ForEach(contacts(a), id: \.0) { name, date in
37 VStack(alignment: .leading, spacing: 0) {
38 Text(name).font(.system(size: 10)).opacity(0.6)
39 Text(clock(date, a)).font(.system(size: 11).monospacedDigit())
40 }
41 if name != "C4" { Spacer(minLength: 4) }
42 }
43 }
44 Text(a.timeZone == nil ? "Times in UTC" : "Local time at the site").font(.system(size: 10)).opacity(0.6)
45 }
46 .padding(16)
47 .foregroundStyle(ink)
48 .activityBackgroundTint(.black)
11 EclipseActivityView(attributes: context.attributes, ink: ink, accent: accent)
12 .foregroundStyle(ink)
13 .activityBackgroundTint(.black)
4914 } dynamicIsland: { context in
5015 let a = context.attributes
5116 let next = a.c2 ?? a.peak
@@ -69,26 +34,96 @@ struct EclipseLiveActivity: Widget {
6934 }
7035 }
7136 } compactLeading: {
72 Text(a.c2 == nil ? "Max" : "C2").font(.system(size: 12, weight: .medium))
37 IslandWidth {
38 Text(a.c2 == nil ? "Max" : "C2").font(.system(size: 12, weight: .medium))
39 } limited: {
40 Image(systemName: "sun.max")
41 }
7342 } compactTrailing: {
7443 countdown(to: next).font(.system(size: 12).monospacedDigit()).frame(maxWidth: 64)
7544 } minimal: {
7645 Image(systemName: "sun.max")
7746 }
7847 }
48 .supplementalActivityFamilies([.small])
7949 }
50}
51
52private struct EclipseActivityView: View {
53 @Environment(\.activityFamily) private var family
54 var attributes: EclipseActivityAttributes
55 var ink: Color
56 var accent: Color
8057
81 /// Counts down to `date` and then stays at zero.
82 private func countdown(to date: Date) -> Text {
83 Text(timerInterval: date.addingTimeInterval(-86_400)...date, countsDown: true)
58 private var a: EclipseActivityAttributes { attributes }
59
60 var body: some View {
61 if family == .small { small } else { medium }
8462 }
8563
86 private func contacts(_ a: EclipseActivityAttributes) -> [(String, Date)] {
87 [("C1", a.c1), ("C2", a.c2), ("Max", a.c2 == nil ? a.peak : nil), ("C3", a.c3), ("C4", a.c4)].compactMap { n, d in d.map { (n, $0) } }
64 /// Apple Watch Smart Stack and CarPlay.
65 private var small: some View {
66 VStack(alignment: .leading, spacing: 2) {
67 Text("\(a.kind) eclipse").font(.system(size: 13, weight: .medium)).lineLimit(1)
68 HStack(spacing: 4) {
69 Text(a.c2 == nil ? "Max" : "C2")
70 countdown(to: a.c2 ?? a.peak)
71 }
72 .font(.system(size: 17, weight: .medium).monospacedDigit())
73 }
74 .frame(maxWidth: .infinity, alignment: .leading)
75 .padding(12)
8876 }
8977
90 /// A string: Text(_:format:) would replace the style's time zone with the environment's.
91 private func clock(_ date: Date, _ a: EclipseActivityAttributes) -> String {
92 date.formatted(Date.FormatStyle(date: .omitted, time: .standard, timeZone: a.timeZone.flatMap(TimeZone.init(identifier:)) ?? .gmt))
78 private var medium: some View {
79 VStack(alignment: .leading, spacing: 6) {
80 Text("\(a.kind) eclipse · \(a.site)").font(.system(size: 15, weight: .medium))
81 HStack(alignment: .firstTextBaseline) {
82 if let c2 = a.c2, let c3 = a.c3 {
83 VStack(alignment: .leading, spacing: 0) {
84 Text(a.kind == "Total" ? "Totality in" : "Annularity in").font(.system(size: 12)).opacity(0.7)
85 countdown(to: c2).font(.system(size: 34, weight: .medium).monospacedDigit())
86 }
87 Spacer()
88 VStack(alignment: .trailing, spacing: 0) {
89 Text("Lasts").font(.system(size: 12)).opacity(0.7)
90 Text(timerInterval: c2...c3, countsDown: true).font(.system(size: 34, weight: .medium).monospacedDigit())
91 .foregroundStyle(accent).multilineTextAlignment(.trailing)
92 }
93 } else {
94 VStack(alignment: .leading, spacing: 0) {
95 Text("Maximum in").font(.system(size: 12)).opacity(0.7)
96 countdown(to: a.peak).font(.system(size: 34, weight: .medium).monospacedDigit())
97 }
98 }
99 }
100 ProgressView(timerInterval: a.c1...a.c4, countsDown: false, label: { EmptyView() }, currentValueLabel: { EmptyView() })
101 .tint(ink)
102 HStack {
103 ForEach(contacts(a), id: \.0) { name, date in
104 VStack(alignment: .leading, spacing: 0) {
105 Text(name).font(.system(size: 10)).opacity(0.6)
106 Text(clock(date, a)).font(.system(size: 11).monospacedDigit())
107 }
108 if name != "C4" { Spacer(minLength: 4) }
109 }
110 }
111 Text(a.timeZone == nil ? "Times in UTC" : "Local time at the site").font(.system(size: 10)).opacity(0.6)
112 }
113 .padding(16)
93114 }
94115}
116
117/// Counts down to `date` and then stays at zero.
118private func countdown(to date: Date) -> Text {
119 Text(timerInterval: date.addingTimeInterval(-86_400)...date, countsDown: true)
120}
121
122private func contacts(_ a: EclipseActivityAttributes) -> [(String, Date)] {
123 [("C1", a.c1), ("C2", a.c2), ("Max", a.c2 == nil ? a.peak : nil), ("C3", a.c3), ("C4", a.c4)].compactMap { n, d in d.map { (n, $0) } }
124}
125
126/// A string: Text(_:format:) would replace the style's time zone with the environment's.
127private func clock(_ date: Date, _ a: EclipseActivityAttributes) -> String {
128 date.formatted(Date.FormatStyle(date: .omitted, time: .standard, timeZone: a.timeZone.flatMap(TimeZone.init(identifier:)) ?? .gmt))
129}
Packages/MoonriseKit/Sources/Feeds/SunSummary.swift +3 −13
@@ -1,20 +1,10 @@
11import Foundation
2import SkyEngine
23
34/// One or two plain sentences on what the Sun is doing to Earth now, from NOAA's feeds.
45/// The first matching rule wins: a geomagnetic storm, then a radiation storm, then a flare, then quiet.
56/// Every sentence names a figure from a feed and its time.
67public enum SunSummary {
7 /// Geomagnetic latitude of the auroral oval's equatorward edge for a Kp value.
8 /// Must match `ovalEdge` and `visibleFrom` in server/src/aurora.ts.
9 static func visibleFrom(kp: Double) -> Double {
10 66.5 - 2.04 * kp - 6
11 }
12
13 /// Lowest Kp at which aurora may be seen low on the horizon from this geomagnetic latitude.
14 static func kpNeeded(at geomagneticLatitude: Double) -> Double {
15 (66.5 - 6 - abs(geomagneticLatitude)) / 2.04
16 }
17
188 /// `nil` when none of the feeds has loaded. `night` is the current or next night at the observer.
199 public static func text(
2010 scales: NOAAScales?, kp: [KpBar], probabilities: SolarProbabilities?, xray: [XRaySample],
@@ -32,7 +22,7 @@ public enum SunSummary {
3222 let bar: TimeInterval = 3 * 3600
3323 let storms = kp.filter { $0.kp >= 5 && $0.start.addingTimeInterval(bar) > now && $0.start < now.addingTimeInterval(86_400) }
3424 let visible = storms.filter {
35 visibleFrom(kp: $0.kp) <= abs(geomagneticLatitude)
25 Geomagnetic.visibleFrom(kp: $0.kp) <= abs(geomagneticLatitude)
3626 && overlaps($0.start, bar, night)
3727 }
3828 let sky = geomagneticLatitude >= 0 ? "northern" : "southern"
@@ -44,7 +34,7 @@ public enum SunSummary {
4434 return "\(name(g)) storm (G\(g)) \(when), Kp \(String(format: "%.1f", b.kp)). Aurora possible low in the \(sky) sky."
4535 }
4636 if current.g >= 1 {
47 let needed = kpNeeded(at: geomagneticLatitude)
37 let needed = Geomagnetic.kpNeeded(geomagneticLatitude: geomagneticLatitude)
4838 var text = "\(name(current.g)) storm (G\(current.g))\(asOf)."
4939 if needed > 9 {
5040 text += " Aurora at your latitude would need more than Kp 9."
Packages/MoonriseKit/Sources/SkyEngine/Geomagnetic.swift +6 −1
@@ -36,6 +36,11 @@ public enum Geomagnetic {
3636
3737 /// Kp at which aurora may be seen on the horizon from here; 0 under or poleward of the quiet oval.
3838 public static func kpNeeded(_ observer: Observer) -> Double {
39 max(0, (visibleFrom(kp: 0) - abs(latitude(observer))) / 2.04)
39 kpNeeded(geomagneticLatitude: latitude(observer))
40 }
41
42 /// `kpNeeded(_:)` for a geomagnetic latitude in degrees.
43 public static func kpNeeded(geomagneticLatitude: Double) -> Double {
44 max(0, (visibleFrom(kp: 0) - abs(geomagneticLatitude)) / 2.04)
4045 }
4146}
Packages/MoonriseKit/Tests/FeedsTests/SunSummaryTests.swift −5
@@ -119,8 +119,3 @@ private func summary(
119119 #expect(summary(scales: scales(r: 2, s: 1), xray: xray)?.hasPrefix("Minor radiation storm (S1)") == true)
120120 #expect(summary(scales: scales(r: 2), xray: xray)?.hasPrefix("Moderate flare (M5.0)") == true)
121121}
122
123@Test func visibleLatitudeMatchesServer() {
124 // server/src/aurora.ts: visibleFrom(kp) = 66.5 - 2.04 * kp - 6
125 #expect(abs(SunSummary.visibleFrom(kp: 5) - 50.3) < 1e-9)
126}