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:
parent
dbd5dfbc58
commit
6b6a6ea158
9 changed files with 419 additions and 31 deletions
25
README.md
25
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
84
tests/bubble-perf.mjs
Normal 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
207
tests/bubbles-test.mjs
Normal 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();
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import path from "node:path";
|
|||
|
||||
const SUITES = [
|
||||
"regression",
|
||||
"bubbles-test",
|
||||
"mobile-test",
|
||||
"wizard-test",
|
||||
"native-test",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue