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.
This commit is contained in:
Martin Dvořák 2026-10-11 15:50:17 +00:00
commit 6b6a6ea158
9 changed files with 419 additions and 31 deletions

View file

@ -27,6 +27,7 @@ prints a per-suite summary at the end.
| Suite | Covers |
| --- | --- |
| `regression.mjs` | Library, deck card + menu, study feed to results, homework entry, desktop shell + settings/health panel. |
| `bubbles-test.mjs` | Ice-scene bubbles: stacking/layout contract, keyframes only transform+opacity, rise/sway/scale/fade phases, per-breakpoint counts (5/3/2), phone pacing, reduced motion, tab-hidden parking. |
| `mobile-test.mjs` | Five-tab bar at 360/390 × EN/CS × dark/light × reduced motion (labels, targets, no hamburger/drawer), the library ⋯ sheet (Import JSON / New folder), the settings sheet (touch targets, key flow, drag-to-close, backdrop), axe contrast audits. |
| `wizard-test.mjs` | Deck 3-step flow and homework flow, flat action bar, in-page mode switch, the loading lock, abort-on-leave. |
| `native-test.mjs` | Manifest/PWA head, offline banner + disabled generate, share target GET + POST, Back-gesture layers, undo toast, tab re-tap, haptics, draft restore. |
@ -44,3 +45,10 @@ stub Ollama (or VAPID keys) on a spare port (3208/3209). Each suite checks the
port is free first and kills the whole process group on exit, so ports do not
leak between suites. Scratch data for the VAPID test lives in `tests/.tmp/`
(gitignored).
## Diagnostics (not part of the run)
`node tests/bubble-perf.mjs [label]` measures the ice-scene frame cost with a
CDP performance trace (rAF frame timing, long tasks, main-thread busy time,
style recalcs) on a 4× throttled phone profile and a 2× desktop profile. It
always exits 0; the numbers are for tuning the bubble count/speed.

84
tests/bubble-perf.mjs Normal file
View file

@ -0,0 +1,84 @@
/**
* 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();

207
tests/bubbles-test.mjs Normal file
View file

@ -0,0 +1,207 @@
/**
* 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();

View file

@ -221,10 +221,10 @@ for (const locale of ["en", "cs"]) {
await context.close();
}
/* 5. axe-core contrast audit (dark + light, en + cs) ----------------- */
/* 5. axe-core contrast audit (360/390/desktop × dark/light × en/cs) -- */
const AXE = axeSource();
async function audit(label, { locale, theme, openSettingsNow }) {
const { context, page } = await makePage(browser);
async function audit(label, { width = 390, locale, theme, openSettingsNow }) {
const { context, page } = await makePage(browser, { width, height: width >= 1280 ? 900 : 800, deviceScaleFactor: width >= 1280 ? 1 : 2 });
await seed(page, { locale, theme, decks: [DECK] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1200);
@ -246,11 +246,18 @@ async function audit(label, { locale, theme, openSettingsNow }) {
check(`axe ${label}: no contrast violations`, report.violations.length === 0, JSON.stringify(report.violations).slice(0, 300));
await context.close();
}
await audit("dark/en library", { locale: "en", theme: "dark" });
await audit("dark/cs library", { locale: "cs", theme: "dark" });
await audit("light/en library", { locale: "en", theme: "light" });
await audit("dark/en settings", { locale: "en", theme: "dark", openSettingsNow: true });
await audit("dark/cs settings", { locale: "cs", theme: "dark", openSettingsNow: true });
for (const width of [360, 390, 1280]) {
for (const theme of ["dark", "light"]) {
for (const locale of ["en", "cs"]) {
await audit(`${width} ${theme}/${locale} library`, { width, locale, theme });
}
}
}
for (const theme of ["dark", "light"]) {
for (const locale of ["en", "cs"]) {
await audit(`390 ${theme}/${locale} settings`, { width: 390, locale, theme, openSettingsNow: true });
}
}
await browser.close();
finish();

View file

@ -13,6 +13,7 @@ import path from "node:path";
const SUITES = [
"regression",
"bubbles-test",
"mobile-test",
"wizard-test",
"native-test",