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

@ -1,9 +1,13 @@
/**
* Ice-scene bubbles: the glossy orbs must rise from below the bottom edge to
* above the top edge with sway + slight scale, fade at the edges, animate only
* transform/opacity on their own composited layers, stay behind everything,
* park under reduced motion and while the tab is hidden, and keep the
* per-breakpoint count (5 desktop / 3 tablet / 2 phones).
* Ice-scene bubbles: the glossy orbs (and Aero's bubbles) must rise through
* the open band — clear of the header, the bottom chrome and the desktop
* rail — with sway + slight scale, fade in and out inside it so no visible
* float ever touches the band's clip, animate only transform/opacity on their
* own composited layers, stay behind everything, park under reduced motion,
* while the tab is hidden, while the window has no focus and under a dialog
* scrim, and keep the per-breakpoint count (5 desktop / 3 tablet / 2 phones).
* The cost contract rides along: no mask on the animated layer, no blend-mode
* grain, static blobs, and no backdrop blur on glass that sits still.
*/
import { BASE, DECK, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs";
@ -24,16 +28,56 @@ async function orbAt(page, phase) {
animation.currentTime = ((wanted % timing.duration) + timing.duration) % timing.duration;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const rect = orb.getBoundingClientRect();
const band = document.querySelector(".ice-floats").getBoundingClientRect();
return {
top: rect.top,
bottom: rect.bottom,
left: rect.left,
width: rect.width,
opacity: Number(getComputedStyle(orb).opacity),
viewport: window.innerHeight,
band: { top: band.top, bottom: band.bottom, left: band.left },
};
}, phase);
}
/** Parks every displayed float matching `selector` at 41 phases of its pass
and lists the frames where a visible one (opacity > 0.05) pokes out of the
band — the band's top, its bottom and, where it starts right of the
viewport edge, its left side (the rail). Those are the frames where the
clip would slice a bubble. */
async function sweep(page, selector) {
return page.evaluate(async (selector) => {
const band = document.querySelector(".ice-floats").getBoundingClientRect();
const floats = [...document.querySelectorAll(selector)].filter((el) => getComputedStyle(el).display !== "none");
const animations = floats.map((el) => {
const animation = el.getAnimations()[0];
animation.pause();
return animation;
});
const misses = [];
let samples = 0;
for (let step = 0; step <= 40; step += 1) {
const phase = step / 40;
for (const animation of animations) {
const timing = animation.effect.getTiming();
const wanted = phase * timing.duration + (timing.delay ?? 0);
animation.currentTime = ((wanted % timing.duration) + timing.duration) % timing.duration;
}
await new Promise((resolve) => requestAnimationFrame(resolve));
floats.forEach((el) => {
const opacity = Number(getComputedStyle(el).opacity);
if (opacity <= 0.05) return;
samples += 1;
const rect = el.getBoundingClientRect();
const out =
rect.top < band.top - 2 || rect.bottom > band.bottom + 2 || (band.left > 0 && rect.left < band.left - 2);
if (out) misses.push(`${el.className} @${phase}: ${Math.round(rect.top)}-${Math.round(rect.bottom)} x${Math.round(rect.left)} (${opacity.toFixed(2)})`);
});
}
return { floats: floats.length, samples, misses };
}, selector);
}
/* 1. scene contract (desktop) ---------------------------------------- */
{
const { context, page } = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 });
@ -75,25 +119,37 @@ async function orbAt(page, phase) {
check("orbs: own duration per pass (30-70s, 5 distinct)", orbs.every((orb) => orb.duration >= 30 && orb.duration <= 70) && new Set(orbs.map((orb) => orb.duration)).size === 5, orbs.map((orb) => orb.duration).join(","));
check("orbs: reduced opacity token applied", orbs.every((orb) => orb.opacity > 0.3 && orb.opacity < 0.45), String(orbs[0]?.opacity));
/* The floats layer: orbs + bubbles live in one masked wrapper that fades
them out before the chrome bands, so none of them is sliced by a panel
edge (see .ice-floats in globals.css). */
/* The floats layer: orbs + bubbles live in one wrapper that is the open
band between the chrome — a plain rectangular clip, never a mask (a mask
re-renders the whole animated layer offscreen on every frame). */
const floats = await page.evaluate(() => {
const layer = document.querySelector(".ice-floats");
const style = layer ? getComputedStyle(layer) : null;
const rect = layer?.getBoundingClientRect();
return {
children: layer ? layer.children.length : 0,
orbs: document.querySelectorAll(".ice-floats .ice-orb").length,
bubbles: document.querySelectorAll(".ice-floats .ice-bubble").length,
mask: style ? style.maskImage || style.webkitMaskImage : "",
mask: style ? style.maskImage || style.webkitMaskImage || "none" : "",
overflow: style?.overflow,
top: rect?.top,
bottom: rect?.bottom,
left: rect?.left,
right: rect?.right,
viewport: { width: document.documentElement.clientWidth, height: window.innerHeight },
};
});
check(
"floats: orbs and bubbles ride one masked layer",
"floats: orbs and bubbles ride one layer",
floats.children === 23 && floats.orbs === 5 && floats.bubbles === 18,
JSON.stringify(floats),
);
check("floats: the chrome bands fade the layer out", floats.mask.includes("gradient"), floats.mask.slice(0, 60));
check("floats: a plain clip, no mask on the animated layer", floats.mask === "none" && floats.overflow === "hidden", `${floats.mask} / ${floats.overflow}`);
check(
"floats: the band clears the header, the bottom chrome and the rail",
floats.top === 68 && floats.viewport.height - floats.bottom === 151 && floats.left === 268 && floats.right === floats.viewport.width,
JSON.stringify(floats),
);
const keyframes = await page.evaluate(() => {
for (const sheet of document.styleSheets) {
@ -119,12 +175,43 @@ async function orbAt(page, phase) {
const mid = await orbAt(page, 0.5);
const upper = await orbAt(page, 0.8);
const end = await orbAt(page, 0.99);
check("rise: starts at the bottom edge, ends above the top edge", Math.abs(start.top - start.viewport) < 6 && end.top < -120, `start ${Math.round(start.top)}/${start.viewport}, end ${Math.round(end.top)}`);
check(
"rise: starts at the band's bottom, ends at its top",
Math.abs(start.bottom - start.band.bottom) < 8 && Math.abs(end.top - end.band.top) < 12,
`start ${Math.round(start.bottom)}/${start.band.bottom}, end ${Math.round(end.top)}/${end.band.top}`,
);
check("rise: moves upward the whole way", lower.top < start.top && mid.top < lower.top && upper.top < mid.top && end.top < upper.top);
check("sway: swings across the path", Math.abs(mid.left - lower.left) > 30 && Math.abs(upper.left - mid.left) > 15, `${Math.round(lower.left)} → ${Math.round(mid.left)} → ${Math.round(upper.left)}`);
check("scale: drifts slightly across the pass", end.width > start.width + 8, `${start.width.toFixed(1)} → ${end.width.toFixed(1)}`);
check("fade: in at the bottom, out at the top, full between", start.opacity < 0.1 && mid.opacity > 0.3 && end.opacity < 0.1, `${start.opacity} / ${mid.opacity} / ${end.opacity}`);
const swept = await sweep(page, ".ice-orb");
check(
"band: a visible orb never touches its edges (nothing to slice)",
swept.floats === 5 && swept.samples > 100 && swept.misses.length === 0,
swept.misses.slice(0, 3).join(" | ") || `${swept.samples} visible frames`,
);
/* The cost contract of the static parts: the blobs are painted once (their
drift was invisible but made every frame a full-screen redraw) and there
is no blend-mode grain over the animated layer. */
const still = await page.evaluate(() => {
const blobs = [...document.querySelectorAll(".ice-blob")].map((blob) => {
const style = getComputedStyle(blob);
return { animation: style.animationName, willChange: style.willChange };
});
const blended = [...document.querySelectorAll(".ice-scene, .ice-scene *")].filter(
(el) => getComputedStyle(el).mixBlendMode !== "normal",
).length;
return { blobs, blended, grain: document.querySelector(".ice-grain") !== null };
});
check(
"blobs: static, painted once (no drift, no own layer)",
still.blobs.length === 4 && still.blobs.every((blob) => blob.animation === "none" && blob.willChange === "auto"),
JSON.stringify(still.blobs),
);
check("scene: no blend-mode grain over the animated layer", !still.grain && still.blended === 0, JSON.stringify(still));
const layoutBefore = await page.evaluate(() => ({ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight }));
await page.waitForTimeout(1400);
const layoutAfter = await page.evaluate(() => ({ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight }));
@ -138,6 +225,30 @@ async function orbAt(page, phase) {
await context.close();
}
/* 1b. Aero's bubbles ride the same band ------------------------------- */
for (const [width, height] of [[1280, 900], [390, 844]]) {
const { context, page } = await makePage(browser, { width, height, deviceScaleFactor: 1 });
await seed(page, { decks: [DECK], theme: "aero" });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1000);
const band = await page.evaluate(() => {
const rect = document.querySelector(".ice-floats").getBoundingClientRect();
return { top: rect.top, bottom: window.innerHeight - rect.bottom, left: rect.left };
});
check(
`aero ${width}px: the band clears the chrome`,
band.top === 68 && band.bottom === (width >= 640 ? 151 : 172) && band.left === (width >= 1024 ? 268 : 0),
JSON.stringify(band),
);
const swept = await sweep(page, ".ice-bubble, .ice-orb");
check(
`aero ${width}px: a visible bubble never touches the band's edges`,
swept.floats >= 20 && swept.samples > 300 && swept.misses.length === 0,
swept.misses.slice(0, 3).join(" | ") || `${swept.floats} floats, ${swept.samples} visible frames`,
);
await context.close();
}
/* 2. per-breakpoint counts + phone pacing ----------------------------- */
{
const counts = {};
@ -173,17 +284,21 @@ async function orbAt(page, phase) {
const orb = document.querySelector(".ice-orb-1");
const style = getComputedStyle(orb);
const rect = orb.getBoundingClientRect();
const floats = getComputedStyle(document.querySelector(".ice-floats"));
const floats = document.querySelector(".ice-floats").getBoundingClientRect();
return {
animation: style.animationName,
willChange: style.willChange,
opacity: Number(style.opacity),
top: rect.top,
left: rect.left,
mask: floats.maskImage || floats.webkitMaskImage,
floats: { top: floats.top, bottom: floats.bottom, viewport: window.innerHeight },
};
});
check("reduced motion: the floats mask is off (parked orbs keep their spots)", state.mask === "none", state.mask);
check(
"reduced motion: the floats cover the whole scene again (parked orbs keep their spots)",
state.floats.top === 0 && state.floats.bottom === state.floats.viewport,
JSON.stringify(state.floats),
);
check("reduced motion: rise removed, layer released", state.animation === "none" && state.willChange === "auto", JSON.stringify(state));
check("reduced motion: orb visible and faded in at a static spot", state.opacity > 0.08 && state.top > 0 && state.top < 900, JSON.stringify(state));
await page.waitForTimeout(1100);
@ -195,7 +310,7 @@ async function orbAt(page, phase) {
await context.close();
}
/* 4. tab hidden: everything parks ------------------------------------- */
/* 4. tab hidden, window blurred, dialog open: everything parks -------- */
{
const { context, page } = await makePage(browser);
await seed(page, { decks: [DECK] });
@ -209,12 +324,10 @@ async function orbAt(page, phase) {
const paused = await page.evaluate(() => ({
scene: document.querySelector(".ice-scene").classList.contains("ice-paused"),
orb: getComputedStyle(document.querySelector(".ice-orb-1")).animationPlayState,
blob: getComputedStyle(document.querySelector(".ice-blob-a")).animationPlayState,
transform: getComputedStyle(document.querySelector(".ice-orb-1")).transform,
top: document.querySelector(".ice-orb-1").getBoundingClientRect().top,
}));
check("tab hidden: scene class + orb parked", paused.scene && paused.orb === "paused", JSON.stringify(paused));
check("tab hidden: blob drift parked too", paused.blob === "paused", paused.blob);
check("tab hidden: parked orb keeps its frame (no jump)", paused.transform !== "none", paused.transform);
await page.waitForTimeout(500);
const held = await page.evaluate(() => document.querySelector(".ice-orb-1").getBoundingClientRect().top);
@ -229,8 +342,91 @@ async function orbAt(page, phase) {
orb: getComputedStyle(document.querySelector(".ice-orb-1")).animationPlayState,
}));
check("tab visible again: rise resumes", !running.scene && running.orb === "running", JSON.stringify(running));
/* A visible window without focus (another app in front, the browser on a
second screen) parks too, and focus brings it back. */
const focus = await page.evaluate(async () => {
const frame = () => new Promise((resolve) => setTimeout(resolve, 150));
const read = () => ({
scene: document.querySelector(".ice-scene").classList.contains("ice-paused"),
orb: getComputedStyle(document.querySelector(".ice-orb-1")).animationPlayState,
});
document.hasFocus = () => false;
window.dispatchEvent(new Event("blur"));
await frame();
const blurred = read();
document.hasFocus = () => true;
window.dispatchEvent(new Event("focus"));
await frame();
return { blurred, focused: read() };
});
check("window blurred: the scene parks", focus.blurred.scene && focus.blurred.orb === "paused", JSON.stringify(focus.blurred));
check("window focused again: the rise resumes", !focus.focused.scene && focus.focused.orb === "running", JSON.stringify(focus.focused));
/* A dialog or sheet: its scrim (and the sheet's glass) blur whatever is
behind them, so the scene holds still while one is open. */
const scrim = await page.evaluate(async () => {
const frame = () => new Promise((resolve) => setTimeout(resolve, 100));
const overlay = document.createElement("div");
overlay.className = "scene-pause";
document.body.append(overlay);
await frame();
const open = getComputedStyle(document.querySelector(".ice-orb-1")).animationPlayState;
overlay.remove();
await frame();
return { open, closed: getComputedStyle(document.querySelector(".ice-orb-1")).animationPlayState };
});
check("dialog scrim: the scene parks while it is open, resumes after", scrim.open === "paused" && scrim.closed === "running", JSON.stringify(scrim));
await context.close();
}
/* 5. glass that sits still never blurs -------------------------------- */
{
/* The study feed: only its floating chrome (header + pager pills, which
the slides scroll under) keeps a backdrop blur. */
const { context, page } = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 });
await seed(page, { decks: [DECK] });
await page.goto(`${BASE}/deck/${DECK.id}`, { waitUntil: "networkidle" });
await page.waitForTimeout(1200);
const blurred = await page.evaluate(() =>
[...document.querySelectorAll("body *")]
.filter((el) => {
const style = getComputedStyle(el);
const filter = style.backdropFilter || style.webkitBackdropFilter;
return filter && filter !== "none" && style.display !== "none" && el.getClientRects().length > 0;
})
.map((el) => String(el.className).split(" ")[0]),
);
check(
"study feed: only the floating chrome blurs (no blur on tiles, chips or hints)",
blurred.length > 0 && blurred.every((name) => name === "study-chrome"),
blurred.join(", "),
);
await context.close();
/* Aero desktop: under the opaque bake a live blur never shows, so the chat
chrome carries none; touch drops the bake and keeps the blur. */
for (const touch of [false, true]) {
const aero = await makePage(browser, touch ? { width: 390, height: 844, isMobile: true, hasTouch: true } : { width: 1280, height: 900, deviceScaleFactor: 1 });
await seed(aero.page, { decks: [DECK], theme: "aero" });
await aero.page.goto(`${BASE}/chat`, { waitUntil: "networkidle" });
await aero.page.waitForTimeout(1000);
const chrome = await aero.page.evaluate(() =>
[".chat-header", ".chat-composer-band"].map((selector) => {
const style = getComputedStyle(document.querySelector(selector));
return { filter: style.backdropFilter || style.webkitBackdropFilter, bake: style.backgroundImage.includes("background-blur") };
}),
);
check(
touch ? "aero touch: the chat chrome keeps its live blur (no bake)" : "aero desktop: the baked chat chrome drops the hidden live blur",
touch
? chrome.every((panel) => !panel.bake && panel.filter.includes("blur"))
: chrome.every((panel) => panel.bake && panel.filter === "none"),
JSON.stringify(chrome),
);
await aero.context.close();
}
}
await browser.close();
finish();