diff --git a/README.md b/README.md index 210ce12..d72c95c 100644 --- a/README.md +++ b/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 diff --git a/app/globals.css b/app/globals.css index cec1d36..0ef8bf8 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; } } diff --git a/components/IceBackdrop.tsx b/components/IceBackdrop.tsx index 1f6888e..19dcca6 100644 --- a/components/IceBackdrop.tsx +++ b/components/IceBackdrop.tsx @@ -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 ( -
+
diff --git a/public/sw.js b/public/sw.js index ae0fa7c..1b29377 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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`; diff --git a/tests/README.md b/tests/README.md index 27c4fdd..af0b300 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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. diff --git a/tests/bubble-perf.mjs b/tests/bubble-perf.mjs new file mode 100644 index 0000000..94953e0 --- /dev/null +++ b/tests/bubble-perf.mjs @@ -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(); diff --git a/tests/bubbles-test.mjs b/tests/bubbles-test.mjs new file mode 100644 index 0000000..e5c2313 --- /dev/null +++ b/tests/bubbles-test.mjs @@ -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(); diff --git a/tests/mobile-test.mjs b/tests/mobile-test.mjs index 82fcb67..b7b5dc6 100644 --- a/tests/mobile-test.mjs +++ b/tests/mobile-test.mjs @@ -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(); diff --git a/tests/run-all.mjs b/tests/run-all.mjs index 7f5f610..b2a44a9 100644 --- a/tests/run-all.mjs +++ b/tests/run-all.mjs @@ -13,6 +13,7 @@ import path from "node:path"; const SUITES = [ "regression", + "bubbles-test", "mobile-test", "wizard-test", "native-test",