Repository navigation
perf(liquid): pose the live gauges still in Low Power Mode - #909
Conversation
The sky branch in LiquidTodayView has always been commented "Reduce-motion (and low-power) users get the same sky posed still", but isLowPowerModeEnabled appears nowhere in the UI — only in IOSDiagnostics, for reporting. The low-power half was never implemented. This implements it, for the sky and for the three live primitives (LiquidVessel, LiquidTube, LiquidThread), reusing the static render path that Reduce Motion already routes to. There is no SwiftUI environment key for Low Power Mode, so LiquidPowerMonitor observes NSProcessInfoPowerStateDidChange and publishes it; toggling the setting takes effect without a relaunch. Why it is worth doing, measured on an iPhone 17 Pro simulator seeded with a real 746 MB store, Today idle, three 15 s windows after a 45 s settle: Debug animated 18.7% of one core / 311 MB RSS posed still 0.0% / 290 MB Release animated 18.2% of one core / 298 MB RSS posed still 0.0% / 226 MB The animation is the entire idle cost on that screen, and it is the same in Debug and Release because the per-frame work is CoreGraphics drawing, not Swift arithmetic. Not-in-Low-Power behaviour is unchanged: re-measured at 19.5-20.1%, i.e. still animating.
ryanbr
left a comment
There was a problem hiding this comment.
Checked the claims rather than taking them, and the completeness question this kind of change usually fails on.
Verified
The premise is exactly right. isLowPowerModeEnabled occurs once in the whole tree, at IOSDiagnostics.swift:45, where it is reported and never read. The comment on the sky branch has been describing behaviour that was never wired.
Coverage is complete, which is the part worth checking. LiquidSky( has exactly one call site and this gates it. More to the point, there is no other TimelineView(.animation…) anywhere in Strand/, StrandiOS* or NOOPWatch* — the four surfaces here are all of them, so nothing keeps animating behind the fix.
No mid-animation glitch. staticGauge renders LiquidSim.posed(value ?? 0) rather than the live sim, so flipping Low Power Mode during a splash snaps to the correct posed picture instead of freezing a transient frame. That is the obvious way this change could look wrong and it does not.
The concurrency shape is fine here. @MainActor singleton with MainActor.assumeIsolated inside a queue: .main observer would be worth arguing about under Swift 6, but the target is SWIFT_VERSION 5.0 with SWIFT_STRICT_CONCURRENCY: minimal, so it compiles and the block genuinely runs on the main thread.
The measurement caveat is handled honestly — routing through the Reduce Motion branch is legitimate precisely because it is the same branch, and the description says so rather than implying real Low Power Mode was toggled.
Android has the identical gap, and it is bigger
Same shape as #908, which also fixed one platform of a two-platform problem:
| surface | mechanism |
|---|---|
ui/LiquidSky.kt:276 |
withFrameNanos frame loop |
ui/LiquidPrimitives.kt |
withFrameNanos |
ui/TimeOfDayBackground.kt:150 |
rememberInfiniteTransition("atmosphere") |
LiquidSky.kt:252 even carries the mirror-image comment — "Under Reduce Motion it collapses to the static picture (no frame loop)" — so Android is half-implemented in exactly the same way, for the same reason.
And the diagnostic parallel holds too: Android touches PowerManager only at AndroidDiagnostics.kt:270, and for isIgnoringBatteryOptimizations, which is a different question entirely (background-work exemption). isPowerSaveMode appears nowhere in the tree. The fix is that flag plus ACTION_POWER_SAVE_MODE_CHANGED for the live half, mapping onto the static branches Reduce Motion already uses.
Not asking for it in this PR — the Apple half stands alone and the saving is real. But it should be a stated follow-up rather than silent, the same way the Android half of #908 was.
One thing to flag about verification
All three files are app-target Swift, and app-build.yml is disabled_manually, so no CI job compiles any of this. The simulator measurement means it built locally, which is the strongest evidence available right now — but a compile-only dispatch of that workflow on this branch would turn "it built on one machine" into a check, and it is the same gap that let a red test sit on main for weeks before android.yml was turned on.
Android half of #909. Apple honours Low Power Mode for its live gauges; here the equivalent flag was read nowhere. `PowerManager` appears once in the tree, in AndroidDiagnostics, and for `isIgnoringBatteryOptimizations` — a different question entirely. `isPowerSaveMode` appeared nowhere at all. The parallel with the Apple side was exact: LiquidSky.kt already carried the comment "Under Reduce Motion it collapses to the static picture (no frame loop)", the same half-implemented pairing #909 found on the sky branch. Battery-saver state is a single process-wide monitor, registered once, read through `rememberPowerSaveMode()`. One receiver rather than one per composable: the liquid primitives alone have 26 call sites, and a DisposableEffect in each would register and unregister a BroadcastReceiver as gauges scroll in and out of composition — churn, in the change that exists to remove per-frame work. The Apple twin is a singleton for the same reason, and like it this is never unregistered. It stays live on ACTION_POWER_SAVE_MODE_CHANGED, so toggling battery saver takes effect without leaving the screen, and registers RECEIVER_NOT_EXPORTED through ContextCompat as WhoopConnectionService does. `rememberPoseStill()` is that OR reduce-motion. Gated: LiquidSky, the three LiquidPrimitives frame loops, TimeOfDayBackground's atmosphere transition, the LiveSessionScreen guardian breath, and the pulsing ConnectionDot halo. The last had no motion gate of any kind — it pulsed under "Remove animations" too, which was already a bug. NOT gated, deliberately: - One-shot animations. CountUpText, staggeredAppear and the 160 ms liquidPress tween settle and stop, so they are not the cost battery saver asks an app to avoid, and #909 gated only its TimelineViews. - TodayScreen's drag-reorder auto-scroll. It is a withFrameNanos loop, but it runs only while the user's finger is down. There is no idle to save, and quieting it would strip the auto-scroll out from under an active drag. `PoseStillCoverageTest` censuses withFrameNanos / rememberInfiniteTransition across com/noop/ui and fails on any file that does not consult the gate, with the drag loop exempted by name and by reason. That census is how three surfaces I had missed were found: my own survey used `head -8` and truncated, and I would have shipped LiveSessionScreen, Components and TodayScreen ungated on the strength of it. The test also pins that the gate reads both signals and stays live, since losing either half is invisible — the screen looks right in whichever mode still works. Named `renderStill` at the call sites rather than `posed`, because LiquidPrimitives already binds `posed` to `LiquidSim.posed(...)` in its static branches and the first version of this shadowed it. The ConnectionDot gate is hoisted to a local rather than called inside `if (pulsing && !rememberPoseStill())`, so the composable is not invoked from the right-hand side of a short-circuit. Verified: the coverage guard passes here, fails against the ungated tree, and fails rather than skips when the source is unlocatable; exactly one registerReceiver remains under com/noop/ui. No measurement on Android — the Apple numbers (~18% of a core idle, 0.0% posed) are cited as the shape, not restated as this platform's.
…911) Android half of #909. Apple honours Low Power Mode for its live gauges; here the equivalent flag was read nowhere. `PowerManager` appears once in the tree, in AndroidDiagnostics, and for `isIgnoringBatteryOptimizations` — a different question entirely. `isPowerSaveMode` appeared nowhere at all. The parallel with the Apple side was exact: LiquidSky.kt already carried the comment "Under Reduce Motion it collapses to the static picture (no frame loop)", the same half-implemented pairing #909 found on the sky branch. Battery-saver state is a single process-wide monitor, registered once, read through `rememberPowerSaveMode()`. One receiver rather than one per composable: the liquid primitives alone have 26 call sites, and a DisposableEffect in each would register and unregister a BroadcastReceiver as gauges scroll in and out of composition — churn, in the change that exists to remove per-frame work. The Apple twin is a singleton for the same reason, and like it this is never unregistered. It stays live on ACTION_POWER_SAVE_MODE_CHANGED, so toggling battery saver takes effect without leaving the screen, and registers RECEIVER_NOT_EXPORTED through ContextCompat as WhoopConnectionService does. `rememberPoseStill()` is that OR reduce-motion. Gated: LiquidSky, the three LiquidPrimitives frame loops, TimeOfDayBackground's atmosphere transition, the LiveSessionScreen guardian breath, and the pulsing ConnectionDot halo. The last had no motion gate of any kind — it pulsed under "Remove animations" too, which was already a bug. NOT gated, deliberately: - One-shot animations. CountUpText, staggeredAppear and the 160 ms liquidPress tween settle and stop, so they are not the cost battery saver asks an app to avoid, and #909 gated only its TimelineViews. - TodayScreen's drag-reorder auto-scroll. It is a withFrameNanos loop, but it runs only while the user's finger is down. There is no idle to save, and quieting it would strip the auto-scroll out from under an active drag. `PoseStillCoverageTest` censuses withFrameNanos / rememberInfiniteTransition across com/noop/ui and fails on any file that does not consult the gate, with the drag loop exempted by name and by reason. That census is how three surfaces I had missed were found: my own survey used `head -8` and truncated, and I would have shipped LiveSessionScreen, Components and TodayScreen ungated on the strength of it. The test also pins that the gate reads both signals and stays live, since losing either half is invisible — the screen looks right in whichever mode still works. Named `renderStill` at the call sites rather than `posed`, because LiquidPrimitives already binds `posed` to `LiquidSim.posed(...)` in its static branches and the first version of this shadowed it. The ConnectionDot gate is hoisted to a local rather than called inside `if (pulsing && !rememberPoseStill())`, so the composable is not invoked from the right-hand side of a short-circuit. Verified: the coverage guard passes here, fails against the ungated tree, and fails rather than skips when the source is unlocatable; exactly one registerReceiver remains under com/noop/ui. No measurement on Android — the Apple numbers (~18% of a core idle, 0.0% posed) are cited as the shape, not restated as this platform's.
…ilt sensor (#909) (#931) LiquidMotion ran CMMotionManager device motion at 60 Hz to drive the decorative slosh. onDisappear does not fire when the app is backgrounded, and NOOP declares bluetooth-central + location, so with a strap connected that sensor fusion kept being delivered all day behind the lock screen for a picture nobody could see. It now stops at the app boundary and re-arms on foreground, and startIfWanted() consults the gate itself rather than trusting call sites. A census (QuietMotionCoverageTests, the Apple twin of #911's PoseStillCoverageTest) found five more never-settling loops #909 never reached: the day-cycle atmosphere drift, the guardian breath, the ConnectionDot and PulseDot halos, the onboarding glows and radar sweep, and RecordingStatusLight's sync ring - which had no motion gate at all, so it pulsed under system Reduce Motion too, while the strap was offloading history. The gate moves into StrandDesign as NoopMotionState so the design system's own surfaces can reach it, reading three OR-ed signals: system Reduce Motion, Low Power Mode, and a new in-app "Reduce motion in NOOP" toggle (default OFF). The census test lives in the StrandDesign package deliberately: swift-packages runs it by default, and it fails loudly rather than skipping if it cannot find the tree or scans too few files - so it cannot pass by censusing nothing. Known asymmetry: the in-app toggle is Apple-only; Android's gate stays the two-signal rememberPoseStill(). Follow-up rather than a blocker.
…l gate (#909) The #sleep-layout hold-to-drag adds a `withFrameNanos` auto-scroll loop to SleepScreen — which PoseStillCoverageTest's census flags as a continuously- animating file that must consult rememberPoseStill(). But it's the same direct-manipulation case Today already is exempt for: the loop runs ONLY `while (sleepSectionDrag.key != null)` (finger down), so it has no idle cost to quiet, and gating it would strip auto-scroll out from under an active drag in battery saver. Add SleepScreen.kt to the `directManipulation` exemption beside TodayScreen.kt (its only withFrameNanos IS that loop).
…sleep-layout) (#1156) * fix(android): hold-to-drag reorder for Sleep cards, matching Today (#sleep-layout) The Sleep tab could only be reordered through the Arrange sheet's up/down buttons — long-press-dragging a Sleep card (the muscle memory from Today) did nothing, because Today's on-card drag was never added to Sleep. Reported on Android: "moving/customising sleep cards doesn't work like the Today tab." Add the same hold-to-drag reorder Today uses. A new SleepSectionReorder.kt is a deliberate, verbatim SleepSection-typed twin of Today's #today-layout drag infra (TodaySectionDragState / swapTargetForDraggedSection / movedTodaySection / TodayReorderableSection), kept separate so this can't destabilise the shipping Today reorder — the two are behaviourally identical. SleepScreen hoists a LazyListState + drag state, runs the same per-frame retry-swap + edge auto-scroll loop, and wraps each of the six analytical sections (spacer + card) in ONE keyed reorderable item so the whole card lifts and drops as a unit. On drop it persists through SleepLayoutPrefs.setOrder (the same store the Arrange sheet writes); the live in-memory order already moved during the drag. iOS/macOS unchanged: both already reorder Sleep via the customization sheet's native .onMove, and SleepLayoutPrefs is byte-identical across platforms — so the feature parity holds. On-tab drag vs sheet drag is a legitimate per-platform UI difference (it matches how each platform's Today tab already behaves), not a data divergence. Verified: compileFullDebugKotlin clean; new SleepSectionReorderTest pins the pure movedSleepSection move semantics (down / up / no-op / absent-section). The drag gesture + auto-scroll itself is validated on-device. * test(android): exempt SleepScreen drag auto-scroll from the pose-still gate (#909) The #sleep-layout hold-to-drag adds a `withFrameNanos` auto-scroll loop to SleepScreen — which PoseStillCoverageTest's census flags as a continuously- animating file that must consult rememberPoseStill(). But it's the same direct-manipulation case Today already is exempt for: the loop runs ONLY `while (sleepSectionDrag.key != null)` (finger down), so it has no idle cost to quiet, and gating it would strip auto-scroll out from under an active drag in battery saver. Add SleepScreen.kt to the `directManipulation` exemption beside TodayScreen.kt (its only withFrameNanos IS that loop).
The sky branch in LiquidTodayView has always been commented "Reduce-motion (and low-power) users get the same sky posed still", but isLowPowerModeEnabled appears nowhere in the UI — only in IOSDiagnostics, for reporting. The low-power half was never implemented. This implements it, for the sky and for the three live primitives (LiquidVessel, LiquidTube, LiquidThread), reusing the static render path that Reduce Motion already routes to. There is no SwiftUI environment key for Low Power Mode, so LiquidPowerMonitor observes NSProcessInfoPowerStateDidChange and publishes it; toggling the setting takes effect without a relaunch. Why it is worth doing, measured on an iPhone 17 Pro simulator seeded with a real 746 MB store, Today idle, three 15 s windows after a 45 s settle: Debug animated 18.7% of one core / 311 MB RSS posed still 0.0% / 290 MB Release animated 18.2% of one core / 298 MB RSS posed still 0.0% / 226 MB The animation is the entire idle cost on that screen, and it is the same in Debug and Release because the per-frame work is CoreGraphics drawing, not Swift arithmetic. Not-in-Low-Power behaviour is unchanged: re-measured at 19.5-20.1%, i.e. still animating.
) (ryanbr#911) Android half of ryanbr#909. Apple honours Low Power Mode for its live gauges; here the equivalent flag was read nowhere. `PowerManager` appears once in the tree, in AndroidDiagnostics, and for `isIgnoringBatteryOptimizations` — a different question entirely. `isPowerSaveMode` appeared nowhere at all. The parallel with the Apple side was exact: LiquidSky.kt already carried the comment "Under Reduce Motion it collapses to the static picture (no frame loop)", the same half-implemented pairing ryanbr#909 found on the sky branch. Battery-saver state is a single process-wide monitor, registered once, read through `rememberPowerSaveMode()`. One receiver rather than one per composable: the liquid primitives alone have 26 call sites, and a DisposableEffect in each would register and unregister a BroadcastReceiver as gauges scroll in and out of composition — churn, in the change that exists to remove per-frame work. The Apple twin is a singleton for the same reason, and like it this is never unregistered. It stays live on ACTION_POWER_SAVE_MODE_CHANGED, so toggling battery saver takes effect without leaving the screen, and registers RECEIVER_NOT_EXPORTED through ContextCompat as WhoopConnectionService does. `rememberPoseStill()` is that OR reduce-motion. Gated: LiquidSky, the three LiquidPrimitives frame loops, TimeOfDayBackground's atmosphere transition, the LiveSessionScreen guardian breath, and the pulsing ConnectionDot halo. The last had no motion gate of any kind — it pulsed under "Remove animations" too, which was already a bug. NOT gated, deliberately: - One-shot animations. CountUpText, staggeredAppear and the 160 ms liquidPress tween settle and stop, so they are not the cost battery saver asks an app to avoid, and ryanbr#909 gated only its TimelineViews. - TodayScreen's drag-reorder auto-scroll. It is a withFrameNanos loop, but it runs only while the user's finger is down. There is no idle to save, and quieting it would strip the auto-scroll out from under an active drag. `PoseStillCoverageTest` censuses withFrameNanos / rememberInfiniteTransition across com/noop/ui and fails on any file that does not consult the gate, with the drag loop exempted by name and by reason. That census is how three surfaces I had missed were found: my own survey used `head -8` and truncated, and I would have shipped LiveSessionScreen, Components and TodayScreen ungated on the strength of it. The test also pins that the gate reads both signals and stays live, since losing either half is invisible — the screen looks right in whichever mode still works. Named `renderStill` at the call sites rather than `posed`, because LiquidPrimitives already binds `posed` to `LiquidSim.posed(...)` in its static branches and the first version of this shadowed it. The ConnectionDot gate is hoisted to a local rather than called inside `if (pulsing && !rememberPoseStill())`, so the composable is not invoked from the right-hand side of a short-circuit. Verified: the coverage guard passes here, fails against the ungated tree, and fails rather than skips when the source is unlocatable; exactly one registerReceiver remains under com/noop/ui. No measurement on Android — the Apple numbers (~18% of a core idle, 0.0% posed) are cited as the shape, not restated as this platform's.
…ilt sensor (ryanbr#909) (ryanbr#931) LiquidMotion ran CMMotionManager device motion at 60 Hz to drive the decorative slosh. onDisappear does not fire when the app is backgrounded, and NOOP declares bluetooth-central + location, so with a strap connected that sensor fusion kept being delivered all day behind the lock screen for a picture nobody could see. It now stops at the app boundary and re-arms on foreground, and startIfWanted() consults the gate itself rather than trusting call sites. A census (QuietMotionCoverageTests, the Apple twin of ryanbr#911's PoseStillCoverageTest) found five more never-settling loops ryanbr#909 never reached: the day-cycle atmosphere drift, the guardian breath, the ConnectionDot and PulseDot halos, the onboarding glows and radar sweep, and RecordingStatusLight's sync ring - which had no motion gate at all, so it pulsed under system Reduce Motion too, while the strap was offloading history. The gate moves into StrandDesign as NoopMotionState so the design system's own surfaces can reach it, reading three OR-ed signals: system Reduce Motion, Low Power Mode, and a new in-app "Reduce motion in NOOP" toggle (default OFF). The census test lives in the StrandDesign package deliberately: swift-packages runs it by default, and it fails loudly rather than skipping if it cannot find the tree or scans too few files - so it cannot pass by censusing nothing. Known asymmetry: the in-app toggle is Apple-only; Android's gate stays the two-signal rememberPoseStill(). Follow-up rather than a blocker.
…sleep-layout) (ryanbr#1156) * fix(android): hold-to-drag reorder for Sleep cards, matching Today (#sleep-layout) The Sleep tab could only be reordered through the Arrange sheet's up/down buttons — long-press-dragging a Sleep card (the muscle memory from Today) did nothing, because Today's on-card drag was never added to Sleep. Reported on Android: "moving/customising sleep cards doesn't work like the Today tab." Add the same hold-to-drag reorder Today uses. A new SleepSectionReorder.kt is a deliberate, verbatim SleepSection-typed twin of Today's #today-layout drag infra (TodaySectionDragState / swapTargetForDraggedSection / movedTodaySection / TodayReorderableSection), kept separate so this can't destabilise the shipping Today reorder — the two are behaviourally identical. SleepScreen hoists a LazyListState + drag state, runs the same per-frame retry-swap + edge auto-scroll loop, and wraps each of the six analytical sections (spacer + card) in ONE keyed reorderable item so the whole card lifts and drops as a unit. On drop it persists through SleepLayoutPrefs.setOrder (the same store the Arrange sheet writes); the live in-memory order already moved during the drag. iOS/macOS unchanged: both already reorder Sleep via the customization sheet's native .onMove, and SleepLayoutPrefs is byte-identical across platforms — so the feature parity holds. On-tab drag vs sheet drag is a legitimate per-platform UI difference (it matches how each platform's Today tab already behaves), not a data divergence. Verified: compileFullDebugKotlin clean; new SleepSectionReorderTest pins the pure movedSleepSection move semantics (down / up / no-op / absent-section). The drag gesture + auto-scroll itself is validated on-device. * test(android): exempt SleepScreen drag auto-scroll from the pose-still gate (ryanbr#909) The #sleep-layout hold-to-drag adds a `withFrameNanos` auto-scroll loop to SleepScreen — which PoseStillCoverageTest's census flags as a continuously- animating file that must consult rememberPoseStill(). But it's the same direct-manipulation case Today already is exempt for: the loop runs ONLY `while (sleepSectionDrag.key != null)` (finger down), so it has no idle cost to quiet, and gating it would strip auto-scroll out from under an active drag in battery saver. Add SleepScreen.kt to the `directManipulation` exemption beside TodayScreen.kt (its only withFrameNanos IS that loop).
…; i18n and lint upkeep - The Coach typing bubble consults rememberPoseStill() (ryanbr#909 PoseStillCoverageTest): stepped, still dots under Remove animations / battery saver. - Coach settings re-reads "has a key" when a key is saved or cleared. - "HRV (rMSSD)" translated in it/pl; "Health Connect" is a pure brand phrase for the echo gate. - AddDeviceWizard no longer has a detached doc comment: lowered the lint baseline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sky branch in
LiquidTodayViewis commented:but
isLowPowerModeEnabledappears nowhere in the UI — the only hit in the whole tree isStrand/System/IOSDiagnostics.swift, where it is reported, not acted on. The low-power half was never implemented. This implements it, for the sky and for the three live primitives (LiquidVessel,LiquidTube,LiquidThread), reusing the exact static render path that Reduce Motion already routes to.There is no SwiftUI environment key for Low Power Mode, so
LiquidPowerMonitorobserves.NSProcessInfoPowerStateDidChangeand publishes it; toggling the setting takes effect without a relaunch.Why it is worth doing
A continuously-animating
Canvasis not free. Measured on an iPhone 17 Pro simulator seeded with a real 746 MB store, default Today screen sitting idle with no touch input — three 15 s CPU windows after a 45 s settle, machine otherwise quiet:The animation is the entire idle cost of that screen. Notably it is the same in Debug and Release, because the per-frame work is
GraphicsContextdrawing rather than Swift arithmetic — optimizing the Swift only moves the smaller half (a standalone-Ononevs-Obenchmark of theliquidWave/ fleck math gives 0.614 → 0.100 ms per hero-gauge frame, which is real but is not what dominates the frame).Low Power Mode is the user explicitly asking the system to stop this class of work, so honouring it costs nothing when it is off and saves ~18% of a core when it is on.
No behaviour change when Low Power Mode is off
Re-measured after the change, not in Low Power Mode: 19.7 / 19.5 / 20.1 % of one core — still animating, unchanged within noise. Screenshotted the Today screen after the change: gauges, sky, and all card surfaces render identically.
Scope note
I could not toggle real Low Power Mode in the simulator, so the "posed still" column above is the Reduce Motion route — which is the same
staticGauge/staticTube/staticThread/LiquidSkyStaticbranch this change sends low-power users down. The new condition isanimated && !reduceMotion && !power.isLowPower, so the saving is the measured one; what is inferred rather than directly exercised is only that iOS reports the flag, whichIOSDiagnosticsalready relies on.