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

@ -1072,8 +1072,29 @@ Things I chose while building, with the reasoning:
(native), i18n, the freeze loading screen, Smart Review, and phases 1–3 (backup/sync/usage
limits, the learning engine, homework check/follow-ups). Suites that need a stub Ollama or
VAPID keys start their own `next start` instance on a spare port, verify the port is free and
kill the process group on exit; test scratch stays in `tests/.tmp/` (gitignored). 379 checks
across the ten suites.
kill the process group on exit; test scratch stays in `tests/.tmp/` (gitignored). Eleven suites,
424 checks.
46. **The ice orbs are rising bubbles now, composited and cheaper to read through.** The glossy
orbs (the glacier blobs stay exactly as they were) rise from below the bottom edge to above the
top edge, each with its own 36–68 s pass (slower on phones), a negative delay so the scene is
already populated on load, its own horizontal position, a gentle sway and a slight scale drift;
opacity fades in/out near the edges so the loop never pops. Only `transform` and `opacity`
animate, each orb owns a composited layer (`will-change: transform`), there is no
filter/backdrop-filter on them, and nothing animates size, offsets or shadow. Opacity dropped
~38% in both themes (0.55→0.34 light, 0.8→0.5 dark; phone overrides 0.32→0.2 / 0.42→0.26) so
the background reads through while the gloss stays soft. Under `prefers-reduced-motion` the
rise is removed and the orbs return to their old static placements — a paused-from-the-start
CSS animation is not applied at all in Chrome (`getAnimations()` is empty), which would have
parked them below the fold; while the tab is hidden a visibility listener parks the whole scene
(`.ice-paused`), which also freezes the blob drift. Measured with a CDP performance trace on a
4× throttled phone profile (390×844 @2x): p95 33.4 ms, zero frames over 34 ms and no long tasks
— identical to the pre-change baseline — for ~0.6% extra main-thread CPU; the desktop scene
stays compositor-bound from the pre-existing blob drift, exactly as before. Verified by the
bubbles suite (34 checks: scene 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) plus the 16-run axe contrast matrix (360/390/1280 × dark/light × EN/CS).
`tests/bubble-perf.mjs` keeps the measurement repeatable.
36. **The mobile declutter pass reshapes, it doesn't fork.** Phones (≤639px) get a 3-step deck
flow and a 2-step homework flow that share the exact state and handlers with the desktop

View file

@ -178,14 +178,14 @@
--ice-base-top: #f4f9ff;
--ice-base-bottom: #e6f1fb;
--ice-blob-opacity: 0.18;
--ice-orb-opacity: 0.55;
--ice-orb-opacity: 0.34;
--ice-grain-opacity: 0.05;
}
.dark {
--ice-base-top: #070b16;
--ice-base-bottom: #0c1426;
--ice-blob-opacity: 0.4;
--ice-orb-opacity: 0.8;
--ice-orb-opacity: 0.5;
--ice-grain-opacity: 0.045;
}
@ -279,11 +279,21 @@ body {
50% { transform: translate3d(3vw, -4vh, 0) scale(1.1); }
}
/* Glossy ice orbs, placed so the glass panels blur them. */
/* Glossy ice orbs: slow bubbles that rise from just below the bottom edge to
above the top edge, swaying gently and fading in/out near 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, and no
animated size, offsets or shadow. Per-orb duration, delay (negative, so the
scene is already populated on load), horizontal position and sway keep them
out of sync. */
.ice-orb {
position: absolute;
top: 100%;
border-radius: 50%;
opacity: var(--ice-orb-opacity);
will-change: transform;
animation: ice-rise var(--ice-orb-duration, 48s) ease-in-out infinite;
animation-delay: var(--ice-orb-delay, 0s);
background: radial-gradient(
circle at 30% 25%,
rgb(255 255 255 / 0.95) 0%,
@ -293,11 +303,25 @@ body {
);
box-shadow: 0 18px 40px -18px rgb(30 64 175 / 0.55), inset 0 -8px 18px -10px rgb(30 64 175 / 0.6);
}
.ice-orb-1 { width: 190px; height: 190px; top: 14%; left: 12%; }
.ice-orb-2 { width: 84px; height: 84px; top: 58%; left: 4%; }
.ice-orb-3 { width: 220px; height: 220px; bottom: -70px; right: 10%; }
.ice-orb-4 { width: 52px; height: 52px; top: 24%; right: 28%; }
.ice-orb-5 { width: 120px; height: 120px; bottom: 16%; left: 38%; }
.ice-orb-1 { width: 190px; height: 190px; left: 12%; --ice-orb-duration: 46s; --ice-orb-delay: -18s; --ice-sway: 4vw; }
.ice-orb-2 { width: 84px; height: 84px; left: 4%; --ice-orb-duration: 58s; --ice-orb-delay: -41s; --ice-sway: -3vw; }
.ice-orb-3 { width: 220px; height: 220px; right: 10%; --ice-orb-duration: 36s; --ice-orb-delay: -7s; --ice-sway: 5vw; }
.ice-orb-4 { width: 52px; height: 52px; right: 28%; --ice-orb-duration: 64s; --ice-orb-delay: -52s; --ice-sway: -4vw; }
.ice-orb-5 { width: 120px; height: 120px; left: 38%; --ice-orb-duration: 52s; --ice-orb-delay: -27s; --ice-sway: 3vw; }
/* The rise: -100vh - 100% clears the top edge (100% = the orb's own height);
the sway crosses the path twice and the scale drifts slightly. Opacity
reaches the token value by 12% and holds it until 88%, so the fades happen
while the orb is still partly outside the scene. */
@keyframes ice-rise {
0% { transform: translate3d(0, 0, 0) scale(0.94); opacity: 0; }
12% { opacity: var(--ice-orb-opacity); }
32% { transform: translate3d(var(--ice-sway, 0px), calc(-32vh - 32%), 0) scale(0.97); }
58% { transform: translate3d(calc(var(--ice-sway, 0px) * -0.55), calc(-58vh - 58%), 0) scale(1.02); }
80% { transform: translate3d(calc(var(--ice-sway, 0px) * 0.4), calc(-80vh - 80%), 0) scale(1.04); }
88% { opacity: var(--ice-orb-opacity); }
100% { transform: translate3d(0, calc(-100vh - 100%), 0) scale(1.06); opacity: 0; }
}
/* Frosted grain over the whole scene. */
.ice-grain {
@ -310,6 +334,25 @@ body {
@media (prefers-reduced-motion: reduce) {
.ice-blob { animation: none; }
/* Orbs go back to the pre-rise static layout (a paused animation is not
applied at all in Chrome, which would leave them parked below the fold)
and give their composited layers back. */
.ice-orb {
animation: none;
will-change: auto;
}
.ice-orb-1 { top: 14%; left: 12%; }
.ice-orb-2 { top: 58%; left: 4%; }
.ice-orb-3 { top: auto; bottom: -70px; right: 10%; }
.ice-orb-4 { top: 24%; right: 28%; }
.ice-orb-5 { top: auto; bottom: 16%; left: 38%; }
}
/* Background tab: park every scene animation, so an invisible page costs no
compositor work. */
.ice-paused .ice-orb,
.ice-paused .ice-blob {
animation-play-state: paused;
}
/* Phones: fewer, softer blobs so the drift animation stays cheap. Unlayered
@ -321,28 +364,30 @@ body {
.ice-orb-5 {
display: none;
}
.ice-orb-1 { width: 120px; height: 120px; }
.ice-orb-3 { width: 150px; height: 150px; }
.ice-orb-1 { width: 120px; height: 120px; --ice-orb-duration: 54s; --ice-orb-delay: -21s; }
.ice-orb-2 { --ice-orb-duration: 64s; --ice-orb-delay: -46s; }
.ice-orb-3 { width: 150px; height: 150px; --ice-orb-duration: 44s; --ice-orb-delay: -8s; }
}
/* Phones: the decoration is already *behind* the content (the scene is a
fixed, pointer-events:none layer at z-index -1), but it shines through the
translucent page and glass, so it reads as overlap. Thin it right down and
tuck the orbs to the edges, away from the text column and the tab bar. */
translucent page and glass, so it reads as overlap. Thin it right down,
tuck the orbs to the edges, away from the text column and the tab bar, and
slow their rise down further so the drifting slivers stay calm. */
@media (max-width: 639px) {
:root {
--ice-blob-opacity: 0.1;
--ice-orb-opacity: 0.32;
--ice-orb-opacity: 0.2;
}
.dark {
--ice-blob-opacity: 0.2;
--ice-orb-opacity: 0.42;
--ice-orb-opacity: 0.26;
}
/* Phones: orbs hug the screen edges so they can never sit under the
content column's cards or primary buttons. The lower-right orb still
read as a glow behind the wizard action bar, so phones drop it. */
.ice-orb-1 { width: 96px; height: 96px; top: -44px; left: -48px; }
.ice-orb-2 { width: 72px; height: 72px; top: 60%; left: -44px; }
.ice-orb-1 { width: 96px; height: 96px; left: -48px; --ice-orb-duration: 62s; --ice-orb-delay: -30s; --ice-sway: 5vw; }
.ice-orb-2 { width: 72px; height: 72px; left: -44px; --ice-orb-duration: 68s; --ice-orb-delay: -49s; --ice-sway: -4vw; }
.ice-orb-3 { display: none; }
}

View file

@ -1,12 +1,27 @@
"use client";
import { useEffect, useState } from "react";
/**
* The fixed ice scene behind every screen: a navy (or pale) base, four slow
* drifting glacier blobs, glossy ice orbs and a whisper of grain. Purely
* decorative — it never intercepts pointer events, and the drift stops under
* `prefers-reduced-motion`.
* drifting glacier blobs, glossy ice orbs that rise slowly and fade at the
* edges, and a whisper of grain. Purely decorative — it never intercepts
* pointer events, only transform/opacity animate (each on its own composited
* layer), the rise stops under `prefers-reduced-motion`, and everything parks
* while the tab is hidden so a background tab costs no compositor work.
*/
export function IceBackdrop() {
const [hidden, setHidden] = useState(false);
useEffect(() => {
const apply = () => setHidden(document.hidden);
apply();
document.addEventListener("visibilitychange", apply);
return () => document.removeEventListener("visibilitychange", apply);
}, []);
return (
<div className="ice-scene" aria-hidden>
<div className={`ice-scene${hidden ? " ice-paused" : ""}`} aria-hidden>
<span className="ice-blob ice-blob-a" />
<span className="ice-blob ice-blob-b" />
<span className="ice-blob ice-blob-c" />

View file

@ -9,7 +9,7 @@
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
const CACHE_VERSION = "freezmo-v30";
const CACHE_VERSION = "freezmo-v31";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;

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",