- 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.
207 lines
12 KiB
JavaScript
207 lines
12 KiB
JavaScript
/**
|
|
* 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).
|
|
*/
|
|
import { BASE, DECK, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs";
|
|
|
|
const { check, finish } = makeChecker();
|
|
const browser = await launchBrowser();
|
|
|
|
/** Reads the phase-1 orb's rendered state after parking its animation there.
|
|
CSS animations render at (currentTime - delay), and the delay is negative,
|
|
so the requested phase has to be offset by it. */
|
|
async function orbAt(page, phase) {
|
|
return page.evaluate(async (phase) => {
|
|
const orb = document.querySelector(".ice-orb-1");
|
|
const animation = orb.getAnimations().find((entry) => entry.animationName === "ice-rise");
|
|
const timing = animation.effect.getTiming();
|
|
animation.pause();
|
|
// Chrome ignores a negative currentTime, so wrap into the loop.
|
|
const wanted = phase * timing.duration + (timing.delay ?? 0);
|
|
animation.currentTime = ((wanted % timing.duration) + timing.duration) % timing.duration;
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
const rect = orb.getBoundingClientRect();
|
|
return {
|
|
top: rect.top,
|
|
left: rect.left,
|
|
width: rect.width,
|
|
opacity: Number(getComputedStyle(orb).opacity),
|
|
viewport: window.innerHeight,
|
|
};
|
|
}, phase);
|
|
}
|
|
|
|
/* 1. scene contract (desktop) ---------------------------------------- */
|
|
{
|
|
const { context, page } = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 });
|
|
await seed(page, { decks: [DECK] });
|
|
await page.goto(BASE, { waitUntil: "networkidle" });
|
|
await page.waitForTimeout(1200);
|
|
|
|
const scene = await page.evaluate(() => {
|
|
const element = document.querySelector(".ice-scene");
|
|
const style = getComputedStyle(element);
|
|
return {
|
|
position: style.position,
|
|
zIndex: style.zIndex,
|
|
pointerEvents: style.pointerEvents,
|
|
overflow: style.overflow,
|
|
ariaHidden: element.getAttribute("aria-hidden"),
|
|
};
|
|
});
|
|
check("scene: fixed, z-index -1, no pointer events, clipped", scene.position === "fixed" && scene.zIndex === "-1" && scene.pointerEvents === "none" && scene.overflow === "hidden");
|
|
check("scene: hidden from assistive tech", scene.ariaHidden === "true");
|
|
|
|
const orbs = await page.evaluate(() =>
|
|
[...document.querySelectorAll(".ice-orb")].map((orb) => {
|
|
const style = getComputedStyle(orb);
|
|
return {
|
|
animation: style.animationName,
|
|
willChange: style.willChange,
|
|
filter: style.filter,
|
|
backdropFilter: style.backdropFilter || style.webkitBackdropFilter,
|
|
duration: parseFloat(style.animationDuration),
|
|
delay: style.animationDelay,
|
|
opacity: Number(style.opacity),
|
|
};
|
|
}),
|
|
);
|
|
check("orbs: five on desktop, all running the rise", orbs.length === 5 && orbs.every((orb) => orb.animation === "ice-rise"));
|
|
check("orbs: will-change transform, no filter/backdrop-filter", orbs.every((orb) => orb.willChange === "transform" && orb.filter === "none" && (orb.backdropFilter === "none" || !orb.backdropFilter)));
|
|
check("orbs: negative delays spread them on load", orbs.every((orb) => orb.delay.startsWith("-")));
|
|
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.4 && orb.opacity < 0.6), String(orbs[0]?.opacity));
|
|
|
|
const keyframes = await page.evaluate(() => {
|
|
for (const sheet of document.styleSheets) {
|
|
try {
|
|
for (const rule of sheet.cssRules) {
|
|
if (rule instanceof CSSKeyframesRule && rule.name === "ice-rise") {
|
|
return [...rule.cssRules].map((frame) => ({ offset: frame.keyText, props: [...frame.style] }));
|
|
}
|
|
}
|
|
} catch {
|
|
/* cross-origin sheet */
|
|
}
|
|
}
|
|
return null;
|
|
});
|
|
check("keyframes: only transform + opacity animate", Boolean(keyframes) && keyframes.every((frame) => frame.props.every((prop) => prop === "transform" || prop === "opacity")), JSON.stringify(keyframes?.map((frame) => frame.props)));
|
|
const fadeIn = keyframes?.find((frame) => frame.offset === "0%");
|
|
const fadeOut = keyframes?.find((frame) => frame.offset === "100%");
|
|
check("keyframes: fade to 0 at both edges", fadeIn?.props.includes("opacity") && fadeOut?.props.includes("opacity"));
|
|
|
|
const start = await orbAt(page, 0.01);
|
|
const lower = await orbAt(page, 0.2);
|
|
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: 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.45 && end.opacity < 0.1, `${start.opacity} / ${mid.opacity} / ${end.opacity}`);
|
|
|
|
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 }));
|
|
check("layout: no shift or scrollbar change while rising", layoutBefore.width === layoutAfter.width && layoutBefore.height === layoutAfter.height && layoutAfter.width <= 1280 + 1, JSON.stringify({ layoutBefore, layoutAfter }));
|
|
|
|
const hit = await page.evaluate(() => {
|
|
const point = document.elementFromPoint(window.innerWidth / 2, 6);
|
|
return { sceneHit: point?.closest?.(".ice-scene") !== null, tag: point?.tagName ?? "" };
|
|
});
|
|
check("stacking: the scene never sits in front of the chrome", !hit.sceneHit, hit.tag);
|
|
await context.close();
|
|
}
|
|
|
|
/* 2. per-breakpoint counts + phone pacing ----------------------------- */
|
|
{
|
|
const counts = {};
|
|
for (const width of [1280, 768, 390, 360]) {
|
|
const { context, page } = await makePage(browser, { width, height: 800, deviceScaleFactor: 1 });
|
|
await seed(page, { decks: [DECK] });
|
|
await page.goto(BASE, { waitUntil: "networkidle" });
|
|
await page.waitForTimeout(900);
|
|
const info = await page.evaluate(() =>
|
|
[...document.querySelectorAll(".ice-orb")]
|
|
.filter((orb) => getComputedStyle(orb).display !== "none")
|
|
.map((orb) => ({ duration: parseFloat(getComputedStyle(orb).animationDuration), delay: getComputedStyle(orb).animationDelay })),
|
|
);
|
|
counts[width] = info;
|
|
check(`${width}px: no horizontal overflow`, await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1));
|
|
await context.close();
|
|
}
|
|
check("desktop keeps 5 orbs", counts[1280].length === 5, String(counts[1280].length));
|
|
check("tablet keeps 3 orbs", counts[768].length === 3, String(counts[768].length));
|
|
check("phone keeps 2 orbs (fewer than before is not allowed)", counts[390].length === 2 && counts[360].length === 2, `${counts[390].length}/${counts[360].length}`);
|
|
check("phone pacing is slower than desktop", counts[390][0].duration > counts[1280][0].duration && counts[390][1].duration > counts[1280][1].duration, `${counts[1280][0].duration}s → ${counts[390][0].duration}s`);
|
|
check("all phone passes stay in 30-70s", counts[390].every((orb) => orb.duration >= 30 && orb.duration <= 70), counts[390].map((orb) => orb.duration).join(","));
|
|
check("all phone delays negative", counts[390].every((orb) => orb.delay.startsWith("-")));
|
|
}
|
|
|
|
/* 3. reduced motion: static and visible ------------------------------- */
|
|
{
|
|
const { context, page } = await makePage(browser, { reducedMotion: "reduce" });
|
|
await seed(page, { decks: [DECK] });
|
|
await page.goto(BASE, { waitUntil: "networkidle" });
|
|
await page.waitForTimeout(900);
|
|
const state = await page.evaluate(() => {
|
|
const orb = document.querySelector(".ice-orb-1");
|
|
const style = getComputedStyle(orb);
|
|
const rect = orb.getBoundingClientRect();
|
|
return { animation: style.animationName, willChange: style.willChange, opacity: Number(style.opacity), top: rect.top, left: rect.left };
|
|
});
|
|
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.15 && state.top > 0 && state.top < 900, JSON.stringify(state));
|
|
await page.waitForTimeout(1100);
|
|
const after = await page.evaluate(() => {
|
|
const rect = document.querySelector(".ice-orb-1").getBoundingClientRect();
|
|
return { top: rect.top, left: rect.left };
|
|
});
|
|
check("reduced motion: no movement over a second", Math.abs(after.top - state.top) < 1 && Math.abs(after.left - state.left) < 1, `${state.top.toFixed(1)} → ${after.top.toFixed(1)}`);
|
|
await context.close();
|
|
}
|
|
|
|
/* 4. tab hidden: everything parks ------------------------------------- */
|
|
{
|
|
const { context, page } = await makePage(browser);
|
|
await seed(page, { decks: [DECK] });
|
|
await page.goto(BASE, { waitUntil: "networkidle" });
|
|
await page.waitForTimeout(900);
|
|
await page.evaluate(() => {
|
|
Object.defineProperty(document, "hidden", { configurable: true, get: () => true });
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
});
|
|
await page.waitForTimeout(200);
|
|
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);
|
|
check("tab hidden: parked orb does not move", Math.abs(held - paused.top) < 1, `${paused.top.toFixed(1)} → ${held.toFixed(1)}`);
|
|
await page.evaluate(() => {
|
|
Object.defineProperty(document, "hidden", { configurable: true, get: () => false });
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
});
|
|
await page.waitForTimeout(200);
|
|
const running = await page.evaluate(() => ({
|
|
scene: document.querySelector(".ice-scene").classList.contains("ice-paused"),
|
|
orb: getComputedStyle(document.querySelector(".ice-orb-1")).animationPlayState,
|
|
}));
|
|
check("tab visible again: rise resumes", !running.scene && running.orb === "running", JSON.stringify(running));
|
|
await context.close();
|
|
}
|
|
|
|
await browser.close();
|
|
finish();
|