From c046a0254a6ca2ae84c726d0e82fbe61346b9550 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Sat, 29 Aug 2026 00:25:24 +1200 Subject: [PATCH 1/4] widgets: make the lock-screen accessory legible on the lock screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A community request asked for a WHOOP-style mini lock-screen widget. iOS already has one — the accessoryRectangular family is declared and renders Charge, Effort and Rest from the same snapshot — so nothing needed building. What it needed was to behave like a lock-screen widget. Two things. It tinted unconditionally. Lock-screen widgets render in .vibrant or .accented, where the system desaturates whatever colour it is handed and maps it onto the wallpaper, so a domain colour does not survive as that colour: it lands as an arbitrary grey whose luminance nobody chose. Charge, Effort and Rest were therefore neither distinguishable from each other nor reliably legible — the tint was doing no work and taking the hierarchy with it. The cells now read the widgetRenderingMode environment and hand the system .primary and .secondary off the lock screen, which are the levels it is built to map, while the home screen and the gallery keep the domain colours exactly as before. And it spent a row on its own name. This family gets roughly 72pt of height for everything, and a "NOOP" title used a whole row of it restating which widget the user had chosen to add, squeezing the three scores that are the only reason to add it. The title is gone and the heart-rate line is now conditional, so an install with no live HR gives the scores the entire area. Left alone deliberately: the metric ORDER. iOS renders Charge, Effort, Rest in the accessory and in the small/medium/large rings, and its gallery description says so; Android renders Rest, Charge, Effort in both of its widgets, and its descriptions say so. Each platform is internally consistent and self-describing, so reordering only this one view would break iOS against itself and contradict its own copy. That divergence is real and worth settling, but it is a platform-wide convention question rather than something to slip into a lock-screen polish. Also unchanged: Android cannot host this at all. NoopCompactGlanceWidget already draws the same three icon cells, but Android removed third-party lock-screen widgets in 5.0 and widgetCategory="keyguard" has been inert since, so there is no surface to target on a phone. StrandiOSWidgets is app-target, and SwiftUI does not exist on Linux, so this could not be compiled locally at all — app-build is the first compiler to see it. The AnyShapeStyle branches are spelled with explicit types rather than leading-dot inference for that reason. --- StrandiOSWidgets/NOOPWidget.swift | 48 +++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/StrandiOSWidgets/NOOPWidget.swift b/StrandiOSWidgets/NOOPWidget.swift index 5e55dc7ebf..945abaf16e 100644 --- a/StrandiOSWidgets/NOOPWidget.swift +++ b/StrandiOSWidgets/NOOPWidget.swift @@ -33,6 +33,10 @@ struct NOOPProvider: TimelineProvider { /// accessories stay compact single-line / gauge layouts. struct NOOPWidgetView: View { @Environment(\.widgetFamily) private var family + /// `.fullColor` on the home screen and in the gallery; `.vibrant` or `.accented` on the lock screen, + /// where the system desaturates any colour it is handed. The accessory cells check this rather than + /// tinting unconditionally — see `accessoryScore`. + @Environment(\.widgetRenderingMode) private var renderingMode let entry: NOOPEntry private var snap: WidgetSnapshot { entry.snapshot } @@ -97,17 +101,16 @@ struct NOOPWidgetView: View { /// Lock-Screen rectangular accessory: Charge · Effort · Rest, same trio as the Home Screen rings. private var rectangular: some View { + // The lock screen gives this family roughly 72pt of height for everything. A "NOOP" title spent + // a whole row of that restating which widget the user chose to add, leaving the three scores — + // the only reason to add it — squeezed underneath. The title is gone and the heart-rate line is + // now conditional, so with no live HR the scores get the entire area. VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Text("NOOP") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(StrandPalette.textSecondary) - Spacer(minLength: 0) - if let bpm = snap.bpm { - Text("\(bpm) bpm") - .font(.caption2) - .foregroundStyle(StrandPalette.textTertiary) - } + if let bpm = snap.bpm { + Text("\(bpm) bpm") + .font(.caption2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .trailing) } HStack(alignment: .top, spacing: 0) { accessoryScore("Charge", text: snap.recovery.map { "\($0)%" }, tint: chargeColor) @@ -117,19 +120,40 @@ struct NOOPWidgetView: View { } } + /// One score cell. The tint applies on the home screen and in the gallery; on the LOCK SCREEN it is + /// deliberately dropped. + /// + /// Lock-screen widgets render in `.vibrant` (or `.accented`), where the system desaturates whatever + /// colour it is given and maps it onto the wallpaper. A domain colour handed to it does not survive + /// as that colour — it lands as an arbitrary grey whose luminance nobody chose, so Charge, Effort and + /// Rest stopped being distinguishable AND stopped being legible. `.primary`/`.secondary` are the two + /// levels the system is designed to map, so the value reads at full strength and the label recedes, + /// which is the hierarchy the tint was there to express in the first place. private func accessoryScore(_ label: String, text: String?, tint: Color) -> some View { VStack(alignment: .leading, spacing: 0) { Text(text ?? "–") .font(.system(size: 16, weight: .semibold, design: .rounded)) - .foregroundStyle(text == nil ? StrandPalette.textTertiary : tint) + .foregroundStyle(scoreStyle(hasValue: text != nil, tint: tint)) .minimumScaleFactor(0.7) Text(label) .font(.system(size: 9)) - .foregroundStyle(StrandPalette.textTertiary) + .foregroundStyle(renderingMode == .fullColor + ? AnyShapeStyle(StrandPalette.textTertiary) + : AnyShapeStyle(HierarchicalShapeStyle.secondary)) } .frame(maxWidth: .infinity, alignment: .leading) } + private func scoreStyle(hasValue: Bool, tint: Color) -> AnyShapeStyle { + // Spelled out rather than leaning on leading-dot inference through AnyShapeStyle's generic + // init, which is the kind of expression that type-checks in a playground and not in a build. + guard renderingMode == .fullColor else { + return hasValue ? AnyShapeStyle(HierarchicalShapeStyle.primary) + : AnyShapeStyle(HierarchicalShapeStyle.secondary) + } + return hasValue ? AnyShapeStyle(tint) : AnyShapeStyle(StrandPalette.textTertiary) + } + // MARK: - Home Screen: systemSmall /// Compact three-ring hero. Diameter is capped so three hard-framed circles fit the narrowest From 44a021f4078cbbdb03b6309d23f497dafadcb66e Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Sat, 29 Aug 2026 00:34:49 +1200 Subject: [PATCH 2/4] widgets: give the lock-screen accessory its metric glyphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request was for a WHOOP-style mini widget, and the thing that makes theirs readable at a glance is that each cell is a GLYPH, not a word. The accessory now shows an icon over each value — figure.mind.and.body, figure.strengthtraining.traditional, moon.fill for Charge, Effort and Rest — centred, replacing the 9pt word that sat under each number. That also buys back height rather than spending it. This family gets roughly 72pt for everything, and a word under every number was the second thing naming a metric the icon already names. Accessibility is the part an icon quietly breaks, so it is handled rather than assumed: a glyph says nothing to VoiceOver, and the word it replaced was the only thing identifying the metric. Each cell collapses to a single element that still speaks "Charge, 68 percent", with an existing catalogue string for the empty case. Lock screen only, deliberately. The home-screen families draw score RINGS whose order and shape deliberately mirror TodayView's hero trio, so putting glyphs there would diverge the widget from the app's own home screen to chase a lock-screen look. Those are untouched. SF Symbols rather than porting Android's ic_widget_* drawables: they render correctly in the vibrant mode the previous commit started respecting, scale with Dynamic Type, and add no assets. The cost is that they are not pixel-identical to Android's glyphs, which is a visible-in-screenshots difference rather than a functional one. The deployment target for this extension is iOS 17, so all three symbols exist without availability guards. i18n audit clean — "No data" was already in the catalogue in all ten locales. --- StrandiOSWidgets/NOOPWidget.swift | 34 ++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/StrandiOSWidgets/NOOPWidget.swift b/StrandiOSWidgets/NOOPWidget.swift index 945abaf16e..2409936441 100644 --- a/StrandiOSWidgets/NOOPWidget.swift +++ b/StrandiOSWidgets/NOOPWidget.swift @@ -113,9 +113,12 @@ struct NOOPWidgetView: View { .frame(maxWidth: .infinity, alignment: .trailing) } HStack(alignment: .top, spacing: 0) { - accessoryScore("Charge", text: snap.recovery.map { "\($0)%" }, tint: chargeColor) - accessoryScore("Effort", text: effortText, tint: effortColor) - accessoryScore("Rest", text: snap.rest.map { "\($0)%" }, tint: restColor) + accessoryScore("Charge", symbol: "figure.mind.and.body", + text: snap.recovery.map { "\($0)%" }, tint: chargeColor) + accessoryScore("Effort", symbol: "figure.strengthtraining.traditional", + text: effortText, tint: effortColor) + accessoryScore("Rest", symbol: "moon.fill", + text: snap.rest.map { "\($0)%" }, tint: restColor) } } } @@ -129,19 +132,26 @@ struct NOOPWidgetView: View { /// Rest stopped being distinguishable AND stopped being legible. `.primary`/`.secondary` are the two /// levels the system is designed to map, so the value reads at full strength and the label recedes, /// which is the hierarchy the tint was there to express in the first place. - private func accessoryScore(_ label: String, text: String?, tint: Color) -> some View { - VStack(alignment: .leading, spacing: 0) { - Text(text ?? "–") - .font(.system(size: 16, weight: .semibold, design: .rounded)) - .foregroundStyle(scoreStyle(hasValue: text != nil, tint: tint)) - .minimumScaleFactor(0.7) - Text(label) - .font(.system(size: 9)) + private func accessoryScore(_ label: String, symbol: String, text: String?, tint: Color) -> some View { + VStack(spacing: 1) { + // Glyph over value, the shape the request asked for. A 9pt word under each number was + // spending scarce height on text nobody needs twice — the icons carry the metric identity. + Image(systemName: symbol) + .font(.system(size: 11, weight: .semibold)) .foregroundStyle(renderingMode == .fullColor ? AnyShapeStyle(StrandPalette.textTertiary) : AnyShapeStyle(HierarchicalShapeStyle.secondary)) + Text(text ?? "–") + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .foregroundStyle(scoreStyle(hasValue: text != nil, tint: tint)) + .minimumScaleFactor(0.7) } - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity) + // An icon says nothing to VoiceOver, and the word it replaced was the only thing naming this + // metric. Collapse the cell to one element that still speaks "Charge, 68 percent". + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(label)) + .accessibilityValue(Text(text ?? String(localized: "No data"))) } private func scoreStyle(hasValue: Bool, tint: Color) -> AnyShapeStyle { From 04f09b6850cf2bccf06a66f989252a3ebc6e00b4 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Sat, 29 Aug 2026 00:46:14 +1200 Subject: [PATCH 3/4] widgets: drop a String(localized:) the extension cannot resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review. Two defects in the icon commit. The accessibility empty-state used String(localized: "No data"). That compiles, passes the i18n audit because the key does exist in the catalogue, and does not work: this extension's sources are StrandiOSWidgets and StrandiOSShared only, so Strand/Resources/Localizable.xcstrings is not in the target, and String(localized:) resolves against Bundle.main — which for an app extension is the extension's own bundle. It would have rendered English in every locale while looking localized in review. It is the only String(localized:) in the whole extension; every other string here is a bare literal, because the widget is not localized at all. Now a literal too, with the reason recorded so the next person does not re-add it. Localizing the widget properly is a separate piece of work — it would mean putting the catalogue in the extension target and covering Charge, Effort and Rest as well, not one accessibility value. The other is prose that outlived its code: the doc for accessoryScore still said the value reads at full strength "and the label recedes", describing the 9pt word the same commit had replaced with a glyph. Corrected, and while there noted that the .fullColor branch is defensive rather than hot — this family renders vibrant on the lock screen and in StandBy, so the tint realistically only reaches a gallery preview. i18n audit clean. --- StrandiOSWidgets/NOOPWidget.swift | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/StrandiOSWidgets/NOOPWidget.swift b/StrandiOSWidgets/NOOPWidget.swift index 2409936441..1e122322b4 100644 --- a/StrandiOSWidgets/NOOPWidget.swift +++ b/StrandiOSWidgets/NOOPWidget.swift @@ -130,8 +130,11 @@ struct NOOPWidgetView: View { /// colour it is given and maps it onto the wallpaper. A domain colour handed to it does not survive /// as that colour — it lands as an arbitrary grey whose luminance nobody chose, so Charge, Effort and /// Rest stopped being distinguishable AND stopped being legible. `.primary`/`.secondary` are the two - /// levels the system is designed to map, so the value reads at full strength and the label recedes, - /// which is the hierarchy the tint was there to express in the first place. + /// levels the system is designed to map, so the value reads at full strength and the glyph above it + /// recedes, which is the hierarchy the tint was there to express in the first place. + /// + /// The `.fullColor` branch is defensive rather than hot: this family renders `.vibrant` on the lock + /// screen and in StandBy, so the tint realistically only reaches a gallery preview. private func accessoryScore(_ label: String, symbol: String, text: String?, tint: Color) -> some View { VStack(spacing: 1) { // Glyph over value, the shape the request asked for. A 9pt word under each number was @@ -151,7 +154,12 @@ struct NOOPWidgetView: View { // metric. Collapse the cell to one element that still speaks "Charge, 68 percent". .accessibilityElement(children: .ignore) .accessibilityLabel(Text(label)) - .accessibilityValue(Text(text ?? String(localized: "No data"))) + // Plain literal, not String(localized:). This extension's sources are StrandiOSWidgets + + // StrandiOSShared only — Strand/Resources/Localizable.xcstrings is NOT in the target, and + // String(localized:) resolves against Bundle.main, which for an app extension is the extension's + // own bundle. It would compile, look localized, and render English in every locale. Every other + // string in this file is a bare literal for the same reason: the widget is not localized yet. + .accessibilityValue(Text(text ?? "No data")) } private func scoreStyle(hasValue: Bool, tint: Color) -> AnyShapeStyle { From 04d14c6bcfbfd94279f4f87a5ee0a406ab5f0df1 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Sat, 29 Aug 2026 00:48:21 +1200 Subject: [PATCH 4/4] widgets: stop the accessory's doc contradicting itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third review pass, all documentation and one dead argument. The doc for accessoryScore opened with "the tint applies on the home screen and in the gallery" and closed, two paragraphs later, by explaining that this family never renders on the home screen at all. Both sentences were mine, added in different commits, and together they told the reader the opposite things about the same branch. The opening now says what the closing paragraph established: the tint survives only where the system renders full colour, which for this family means a gallery preview. The type-level doc still described lock-screen accessories as "compact single-line / gauge layouts", which stopped covering the rectangular one when it became a glyph-over-value trio. And VStack(alignment: .leading) is gone. Both children set their own frame across the full width, so the alignment had no effect and only implied a left edge that is not there — the cells are centred. No behaviour change beyond that removal, which had none. --- StrandiOSWidgets/NOOPWidget.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/StrandiOSWidgets/NOOPWidget.swift b/StrandiOSWidgets/NOOPWidget.swift index 1e122322b4..50907d89ef 100644 --- a/StrandiOSWidgets/NOOPWidget.swift +++ b/StrandiOSWidgets/NOOPWidget.swift @@ -29,8 +29,8 @@ struct NOOPProvider: TimelineProvider { } /// The glanceable widget — the iOS analogue of the macOS menu-bar extra. -/// Home Screen families mirror Today's hero trio (Charge · Effort · Rest) as score rings; Lock Screen -/// accessories stay compact single-line / gauge layouts. +/// Home Screen families mirror Today's hero trio (Charge · Effort · Rest) as score rings. Lock Screen +/// accessories are compact: a single line, a gauge, or the rectangular glyph-over-value trio. struct NOOPWidgetView: View { @Environment(\.widgetFamily) private var family /// `.fullColor` on the home screen and in the gallery; `.vibrant` or `.accented` on the lock screen, @@ -105,7 +105,7 @@ struct NOOPWidgetView: View { // a whole row of that restating which widget the user chose to add, leaving the three scores — // the only reason to add it — squeezed underneath. The title is gone and the heart-rate line is // now conditional, so with no live HR the scores get the entire area. - VStack(alignment: .leading, spacing: 2) { + VStack(spacing: 2) { if let bpm = snap.bpm { Text("\(bpm) bpm") .font(.caption2) @@ -123,8 +123,8 @@ struct NOOPWidgetView: View { } } - /// One score cell. The tint applies on the home screen and in the gallery; on the LOCK SCREEN it is - /// deliberately dropped. + /// One score cell. On the LOCK SCREEN the domain tint is deliberately dropped; it survives only + /// where the system actually renders full colour, which for this family means a gallery preview. /// /// Lock-screen widgets render in `.vibrant` (or `.accented`), where the system desaturates whatever /// colour it is given and maps it onto the wallpaper. A domain colour handed to it does not survive