The scene stops heating the MacBook

"Terribly high CPU usage, the MacBook gets really hot" - a fanless MacBook
Air on a 144Hz screen. Measured on that machine with headed engines
(process CPU plus the per-process GPU time macOS keeps): an idle Aero
library cost LibreWolf ~110% CPU and ~230ms/s of GPU, the study feed ~130%,
Chromium up to ~57% CPU and ~350ms/s of GPU, Safari ~220-250ms/s of
WindowServer GPU. Priced one by one:

- the gradient mask on .ice-floats re-rendered the animated layer offscreen
  on every frame (~75 points of Gecko CPU). The floats now rise through a
  plain rectangular band clear of the header, the bottom chrome and the
  rail, and fade in and out inside it, so the clip never cuts a visible one;
- the overlay grain forced a backdrop readback per frame for a texture that
  measured a mean <=0.11/255 difference: removed;
- the invisible blob drift made every frame a full-screen redraw: the blobs
  are painted once;
- static glass (answer tiles, feedback panel, chips, inputs, dropzones,
  lists, segmented controls, Aero hint chips) re-blurred the moving scene
  every frame: only floating glass blurs now, and Aero's baked chat chrome
  drops a live blur that never showed under the bake;
- the scene also parks while the window has no focus and under any dialog
  or sheet scrim (.scene-pause); the loading icon is a compositor-run CSS
  animation instead of a framer-motion main-thread loop.

After, focused and animating: LibreWolf library 109% -> 33% CPU, study
134% -> 34%, chat 130% -> 42%; Chromium GPU 2.5-4.4x lower with 7-22 points
less CPU; Safari's WindowServer GPU -20-26% in Aero, about half in dark.
Unfocused or under a sheet: nothing. bubbles-test sweeps every float through
41 phases against the band and pins the parking and the cost contract;
tests/heat-probe.mjs (macOS) repeats the measurement; the suites find
Playwright's Chromium on macOS.

All 15 suites pass, lint clean, the contrast scan lost a finding. Version
0.6.7, SW v89.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-10-15 19:39:29 +02:00
commit bfc0f806b9
27 changed files with 613 additions and 249 deletions

View file

@ -197,14 +197,12 @@
--ice-base-bottom: #e6f1fb;
--ice-blob-opacity: 0.045;
--ice-orb-opacity: 0.22;
--ice-grain-opacity: 0.05;
}
.dark {
--ice-base-top: #070b16;
--ice-base-bottom: #0c1426;
--ice-blob-opacity: 0.1;
--ice-orb-opacity: 0.38;
--ice-grain-opacity: 0.045;
}
/* Frutiger Aero — the third theme (Settings → Theme). An underwater scene:
@ -265,7 +263,6 @@
--ice-base-bottom: #0a5490;
--ice-blob-opacity: 0.5;
--ice-orb-opacity: 0.6;
--ice-grain-opacity: 0.02;
}
/* The scene is the wallpaper itself (a Frutiger Aero scene made with Gemini,
@ -321,14 +318,17 @@
}
/* Rising bubbles. Hidden entirely in the other themes, so they cost nothing
there; under reduced motion they sit still instead of rising. */
there; under reduced motion they sit still instead of rising. Each one
rises from the bottom of the open band to its top (see .ice-floats) and
fades in and out inside it, so the band's clip never cuts a visible
bubble. */
.ice-bubble {
display: none;
}
.aero .ice-bubble {
display: block;
position: absolute;
bottom: -14vh;
bottom: 0;
border-radius: 50%;
opacity: 0;
background:
@ -351,16 +351,13 @@
animation-delay: var(--bubble-delay, 0s);
will-change: transform, opacity;
}
.ice-paused .ice-bubble {
animation-play-state: paused;
}
@keyframes aero-bubble-rise {
0% { transform: translate3d(0, 0, 0) scale(0.85); opacity: 0; }
6% { opacity: var(--bubble-opacity, 0.9); }
50% { transform: translate3d(var(--bubble-sway, 3vw), -52vh, 0) scale(1); }
94% { opacity: var(--bubble-opacity, 0.9); }
100% { transform: translate3d(calc(var(--bubble-sway, 3vw) * -0.5), -104vh, 0) scale(1.06); opacity: 0; }
18% { opacity: var(--bubble-opacity, 0.9); }
50% { transform: translate3d(var(--bubble-sway, 3vw), calc(var(--ice-rise) * 0.5), 0) scale(1); }
90% { opacity: var(--bubble-opacity, 0.9); }
100% { transform: translate3d(calc(var(--bubble-sway, 3vw) * -0.5), calc(var(--ice-rise) + 3%), 0) scale(1.06); opacity: 0; }
}
.aero .ice-bubble-1 { width: 104px; height: 104px; left: 7%; --bubble-duration: 19s; --bubble-delay: -2s; --bubble-sway: 4vw; }
@ -384,15 +381,14 @@
/* Helper lines that sit straight on the wallpaper (the keyboard tip, the AI
note) need their own surface in Aero: dark text on the grass measured
2.5:1. A small glass chip fixes it without hiding the wallpaper. */
2.5:1. A small glass chip fixes it without hiding the wallpaper — the
shared frosted recipe below, not a live blur. */
.aero .hint-line {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 0.75rem;
background: var(--glass-bg);
background-color: var(--glass-bg);
border: 1px solid var(--glass-border);
/* Same trap as the buttons above: use the Tailwind utilities. */
@apply backdrop-blur-md backdrop-saturate-150;
}
/* Bright white rims on the study/homework segments (the frosted fill itself
@ -454,6 +450,7 @@
.aero .chat-empty,
.aero .chat-composer-band,
.aero .segmented,
.aero .hint-line,
/* Hand-rolled frost fills in the study, exam, chat and homework flows:
white/xx utilities and the surface tokens — without this they render as
flat white chips next to the glass. */
@ -484,6 +481,15 @@
background-repeat: no-repeat, no-repeat, no-repeat;
}
/* The chat header and composer are floating glass (a live backdrop blur),
but the bake above is opaque: the blur under it never shows, yet it still
re-ran on every frame. The touch fallback below drops the bake, so it
brings the blur back there. */
.aero .chat-header,
.aero .chat-composer-band {
@apply backdrop-filter-none;
}
/* The wizard's phone action bar: Aero's page token is the saturated water
blue, which read as a heavy slab over the photo — give it the same thin
glass as everything else (it already has a real backdrop blur). */
@ -680,10 +686,15 @@
.aero .chat-header,
.aero .chat-empty,
.aero .chat-composer-band,
.aero .segmented {
.aero .segmented,
.aero .hint-line {
background-image: none;
background-color: rgb(255 255 255 / 0.32);
}
.aero .chat-header,
.aero .chat-composer-band {
@apply backdrop-blur-md;
}
/* Reading surfaces keep a touch more body than the chrome — long answers
have to stay comfortable over grass and water — but they are still glass,
not white panes. */
@ -899,8 +910,9 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
/* The key gate rides on the shared ice scene (see .ice-scene). */
/* The fixed ice scene: navy/pale base, drifting glacier blobs, glossy ice
orbs and a whisper of grain. Purely decorative. */
/* The fixed ice scene: navy/pale base, soft glacier blobs and glossy ice
orbs. Purely decorative. Only the orbs (and Aero's bubbles) move; the base
and the blobs are painted once. */
.ice-scene {
position: fixed;
inset: 0;
@ -914,61 +926,45 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
position: absolute;
border-radius: 50%;
opacity: var(--ice-blob-opacity);
will-change: transform;
/* No filter: blur() here on purpose. A radial gradient fading to
transparent is already a soft blob, and blurring a huge layer that is
also being animated forces a re-rasterisation every frame (measured
150ms vs 33ms per frame in a software renderer). The falloff stops below
keep the same softness. */
transparent is already a soft blob, and blurring a huge layer forces a
re-rasterisation (measured 150ms vs 33ms per frame in a software
renderer). The falloff stops below keep the same softness.
No drift either: a few vw over 44-60s never showed at this opacity, but
four viewport-sized moving layers turned every frame into a full-screen
redraw. */
}
.ice-blob-a {
width: 52vw; height: 52vw; min-width: 24rem; min-height: 24rem;
top: -14vh; left: -10vw;
background: radial-gradient(circle, rgb(56 189 248 / 0.9) 0%, rgb(56 189 248 / 0.45) 38%, transparent 72%);
animation: ice-drift-a 44s ease-in-out infinite;
}
.ice-blob-b {
width: 46vw; height: 46vw; min-width: 22rem; min-height: 22rem;
top: 4vh; right: -12vw;
background: radial-gradient(circle, rgb(96 165 250 / 0.9) 0%, rgb(96 165 250 / 0.45) 38%, transparent 72%);
animation: ice-drift-b 52s ease-in-out infinite;
}
.ice-blob-c {
width: 48vw; height: 48vw; min-width: 22rem; min-height: 22rem;
bottom: -16vh; left: 14vw;
background: radial-gradient(circle, rgb(129 140 248 / 0.9) 0%, rgb(129 140 248 / 0.42) 38%, transparent 74%);
animation: ice-drift-c 60s ease-in-out infinite;
}
.ice-blob-d {
width: 34vw; height: 34vw; min-width: 16rem; min-height: 16rem;
bottom: 2vh; right: 6vw;
background: radial-gradient(circle, rgb(56 189 248 / 0.85) 0%, rgb(56 189 248 / 0.4) 38%, transparent 74%);
animation: ice-drift-a 48s ease-in-out infinite reverse;
}
@keyframes ice-drift-a {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(4vw, 3vh, 0) scale(1.08); }
}
@keyframes ice-drift-b {
0%, 100% { transform: translate3d(0, 0, 0) scale(1.05); }
50% { transform: translate3d(-5vw, 4vh, 0) scale(1); }
}
@keyframes ice-drift-c {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(3vw, -4vh, 0) scale(1.1); }
}
/* Glossy ice orbs: slow bubbles that rise from just below the bottom edge to
above the top edge, swaying gently and fading in/out near the edges so the
loop never pops. Only transform + opacity animate, each orb on its own
/* Glossy ice orbs: slow bubbles that rise through the open band (see
.ice-floats), swaying gently and fading in/out at its edges so the loop
never pops. Only transform + opacity animate, each orb on its own
composited layer (will-change: transform): no filter/backdrop-filter, and no
animated size, offsets or shadow. Per-orb duration, delay (negative, so the
scene is already populated on load), horizontal position and sway keep them
out of sync. */
.ice-orb {
position: absolute;
top: 100%;
bottom: 0;
border-radius: 50%;
opacity: var(--ice-orb-opacity);
will-change: transform;
@ -989,120 +985,58 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
.ice-orb-4 { width: 52px; height: 52px; right: 28%; --ice-orb-duration: 64s; --ice-orb-delay: -52s; --ice-sway: -4vw; }
.ice-orb-5 { width: 120px; height: 120px; left: 38%; --ice-orb-duration: 52s; --ice-orb-delay: -27s; --ice-sway: 3vw; }
/* The rise: -100vh - 100% clears the top edge (100% = the orb's own height);
the sway crosses the path twice and the scale drifts slightly. Opacity
reaches the token value by 12% and holds it until 88%, so the fades happen
while the orb is still partly outside the scene. */
/* The rise: --ice-rise lifts an orb from the bottom of the band until its
top edge meets the band's top (100% = the orb's own height; the extra 3%
at the end takes back what the 1.06 scale adds above it); the sway crosses
the path twice and the scale drifts slightly. Opacity reaches the token
value by 18% and holds it until 90%, so a visible orb never touches the
band's edges. */
@keyframes ice-rise {
0% { transform: translate3d(0, 0, 0) scale(0.94); opacity: 0; }
12% { opacity: var(--ice-orb-opacity); }
32% { transform: translate3d(var(--ice-sway, 0px), calc(-32vh - 32%), 0) scale(0.97); }
58% { transform: translate3d(calc(var(--ice-sway, 0px) * -0.55), calc(-58vh - 58%), 0) scale(1.02); }
80% { transform: translate3d(calc(var(--ice-sway, 0px) * 0.4), calc(-80vh - 80%), 0) scale(1.04); }
88% { opacity: var(--ice-orb-opacity); }
100% { transform: translate3d(0, calc(-100vh - 100%), 0) scale(1.06); opacity: 0; }
18% { opacity: var(--ice-orb-opacity); }
32% { transform: translate3d(var(--ice-sway, 0px), calc(var(--ice-rise) * 0.32), 0) scale(0.97); }
58% { transform: translate3d(calc(var(--ice-sway, 0px) * -0.55), calc(var(--ice-rise) * 0.58), 0) scale(1.02); }
80% { transform: translate3d(calc(var(--ice-sway, 0px) * 0.4), calc(var(--ice-rise) * 0.8), 0) scale(1.04); }
90% { opacity: var(--ice-orb-opacity); }
100% { transform: translate3d(0, calc(var(--ice-rise) + 3%), 0) scale(1.06); opacity: 0; }
}
/* The rising orbs and bubbles are clipped to the scene, but the floating
glass panels (the rail, the chat header/composer, the tab bar) sit on top of
it — a bubble reaching one of those edges looked sliced by a straight line.
This layer keeps them inside the open middle band: they fade in below the
top chrome, out above the bottom chrome (the values come from where the
header and the composer actually end) and, on desktop, stay out of the rail
strip. Static under reduced motion: nothing moves there, and the parked
orbs keep their spots. */
/* The open band the orbs and bubbles rise through. The floating glass
panels (the rail, the chat header/composer, the tab bar) sit on top of the
scene, and a bubble reaching one of their edges looked sliced by a straight
line — so the floats stay clear of them: below the top chrome, above the
bottom chrome (the values come from where the header and the composer
actually end) and, on desktop, right of the rail strip. Every float starts
and ends inside the band at zero opacity, so this clip never cuts a visible
one. It is a plain rectangular clip on purpose: the gradient mask it
replaces re-rendered the whole animated layer offscreen on every frame
(Gecko on a 144Hz screen: ~114% CPU idle on the library, ~39% without the
mask). Static under reduced motion: nothing moves there, and the parked
orbs keep their full-screen spots. */
.ice-floats {
position: absolute;
inset: 0;
inset: 68px 0 172px;
overflow: hidden;
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0,
transparent 68px,
black 150px,
black calc(100% - 330px),
transparent calc(100% - 172px)
);
mask-image: linear-gradient(
to bottom,
transparent 0,
transparent 68px,
black 150px,
black calc(100% - 330px),
transparent calc(100% - 172px)
);
--ice-rise: calc(100% - 100vh + 240px);
}
@media (min-width: 640px) {
.ice-floats {
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0,
transparent 68px,
black 150px,
black calc(100% - 300px),
transparent calc(100% - 151px)
);
mask-image: linear-gradient(
to bottom,
transparent 0,
transparent 68px,
black 150px,
black calc(100% - 300px),
transparent calc(100% - 151px)
);
bottom: 151px;
--ice-rise: calc(100% - 100vh + 219px);
}
}
@media (min-width: 1024px) {
/* …and the rail's strip (plus its gutter) stays clear too. */
.ice-floats {
-webkit-mask-image:
linear-gradient(
to bottom,
transparent 0,
transparent 68px,
black 150px,
black calc(100% - 300px),
transparent calc(100% - 151px)
),
linear-gradient(to right, transparent 0, transparent 260px, black 360px);
mask-image:
linear-gradient(
to bottom,
transparent 0,
transparent 68px,
black 150px,
black calc(100% - 300px),
transparent calc(100% - 151px)
),
linear-gradient(to right, transparent 0, transparent 260px, black 360px);
-webkit-mask-composite: destination-in;
mask-composite: intersect;
}
}
@media (prefers-reduced-motion: reduce) {
.ice-floats { -webkit-mask-image: none !important; mask-image: none !important; }
}
html.reduce-motion .ice-floats {
-webkit-mask-image: none !important;
mask-image: none !important;
}
/* Frosted grain over the whole scene. */
.ice-grain {
position: absolute;
inset: 0;
opacity: var(--ice-grain-opacity);
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
/* …and the rail's strip plus its gutter (268px, see Sidebar). */
.ice-floats { left: 268px; }
}
@media (prefers-reduced-motion: reduce) {
.ice-blob { animation: none; }
.ice-floats { inset: 0; }
/* Bubbles sit still (and visible) instead of rising. */
.aero .ice-bubble { animation: none; opacity: 0.55; transform: translate3d(0, -60vh, 0); }
.aero .ice-bubble { animation: none; opacity: 0.55; transform: translate3d(0, -46vh, 0); }
/* Orbs go back to the pre-rise static layout (a paused animation is not
applied at all in Chrome, which would leave them parked below the fold)
and give their composited layers back. */
applied at all in Chrome, which would leave them parked at the bottom of
the band) and give their composited layers back. */
.ice-orb {
animation: none;
will-change: auto;
@ -1114,15 +1048,19 @@ html.reduce-motion .ice-floats {
.ice-orb-5 { top: auto; bottom: 16%; left: 38%; }
}
/* Background tab: park every scene animation, so an invisible page costs no
compositor work. */
.ice-paused .ice-orb,
.ice-paused .ice-blob {
/* Park every scene animation while nobody is watching it, so the page costs
no compositor work: a background tab or a window without focus (IceBackdrop
sets .ice-paused), and any dialog or sheet over the page — its scrim
carries .scene-pause, and the blur of the scrim and the sheet would
otherwise re-run on every frame of the scene moving behind them. */
.ice-paused :is(.ice-orb, .ice-bubble),
body:has(.scene-pause) .ice-scene :is(.ice-orb, .ice-bubble) {
animation-play-state: paused;
}
/* Phones: fewer, softer blobs so the drift animation stays cheap. Unlayered
on purpose — layered rules would lose to the .ice-blob declarations above. */
/* Phones and tablets: fewer blobs and orbs, so the scene stays calm and
cheap. Unlayered on purpose — layered rules would lose to the .ice-blob
declarations above. */
@media (max-width: 1023px) {
.ice-blob-c,
.ice-blob-d,
@ -1183,13 +1121,16 @@ html.reduce-motion .ice-floats {
the tint below plus a backdrop blur — the blur is what reads as glass
when content scrolls underneath (the app header, the study chrome, the
tab bar, a sheet over the page).
- **Static glass** (cards, the rail, the gate, the study prompt card): the
exact same tint, rim, highlight and shadow, but **no** backdrop-filter.
On a smooth gradient the blur is invisible — and because the ice scene
animates, every blurred surface re-runs the filter on every frame.
Measured on desktop (2x throttle, software renderer): p50 frame time
66ms while the static surfaces blurred the scene, 33ms without them,
17ms with the scene's animation also stopped.
- **Static glass** (cards, the rail, the gate, the study prompt card, and
everything that sits in the page flow: answer tiles, chips, inputs,
dropzones, grouped lists, segmented controls): the exact same tint,
rim, highlight and shadow, but **no** backdrop-filter. On a smooth
gradient the blur is invisible — and because the ice scene animates,
every blurred surface re-runs the filter on every frame. Measured on
desktop (2x throttle, software renderer): p50 frame time 66ms while the
static surfaces blurred the scene, 33ms without them, 17ms with the
scene's animation also stopped; on a 144Hz MacBook screen the study
feed's answer tiles alone cost Chromium ~20% CPU.
Nested glass (chips, inputs) never adds a second blur layer. Tailwind's
backdrop utilities emit the -webkit- prefix too (hand-written
@ -1300,7 +1241,6 @@ html.reduce-motion .ice-floats {
/* Inset grouped list ---------------------------------------------------- */
.list-group {
border-radius: var(--radius-control);
@apply backdrop-blur-lg backdrop-saturate-150;
background: color-mix(in oklab, var(--surface) 82%, transparent);
border: 1px solid var(--hairline);
overflow: hidden;
@ -1349,7 +1289,6 @@ html.reduce-motion .ice-floats {
gap: 2px;
padding: 3px;
border-radius: var(--radius-control);
@apply backdrop-blur-sm;
background: var(--surface-sunken);
border: 1px solid var(--hairline);
}
@ -1741,6 +1680,26 @@ html.reduce-motion .ice-floats {
.app-shell-in { animation: none; }
}
/* The loading screen's icon: a slow rock and swell for as long as a model
runs. CSS on the individual transform properties, so the compositor runs
it — the framer-motion loop it replaces rewrote the transform from the
main thread on every frame. */
@keyframes loading-rock {
0%, 100% { rotate: 0deg; }
33.333% { rotate: 8deg; }
66.667% { rotate: -8deg; }
}
@keyframes loading-swell {
0%, 100% { scale: 1; }
50% { scale: 1.08; }
}
.loading-bob {
animation: loading-rock 3.2s ease-in-out infinite, loading-swell 3.2s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.loading-bob { animation: none; }
}
/* Phones: a quick fade when the study feed takes over (the generation
hand-off), so the result doesn't hard-cut in. */
@media (max-width: 639px) {
@ -1802,7 +1761,9 @@ html.reduce-motion .ice-floats {
every CSS animation/transition collapses to nothing. framer-motion gets
told separately (components/MotionPreference).
--------------------------------------------------------------------------- */
html.reduce-motion .ice-blob,
html.reduce-motion .ice-floats {
inset: 0;
}
html.reduce-motion .ice-orb,
html.reduce-motion .ice-bubble {
animation: none !important;
@ -1810,7 +1771,7 @@ html.reduce-motion .ice-bubble {
}
html.reduce-motion.aero .ice-bubble {
opacity: 0.55;
transform: translate3d(0, -60vh, 0);
transform: translate3d(0, -46vh, 0);
}
html.reduce-motion .ice-orb-1 { top: 14%; left: 12%; }
html.reduce-motion .ice-orb-2 { top: 58%; left: 4%; }