freezmo/tests/bubble-perf.mjs
Martin Dvorak 6b6a6ea158 Ice scene: the glossy orbs become rising bubbles, ~38% more transparent
- Orbs rise from below the bottom edge to above the top edge, each with
  its own 36-68s pass (slower on phones), a negative delay (scene already
  populated on load), own horizontal position, a gentle sway and a slight
  scale drift; opacity fades in/out at the edges so the loop never pops.
- Only transform + opacity animate, each orb on its own composited layer
  (will-change: transform); no filter/backdrop-filter, nothing animates
  size/offsets/shadow. The glacier blobs are untouched.
- Opacity lowered ~38% in both themes: 0.55->0.34 light, 0.8->0.5 dark,
  phone overrides 0.32->0.2 / 0.42->0.26.
- prefers-reduced-motion: rise removed, orbs return to the old static
  placements (a paused-from-start CSS animation is not applied in Chrome
  and would park them below the fold); tab hidden: a visibility listener
  parks the whole scene (.ice-paused), blob drift included.
- Measured with a CDP perf trace on a 4x throttled phone profile: p95
  33.4ms, 0 frames over 34ms, no long tasks - identical to the pre-change
  baseline; ~0.6% extra main-thread CPU. Desktop stays compositor-bound
  from the pre-existing blob drift, as before. tests/bubble-perf.mjs
  keeps it repeatable.
- New bubbles suite (34 checks) in the repo harness; the axe contrast
  matrix is now 16 runs (360/390/1280 x dark/light x EN/CS) and passes.
- SW cache -> freezmo-v31; README decision 46.

Full suite: 11/11 suites, 424 checks.
2026-10-11 15:50:17 +00:00

84 lines
4.2 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 }) {
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);
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 });
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`);
await browser.close();