Skip to content

widgets: make the lock-screen accessory read like a lock-screen widget - #1715

Merged
ryanbr merged 4 commits into
mainfrom
ios-lockscreen-widget-polish
Aug 28, 2026
Merged

ryanbr merged 4 commits into
mainfrom
ios-lockscreen-widget-polish

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 28, 2026

Copy link
Copy Markdown
Owner

A community request asked for a WHOOP-style mini lock-screen widget. iOS already had one — accessoryRectangular is declared and renders Charge, Effort and Rest from the same snapshot — so nothing needed building. It needed to behave like a lock-screen widget, and to look like the thing that was asked for.

Before / after

BEFORE                              AFTER
NOOP                    72 bpm                          72 bpm
68%     4.1     82%                  🧘        🏋        🌙
Charge  Effort  Rest                68%       4.1       82%

1. 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. A domain colour does not survive as that colour — it lands as an arbitrary grey whose luminance nobody chose. So Charge, Effort and Rest were neither distinguishable from each other nor reliably legible: the tint was doing no work and taking the visual hierarchy with it.

The cells now read widgetRenderingMode and hand the system .primary / .secondary off the lock screen, which are the two levels it is built to map. The home screen and the widget gallery keep the domain colours exactly as before.

2. 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 just 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.

3. Metric glyphs

What makes WHOOP's readable at a glance is that each cell is a glyph, not a word. Icons now sit over each value — figure.mind.and.body, figure.strengthtraining.traditional, moon.fill — centred, replacing the 9pt word under each number, which was the second thing naming a metric the icon already names.

Accessibility is the part an icon quietly breaks. 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", using a catalogue string for the empty case — "No data" already exists in all ten locales, so there is no new copy and the i18n gate stays clean.

SF Symbols rather than porting Android's ic_widget_* drawables: they render correctly in the vibrant mode item 1 started respecting, scale with Dynamic Type, and add no assets. The cost is that they are not pixel-identical to Android's glyphs — visible if someone screenshots both, not functional.

Deliberately not done

The home-screen families. They draw score rings whose order and shape deliberately mirror TodayView's hero trio. Putting glyphs there would diverge the widget from the app's own home screen in order to chase a lock-screen look. Untouched.

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 — as its own issue, not inside a lock-screen polish.

Android. 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. There is no surface on a phone to target.

Verified

StrandiOSWidgets is app-target and SwiftUI does not exist on Linux, so nothing here could be compiled locally — app-build is the first compiler to see it, and it is green on both legs for this exact head. The AnyShapeStyle branches are written with explicit types rather than leading-dot inference for that reason: that construct type-checks in isolation and fails in a real build.

Tools/i18n_audit.py --ci clean.

ryanbr added 4 commits August 29, 2026 00:25
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.
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.
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.
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.
@ryanbr
ryanbr merged commit 1905e1a into main Aug 28, 2026
7 checks passed
@ryanbr
ryanbr deleted the ios-lockscreen-widget-polish branch August 28, 2026 12:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant