Skip to content

perf(liquid): pose the live gauges still in Low Power Mode - #909

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
vishk23:perf/liquid-low-power-mode
Jul 28, 2026
Merged

ryanbr merged 1 commit into
ryanbr:mainfrom
vishk23:perf/liquid-low-power-mode

Conversation

@vishk23

@vishk23 vishk23 commented Jul 27, 2026

Copy link
Copy Markdown

The sky branch in LiquidTodayView is commented:

Reduce-motion (and low-power) users get the same sky posed still — no twinkle/breath.

but isLowPowerModeEnabled appears nowhere in the UI — the only hit in the whole tree is Strand/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 LiquidPowerMonitor observes .NSProcessInfoPowerStateDidChange and publishes it; toggling the setting takes effect without a relaunch.

Why it is worth doing

A continuously-animating Canvas is 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:

build live gauges posed still
Debug 18.4 / 19.3 / 18.3 % of one core, 311 MB RSS 0.0 / 0.0 / 0.0 %, 290 MB
Release 18.2 / 18.7 % of one core, 298 MB RSS 0.0 / 0.0 / 0.0 %, 226 MB

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 GraphicsContext drawing rather than Swift arithmetic — optimizing the Swift only moves the smaller half (a standalone -Onone vs -O benchmark of the liquidWave / 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 / LiquidSkyStatic branch this change sends low-power users down. The new condition is animated && !reduceMotion && !power.isLowPower, so the saving is the measured one; what is inferred rather than directly exercised is only that iOS reports the flag, which IOSDiagnostics already relies on.

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 ryanbr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

ryanbr added a commit that referenced this pull request Jul 28, 2026
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.
@ryanbr
ryanbr merged commit 3d62c58 into ryanbr:main Jul 28, 2026
2 checks passed
ryanbr added a commit that referenced this pull request Jul 28, 2026
…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.
@vishk23
vishk23 deleted the perf/liquid-low-power-mode branch July 30, 2026 19:59
ryanbr pushed a commit that referenced this pull request Aug 2, 2026
…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.
ryanbr added a commit that referenced this pull request Aug 8, 2026
…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).
ryanbr added a commit that referenced this pull request Aug 8, 2026
…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).
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
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.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
) (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.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…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.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…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).
ruxa40 pushed a commit to denismanulov/reNOOP that referenced this pull request Oct 3, 2026
…; 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>
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.

2 participants