NOOP version
11.8.0 (build 198)
Platform
macOS
OS version
macOS 27.0 (26A428)
WHOOP strap / Oura ring model
Not strap-dependent. This is decorative UI rendering and reproduces regardless of the connected device or data source.
Where the data is coming from
Not applicable. It reproduces with the app simply left running.
Test Centre profile
Not a test-mode bug
What happens
With "Reduce motion in NOOP" off (the default), the Liquid gauges keep rendering for as long as the app runs, whether or not the window is visible. On an M1 Pro that costs between a quarter and half a CPU core continuously. Hiding the app with Cmd+H does not reduce it. It reproducibly increases it.
I noticed because I was auditing idle CPU on this Mac for unrelated reasons and NOOP was the largest single process on the system, ahead of WindowServer and kernel_task.
A second, much smaller observation about the shipped macOS build is in section 2. I originally assumed it contributed to the CPU cost, measured it, and it does not. It is reported separately because the build config looks unintended rather than because it is expensive.
1. The liquid animations have no visibility condition
Three TimelineView(.animation(...)) drivers in Strand/Liquid/LiquidPrimitives.swift:
| Line |
Cadence |
View |
| 302 |
1.0 / 60.0 |
gauge |
| 348 |
1.0 / 30.0 |
liveTube |
| 391 |
1.0 / 60.0 |
liveThread |
Each one is gated, but only on the motion preference, never on visibility:
// LiquidPrimitives.swift:295
if animated && !motion.poseStill(reduceMotion) { gauge } else { staticGauge }
// :344
if animated && !motion.poseStill(reduceMotion) { liveTube } else { staticTube }
// :387
if animated && !motion.poseStill(reduceMotion) { liveThread } else { staticThread }
poseStill(_:) is reduceMotion || isLowPower || quietMotion (NoopMotion.swift:146). None of those three signals has anything to do with whether the window is on screen.
scenePhase is read elsewhere in the app (StrandApp.swift:37, InsightsView.swift:66, AppModel.swift), but nothing under Strand/Liquid/ reads it, and a grep across the repository finds no occlusionState, NSWindowDidChangeOcclusionState or isOnActiveSpace observer anywhere.
To be fair to the original decision, the comment above gauge explains the 60 fps choice and shows it was costed:
60fps: on the 120Hz ProMotion panel a 30fps cap updated the fluid only every 4th refresh, which read as juddery slosh. Only the 3 hero gauges + HR thread run live now (the small ones are static), so the higher rate is affordable and the liquid actually flows.
That reasoning holds for a visible window. The problem is that the same rate keeps running when nobody is looking at it.
Profile
sample, 8 s, on the local Release build with the window visible. Top non-idle leaves:
RB::SurfacePool::collect(bool) (RenderBox) 199
_dispatch_timer_heap_resift (libdispatch) 195
CAImageQueueConsumeUnconsumedInRange (QuartzCore) 76
CABackingStoreCollect_(double, bool) (QuartzCore) 47
App-side frames present in the same profile:
LiquidRender.vessel(_:_:_:now:tint:)
LiquidRender.thread(_:_:values:now:tint:segments:)
LiquidRender.progressGradient(_:)
liquidSkyAt(_:light:)
Color.liquidComponents()
Color.sRGBComponents(hex:)
closure #1 in Color.init(light:dark:)
Secondary: the palette re-parses hex strings on every colour read
The last three frames above are worth a look on their own. On macOS Color(light:dark:) builds a dynamic NSColor whose resolution closure parses the hex string each time it runs (Palette.swift:44):
self.init(nsColor: NSColor(name: nil) { appearance in
let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
let c = Color.sRGBComponents(hex: isDark ? dark : light)
and sRGBComponents(hex:) is not cheap (Palette.swift:8):
let raw = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: raw).scanHexInt64(&int)
liquidComponents() (LiquidCore.swift:29) reads components back out with NSColor(self).usingColorSpace(.sRGB), which forces that closure to run again. liquidMix, liquidDarker and liquidLighter all route through it, and LiquidSky.swift:56 calls liquidComponents() twice in one expression. Because the colour is dynamic, none of this can be constant-folded, so the palette is re-parsed from strings while frames are being produced.
I have not isolated how much of the total this accounts for, so treat it as a separate cleanup rather than as part of the measured cost above. Caching the resolved components looks worthwhile regardless of the visibility fix.
2. The shipped macOS build is a Debug build
This is not a performance claim. I measured it and it makes no meaningful difference, see the table below. Reporting it because the inconsistency with iOS looks accidental.
Both release workflows build macOS as Debug and iOS as Release:
| Workflow |
Line |
Target |
Configuration |
fork-release.yml |
218 |
macOS (Strand) |
Debug |
fork-release.yml |
273 |
iOS (NOOPiOS) |
Release |
fork-testing-build.yml |
156 |
macOS (Strand) |
Debug |
fork-testing-build.yml |
202 |
iOS (NOOPiOS) |
Release |
The comment above the macOS step says this is deliberate ("these command-line settings override the config, so it stays Debug (unchanged shipping behaviour)"). Reading it in context, it is about forcing a universal binary after the Intel-Mac report in #51, and the Debug configuration is preserved as a side effect rather than chosen on its merits.
What ships:
| Artefact |
Contents/MacOS/ |
NOOP-macos-v11.8.0.zip (stable, v11.8.0) |
120 KB stub + NOOP Staging.debug.dylib 195 MB + __preview.dylib |
NOOP-macos-v11.8.0.zip (testing-latest) |
same shape, debug.dylib 196 MB |
Local -configuration Release build of the same commit |
single 53 MB binary, no debug.dylib, no __preview.dylib |
Also worth a look: the stable macOS artefact carries the com.noopapp.noop.staging bundle id and a NOOP Staging executable, which may not be intended for a v11.8.0 release.
What I expected instead
The liquid timelines should stop when the window is occluded, hidden or minimised. poseStill(_:) already exists as the single gate, so a fourth signal alongside reduceMotion, isLowPower and quietMotion would fit the existing shape.
Steps to reproduce
- Launch NOOP on macOS with "Reduce motion in NOOP" off and leave the Today screen open.
- Read
ps -p $(pgrep -x "NOOP Staging") -o time= twice, 120 s apart, and divide the delta by 120.
- Hide the app with Cmd+H, wait 20 s, measure again. It goes up, not down.
- Turn "Reduce motion in NOOP" on and measure again. It drops to roughly zero.
Measurements
MacBook Pro 18,3 (M1 Pro, 6P+2E, 16 GB), macOS 27.0 (26A428), two external displays at 60 Hz and 120 Hz. NOOP Staging 11.8.0 (198) built locally from dab0efe at -configuration Release unless stated otherwise. Every figure is a CPU-time delta over a 120 s window, taken 50 s after launch or 20 s after a window state change.
Run 1, four states, app relaunched between the motion-preference changes:
| # |
Reduce motion in NOOP |
Window |
CPU |
| A |
off |
visible |
28.76 % |
| B |
off |
hidden (Cmd+H) |
49.33 % |
| C |
on |
visible |
0.09 % |
| D |
on |
hidden |
0.01 % |
Run 2, the same A/B inside a single process with no relaunch, to rule out a restart artefact:
| # |
Window |
CPU |
| A |
visible |
34.15 % |
| B |
hidden (Cmd+H) |
41.75 % |
| A2 |
visible again |
34.11 % |
Run 3, the shipped Debug artefact against my Release build of the same version, both visible, motion preference off:
| Build |
CPU |
NOOP-macos-v11.8.0.zip as shipped (Debug) |
27.51 % |
Same version built -configuration Release |
26.49 % |
So the build configuration is not the story. The animation is.
Two things stand out in runs 1 and 2. Hiding the window costs more than showing it, across two independent runs, and re-showing returns to baseline. And the motion preference is a complete fix rather than a partial one. 0.09 % is the app doing its ordinary background work with the gauges held still.
I have no Instruments trace for the hidden-is-worse part, so treat the explanation as a guess rather than a finding. With the window on screen the display link appears to pace the timeline, and once hidden that pacing is gone and the schedule free-runs. _dispatch_timer_heap_resift at 195 samples is consistent with that, but it is not proof.
For scale, the copy I had been running accumulated 915 min 51 s of CPU over 2 d 18 h 49 m of wall clock. That is a 22.8 % average, and it includes periods where the machine was asleep, so the awake-time figure is higher.
Workaround
Settings → "Reduce motion in NOOP" (SettingsView.swift:1362). It writes noop.quietMotion, which feeds poseStill(_:) and swaps gauge, liveTube and liveThread for their static variants.
I verified the effect by setting the key and relaunching, which is scenarios C and D above. I did not separately time the in-app toggle, though NoopMotion.swift:125 mirrors the key on UserDefaults.didChangeNotification, so it should apply without a restart.
One note if anyone scripts this rather than using the toggle: a defaults write from a shell will not take effect in a running instance, because that notification is intra-process. The value is picked up at the next launch (NoopMotion.swift:115).
The toggle is off by default and its help text presents it as a preference ("Saves battery"). On this machine it is the difference between a third of a core and nothing, and someone hunting a hot fan has no reason to suspect a decorative setting.
NOOP version
11.8.0 (build 198)
Platform
macOS
OS version
macOS 27.0 (26A428)
WHOOP strap / Oura ring model
Not strap-dependent. This is decorative UI rendering and reproduces regardless of the connected device or data source.
Where the data is coming from
Not applicable. It reproduces with the app simply left running.
Test Centre profile
Not a test-mode bug
What happens
With "Reduce motion in NOOP" off (the default), the Liquid gauges keep rendering for as long as the app runs, whether or not the window is visible. On an M1 Pro that costs between a quarter and half a CPU core continuously. Hiding the app with Cmd+H does not reduce it. It reproducibly increases it.
I noticed because I was auditing idle CPU on this Mac for unrelated reasons and NOOP was the largest single process on the system, ahead of
WindowServerandkernel_task.A second, much smaller observation about the shipped macOS build is in section 2. I originally assumed it contributed to the CPU cost, measured it, and it does not. It is reported separately because the build config looks unintended rather than because it is expensive.
1. The liquid animations have no visibility condition
Three
TimelineView(.animation(...))drivers inStrand/Liquid/LiquidPrimitives.swift:1.0 / 60.0gauge1.0 / 30.0liveTube1.0 / 60.0liveThreadEach one is gated, but only on the motion preference, never on visibility:
poseStill(_:)isreduceMotion || isLowPower || quietMotion(NoopMotion.swift:146). None of those three signals has anything to do with whether the window is on screen.scenePhaseis read elsewhere in the app (StrandApp.swift:37,InsightsView.swift:66,AppModel.swift), but nothing underStrand/Liquid/reads it, and a grep across the repository finds noocclusionState,NSWindowDidChangeOcclusionStateorisOnActiveSpaceobserver anywhere.To be fair to the original decision, the comment above
gaugeexplains the 60 fps choice and shows it was costed:That reasoning holds for a visible window. The problem is that the same rate keeps running when nobody is looking at it.
Profile
sample, 8 s, on the local Release build with the window visible. Top non-idle leaves:App-side frames present in the same profile:
Secondary: the palette re-parses hex strings on every colour read
The last three frames above are worth a look on their own. On macOS
Color(light:dark:)builds a dynamicNSColorwhose resolution closure parses the hex string each time it runs (Palette.swift:44):and
sRGBComponents(hex:)is not cheap (Palette.swift:8):liquidComponents()(LiquidCore.swift:29) reads components back out withNSColor(self).usingColorSpace(.sRGB), which forces that closure to run again.liquidMix,liquidDarkerandliquidLighterall route through it, andLiquidSky.swift:56callsliquidComponents()twice in one expression. Because the colour is dynamic, none of this can be constant-folded, so the palette is re-parsed from strings while frames are being produced.I have not isolated how much of the total this accounts for, so treat it as a separate cleanup rather than as part of the measured cost above. Caching the resolved components looks worthwhile regardless of the visibility fix.
2. The shipped macOS build is a Debug build
This is not a performance claim. I measured it and it makes no meaningful difference, see the table below. Reporting it because the inconsistency with iOS looks accidental.
Both release workflows build macOS as Debug and iOS as Release:
fork-release.ymlStrand)fork-release.ymlNOOPiOS)fork-testing-build.ymlStrand)fork-testing-build.ymlNOOPiOS)The comment above the macOS step says this is deliberate ("these command-line settings override the config, so it stays Debug (unchanged shipping behaviour)"). Reading it in context, it is about forcing a universal binary after the Intel-Mac report in
#51, and the Debug configuration is preserved as a side effect rather than chosen on its merits.What ships:
Contents/MacOS/NOOP-macos-v11.8.0.zip(stable,v11.8.0)NOOP Staging.debug.dylib195 MB +__preview.dylibNOOP-macos-v11.8.0.zip(testing-latest)debug.dylib196 MB-configuration Releasebuild of the same commitdebug.dylib, no__preview.dylibAlso worth a look: the stable macOS artefact carries the
com.noopapp.noop.stagingbundle id and aNOOP Stagingexecutable, which may not be intended for av11.8.0release.What I expected instead
The liquid timelines should stop when the window is occluded, hidden or minimised.
poseStill(_:)already exists as the single gate, so a fourth signal alongsidereduceMotion,isLowPowerandquietMotionwould fit the existing shape.Steps to reproduce
ps -p $(pgrep -x "NOOP Staging") -o time=twice, 120 s apart, and divide the delta by 120.Measurements
MacBook Pro 18,3 (M1 Pro, 6P+2E, 16 GB), macOS 27.0 (26A428), two external displays at 60 Hz and 120 Hz. NOOP Staging 11.8.0 (198) built locally from
dab0efeat-configuration Releaseunless stated otherwise. Every figure is a CPU-time delta over a 120 s window, taken 50 s after launch or 20 s after a window state change.Run 1, four states, app relaunched between the motion-preference changes:
Run 2, the same A/B inside a single process with no relaunch, to rule out a restart artefact:
Run 3, the shipped Debug artefact against my Release build of the same version, both visible, motion preference off:
NOOP-macos-v11.8.0.zipas shipped (Debug)-configuration ReleaseSo the build configuration is not the story. The animation is.
Two things stand out in runs 1 and 2. Hiding the window costs more than showing it, across two independent runs, and re-showing returns to baseline. And the motion preference is a complete fix rather than a partial one. 0.09 % is the app doing its ordinary background work with the gauges held still.
I have no Instruments trace for the hidden-is-worse part, so treat the explanation as a guess rather than a finding. With the window on screen the display link appears to pace the timeline, and once hidden that pacing is gone and the schedule free-runs.
_dispatch_timer_heap_resiftat 195 samples is consistent with that, but it is not proof.For scale, the copy I had been running accumulated 915 min 51 s of CPU over 2 d 18 h 49 m of wall clock. That is a 22.8 % average, and it includes periods where the machine was asleep, so the awake-time figure is higher.
Workaround
Settings → "Reduce motion in NOOP" (
SettingsView.swift:1362). It writesnoop.quietMotion, which feedsposeStill(_:)and swapsgauge,liveTubeandliveThreadfor their static variants.I verified the effect by setting the key and relaunching, which is scenarios C and D above. I did not separately time the in-app toggle, though
NoopMotion.swift:125mirrors the key onUserDefaults.didChangeNotification, so it should apply without a restart.One note if anyone scripts this rather than using the toggle: a
defaults writefrom a shell will not take effect in a running instance, because that notification is intra-process. The value is picked up at the next launch (NoopMotion.swift:115).The toggle is off by default and its help text presents it as a preference ("Saves battery"). On this machine it is the difference between a third of a core and nothing, and someone hunting a hot fan has no reason to suspect a decorative setting.