The MacBook ran hot browsing the app: the ice scene animates constantly, so every backdrop-filter surface re-ran its blur on every frame. Measured on desktop (2x CPU throttle, software renderer): p50 frame 66.6ms baseline, 33.3ms with the static surfaces not filtering, 16.7ms with the scene's animation also off; frames over 34ms: 128 -> 14. The glass recipe is split: floating layers (sheets, dropdowns, dialogs, the sticky header/chrome/tab bar) keep the blur (backdrop-blur-lg, what phones already used); static layers (cards, the sidebar rail, the gate, the study prompt card) keep the identical tint, rim, highlight and shadow without the filter. The study prompt card moves from glass-panel to card + glass-solid, which is the same surface recipe. Quality: a deterministic pixel diff (scene motion frozen, both themes, desktop + phone) shows a ~1% colour shift in the card backdrops — invisible. The mobile axe contrast audit and the bubbles suite stay green. tests/bubble-perf.mjs now also reports the glass cost. README: decision 61. SW -> freezmo-v49.
101 lines
5 KiB
JavaScript
101 lines
5 KiB
JavaScript
/**
|
|
* Diagnostic, NOT part of `npm run test:e2e`: measures the ice-scene frame
|
|
* cost on a throttled phone profile with a CDP performance trace (frame
|
|
* timing via rAF, long tasks, main-thread busy time, style recalcs, layouts).
|
|
*
|
|
* node tests/bubble-perf.mjs # numbers only
|
|
* node tests/bubble-perf.mjs before # label the run (e.g. before/after)
|
|
*
|
|
* It always exits 0 — the numbers are for tuning the bubble count/speed, not
|
|
* a pass/fail gate (CI machines vary wildly).
|
|
*/
|
|
import { BASE, DECK, launchBrowser, makePage, seed } from "./lib.mjs";
|
|
|
|
const label = process.argv[2] ?? "run";
|
|
const browser = await launchBrowser();
|
|
|
|
async function measure(name, { width, height, throttle, reducedMotion, extraCss }) {
|
|
const { context, page } = await makePage(browser, { width, height, deviceScaleFactor: 2, reducedMotion });
|
|
const cdp = await context.newCDPSession(page);
|
|
await cdp.send("Emulation.setCPUThrottlingRate", { rate: throttle });
|
|
await cdp.send("Performance.enable");
|
|
await seed(page, { decks: [DECK] });
|
|
await page.goto(BASE, { waitUntil: "networkidle" });
|
|
await page.waitForTimeout(1500);
|
|
if (extraCss) {
|
|
await page.addStyleTag({ content: extraCss });
|
|
await page.waitForTimeout(400);
|
|
}
|
|
|
|
const scene = await page.evaluate(() => {
|
|
const orb = document.querySelector(".ice-orb-1");
|
|
const blob = document.querySelector(".ice-blob-a");
|
|
const read = (element) => {
|
|
const style = getComputedStyle(element);
|
|
return { animation: style.animationName, play: style.animationPlayState, willChange: style.willChange };
|
|
};
|
|
return { orb: read(orb), blob: read(blob), orbs: document.querySelectorAll(".ice-orb:not([style*='display: none'])").length };
|
|
});
|
|
|
|
const before = await cdp.send("Performance.getMetrics");
|
|
const frames = await page.evaluate(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
const times = [];
|
|
const longTasks = [];
|
|
try {
|
|
new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) longTasks.push(entry.duration);
|
|
}).observe({ entryTypes: ["longtask"] });
|
|
} catch {
|
|
/* not supported */
|
|
}
|
|
let last = performance.now();
|
|
const start = last;
|
|
const tick = (now) => {
|
|
times.push(now - last);
|
|
last = now;
|
|
if (now - start < 8000) requestAnimationFrame(tick);
|
|
else resolve({ times, longTasks });
|
|
};
|
|
requestAnimationFrame(tick);
|
|
}),
|
|
);
|
|
const after = await cdp.send("Performance.getMetrics");
|
|
const metric = (snapshot, key) => snapshot.metrics.find((entry) => entry.name === key)?.value ?? 0;
|
|
const busyMs = (metric(after, "TaskDuration") - metric(before, "TaskDuration")) * 1000;
|
|
const recalcs = metric(after, "RecalcStyleCount") - metric(before, "RecalcStyleCount");
|
|
const layouts = metric(after, "LayoutCount") - metric(before, "LayoutCount");
|
|
|
|
const sorted = frames.times.slice(1).sort((a, b) => a - b);
|
|
const pct = (fraction) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * fraction))] ?? 0;
|
|
const over34 = sorted.filter((time) => time > 34).length;
|
|
console.log(`\n[${label}] ${name} (${throttle}x CPU)`);
|
|
console.log(` scene: orb anim=${scene.orb.animation} play=${scene.orb.play} will-change=${scene.orb.willChange} | blob anim=${scene.blob.animation} play=${scene.blob.play}`);
|
|
console.log(` frames: ${sorted.length} over 8s | p50 ${pct(0.5).toFixed(1)}ms | p95 ${pct(0.95).toFixed(1)}ms | max ${(sorted.at(-1) ?? 0).toFixed(1)}ms | over 34ms: ${over34}`);
|
|
console.log(` long tasks: ${frames.longTasks.length} (max ${Math.max(0, ...frames.longTasks).toFixed(0)}ms)`);
|
|
console.log(` main-thread busy: ${busyMs.toFixed(0)}ms | style recalc: ${recalcs} | layout: ${layouts}`);
|
|
await context.close();
|
|
return { p95: pct(0.95), over34, busyMs, recalcs, layouts };
|
|
}
|
|
|
|
const phone = { width: 390, height: 844 };
|
|
const animated = await measure("phone 390 animated", { ...phone, throttle: 4 });
|
|
const still = await measure("phone 390 reduced-motion", { ...phone, throttle: 4, reducedMotion: "reduce" });
|
|
const desktop = await measure("desktop 1280 animated", { width: 1280, height: 900, throttle: 2 });
|
|
/* The glass cost: how much of the frame time is backdrop-filter re-blurring
|
|
the (always animating) scene. Static surfaces no longer blur at all — see
|
|
the "Frosted ice glass" block in app/globals.css — so this number should
|
|
stay close to the animated run; a jump means a new blurred surface landed
|
|
on top of the scene. */
|
|
const noGlass = await measure("desktop 1280 no backdrop-filter", {
|
|
width: 1280,
|
|
height: 900,
|
|
throttle: 2,
|
|
extraCss: "*{backdrop-filter:none !important;-webkit-backdrop-filter:none !important;}",
|
|
});
|
|
|
|
console.log(
|
|
`\n[${label}] deltas — animated vs reduced-motion: p95 ${(animated.p95 - still.p95).toFixed(1)}ms, busy ${(animated.busyMs - still.busyMs).toFixed(0)}ms; desktop p95 ${desktop.p95.toFixed(1)}ms; glass cost on desktop: p95 ${(desktop.p95 - noGlass.p95).toFixed(1)}ms, over-34ms frames ${desktop.over34 - noGlass.over34}`,
|
|
);
|
|
await browser.close();
|