From 3a1a89494196c56e42764ee43e58db6a546ea071 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Wed, 2 Sep 2026 11:39:40 +1200 Subject: [PATCH 1/3] widgets: give the home-screen vitals footer the app's own HR/HRV icons The footer under the score rings paired heart rate with waveform.path.ecg and HRV with heart.fill - the inverse of the pairing every other surface uses. TodayView renders the two as adjacent rows the other way round, and DashboardCards, TodayCustomizationMetadata, MetricCatalog and this extension's own Live Activity all agree with TodayView. The footer has been the lone outlier since #1022. It matters more here than a mismatched glyph usually would, because the footer shows no text labels at all: the icon is the only thing naming each number. The inversion was an understandable one, and the doc comment now names it so it does not get "corrected" back. waveform.path.ecg IS the right symbol for Live HR as a feature - RootView, RootTabView and HomeScreenQuickActions all use it for the Live screen. As a metric icon it is HRV's, and this footer is the one place that draws both metrics side by side. Also gives the three vitals accessibility labels and values. They were bare numbers with no unit and no name, so VoiceOver read "58", "64", "84 percent" - three anonymous figures. This is the same gap #1715 fixed for the lock-screen accessory ("an icon says nothing to VoiceOver"); the footer predates that change and never got it. Literals are plain rather than String(localized:), for the reason already documented on accessoryScore: the extension does not carry the app's string catalog. Reported in #1795 by @Sneheth. --- StrandiOSWidgets/NOOPWidget.swift | 36 ++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/StrandiOSWidgets/NOOPWidget.swift b/StrandiOSWidgets/NOOPWidget.swift index 50907d89ef..7e67b31357 100644 --- a/StrandiOSWidgets/NOOPWidget.swift +++ b/StrandiOSWidgets/NOOPWidget.swift @@ -273,21 +273,51 @@ struct NOOPWidgetView: View { .frame(maxWidth: .infinity) } + /// Home-Screen footer: heart rate, HRV, strap battery, under the score rings. + /// + /// `heart.fill` is HR and `waveform.path.ecg` is HRV, which is the metric pairing the rest of the app + /// uses — `TodayView` renders them as adjacent rows that way, and `DashboardCards`, + /// `TodayCustomizationMetadata`, `MetricCatalog` and this extension's own Live Activity all agree. + /// The two were inverted here from #1022 until #1795 reported it. + /// + /// The inversion was an easy one to make and is worth naming so it is not "corrected" back: + /// `waveform.path.ecg` IS the right symbol for Live HR as a FEATURE — `RootView`, `RootTabView` and + /// `HomeScreenQuickActions` all use it for the Live screen. As a METRIC icon it belongs to HRV, and + /// this footer is the one place that renders both metrics side by side, where the collision is the + /// entire problem: nothing here carries a text label, so the glyph is the only thing naming a number. private func vitalsFooter(compact: Bool) -> some View { HStack { - Label(snap.bpm.map(String.init) ?? "–", systemImage: "waveform.path.ecg") + vital(symbol: "heart.fill", text: snap.bpm.map(String.init), + name: "Heart rate", spoken: snap.bpm.map { "\($0) beats per minute" }) Spacer() if !compact, let hrv = snap.hrv { - Label("\(hrv)", systemImage: "heart.fill") + vital(symbol: "waveform.path.ecg", text: "\(hrv)", + name: "Heart rate variability", spoken: "\(hrv) milliseconds") Spacer() } - Label(snap.batteryPct.map { "\($0)%" } ?? "–", systemImage: "battery.50") + vital(symbol: "battery.50", text: snap.batteryPct.map { "\($0)%" }, + name: "Strap battery", spoken: snap.batteryPct.map { "\($0) percent" }) } .font(.caption2) .foregroundStyle(StrandPalette.textSecondary) .labelStyle(.titleAndIcon) } + /// One footer vital. `spoken` is carried separately from the rendered `text` because VoiceOver gets + /// neither of the two things a sighted reader uses here: the glyph that names the metric, and the + /// unit, which the footer shows for none of them. Without this a reader hears "58", "64", "84 percent" + /// — three anonymous numbers. Same reasoning as `accessoryScore`, which #1715 fixed for the lock + /// screen; this footer predates it. + /// + /// Plain literals rather than `String(localized:)`, for the reason spelled out on `accessoryScore`: + /// this extension does not carry the app's string catalog, so `String(localized:)` would look + /// localized and render English anyway. + private func vital(symbol: String, text: String?, name: String, spoken: String?) -> some View { + Label(text ?? "–", systemImage: symbol) + .accessibilityLabel(Text(name)) + .accessibilityValue(Text(spoken ?? "No data")) + } + /// One labelled stat in the large grid — value over a caption, equal-width so the columns align. private func statCell(_ label: String, value: String?, unit: String? = nil, tint: Color = StrandPalette.textPrimary) -> some View { From 87b9367b1c3d37c24ab8188c7b921de356db741a Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Wed, 2 Sep 2026 11:45:52 +1200 Subject: [PATCH 2/3] widgets: collapse the vitals cells before labelling them Re-review: vital() set accessibilityLabel/accessibilityValue without first calling accessibilityElement(children: .ignore), making it the only labelled graphic in this file that does not. accessoryScore and WidgetScoreRing both collapse first. It is not cosmetic. Label under .titleAndIcon renders an image beside a text, so without the collapse the bare number remains its own accessibility element and whether the label wins is SwiftUI's container semantics rather than something this file decides - and a widget cannot be checked for that here. Matching the two sites that already work is strictly safer than relying on how Label happens to decompose. --- StrandiOSWidgets/NOOPWidget.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/StrandiOSWidgets/NOOPWidget.swift b/StrandiOSWidgets/NOOPWidget.swift index 7e67b31357..b31d38d341 100644 --- a/StrandiOSWidgets/NOOPWidget.swift +++ b/StrandiOSWidgets/NOOPWidget.swift @@ -314,6 +314,11 @@ struct NOOPWidgetView: View { /// localized and render English anyway. private func vital(symbol: String, text: String?, name: String, spoken: String?) -> some View { Label(text ?? "–", systemImage: symbol) + // Collapse first, like `accessoryScore` and `WidgetScoreRing` already do. A `Label` under + // `.titleAndIcon` renders an image beside a text, so without this the bare number stays its + // own element and whether the label below wins is SwiftUI container semantics rather than + // something this file decides. Every other labelled graphic here ignores its children. + .accessibilityElement(children: .ignore) .accessibilityLabel(Text(name)) .accessibilityValue(Text(spoken ?? "No data")) } From 1b3d56c4a5648ac0711969e6cc2c4c725ca6e47a Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Wed, 2 Sep 2026 11:47:57 +1200 Subject: [PATCH 3/3] widgets: let the large-grid stat cells speak their metric and unit statCell renders the value, then the unit as a separate view, then an abbreviated caption. Read as-is that is "64", "ms", "HRV" - three fragments in value-before-label order, with ms and bpm spelled out letter by letter. Less severe than the footer, because the caption at least names the metric somewhere, but still not a reading anyone would choose. Collapses the cell like the other three labelled graphics in this file and gives it a spoken name and unit, so it reads "Heart rate variability, 64 milliseconds". spoken falls back to the rendered value rather than to "No data", so a caller that omits it degrades to the old reading instead of asserting something false. --- StrandiOSWidgets/NOOPWidget.swift | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/StrandiOSWidgets/NOOPWidget.swift b/StrandiOSWidgets/NOOPWidget.swift index b31d38d341..d149ba447f 100644 --- a/StrandiOSWidgets/NOOPWidget.swift +++ b/StrandiOSWidgets/NOOPWidget.swift @@ -209,10 +209,18 @@ struct NOOPWidgetView: View { scoreRings(diameter: 88, lineWidth: 8, labelFont: .caption) Divider() HStack(alignment: .top, spacing: 0) { - statCell("HRV", value: snap.hrv.map { "\($0)" }, unit: "ms") - statCell("Rest HR", value: snap.restingHr.map { "\($0)" }, unit: "bpm") - statCell("HR", value: snap.bpm.map { "\($0)" }, unit: "bpm") - statCell("Battery", value: snap.batteryPct.map { "\($0)%" }) + statCell("HRV", value: snap.hrv.map { "\($0)" }, unit: "ms", + name: "Heart rate variability", + spoken: snap.hrv.map { "\($0) milliseconds" }) + statCell("Rest HR", value: snap.restingHr.map { "\($0)" }, unit: "bpm", + name: "Resting heart rate", + spoken: snap.restingHr.map { "\($0) beats per minute" }) + statCell("HR", value: snap.bpm.map { "\($0)" }, unit: "bpm", + name: "Heart rate", + spoken: snap.bpm.map { "\($0) beats per minute" }) + statCell("Battery", value: snap.batteryPct.map { "\($0)%" }, + name: "Strap battery", + spoken: snap.batteryPct.map { "\($0) percent" }) } Spacer(minLength: 0) } @@ -324,8 +332,15 @@ struct NOOPWidgetView: View { } /// One labelled stat in the large grid — value over a caption, equal-width so the columns align. + /// + /// `name` and `spoken` exist because the on-screen caption is abbreviated for width and the unit is a + /// separate view: read as-is, VoiceOver produces "64", "ms", "HRV" — three fragments, value before + /// label, with "ms" and "bpm" spelled out letter by letter. Collapsing to one element lets the cell + /// speak "Heart rate variability, 64 milliseconds". `spoken` falls back to the rendered value rather + /// than to "No data", so a caller that omits it degrades to the old reading instead of lying. private func statCell(_ label: String, value: String?, unit: String? = nil, - tint: Color = StrandPalette.textPrimary) -> some View { + tint: Color = StrandPalette.textPrimary, + name: String? = nil, spoken: String? = nil) -> some View { VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 2) { Text(value ?? "–") @@ -338,6 +353,9 @@ struct NOOPWidgetView: View { Text(label).font(.caption2).foregroundStyle(StrandPalette.textTertiary) } .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(name ?? label)) + .accessibilityValue(Text(spoken ?? value ?? "No data")) } }