/** * Ice-scene bubbles: the glossy orbs (and Aero's bubbles) must rise through * the open band — clear of the header, the bottom chrome and the desktop * rail — with sway + slight scale, fade in and out inside it so no visible * float ever touches the band's clip, animate only transform/opacity on their * own composited layers, stay behind everything, park under reduced motion, * while the tab is hidden, while the window has no focus and under a dialog * scrim, and keep the per-breakpoint count (5 desktop / 3 tablet / 2 phones). * The cost contract rides along: no mask on the animated layer, no blend-mode * grain, static blobs, and no backdrop blur on glass that sits still. */ 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(); const band = document.querySelector(".ice-floats").getBoundingClientRect(); return { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width, opacity: Number(getComputedStyle(orb).opacity), band: { top: band.top, bottom: band.bottom, left: band.left }, }; }, phase); } /** Parks every displayed float matching `selector` at 41 phases of its pass and lists the frames where a visible one (opacity > 0.05) pokes out of the band — the band's top, its bottom and, where it starts right of the viewport edge, its left side (the rail). Those are the frames where the clip would slice a bubble. */ async function sweep(page, selector) { return page.evaluate(async (selector) => { const band = document.querySelector(".ice-floats").getBoundingClientRect(); const floats = [...document.querySelectorAll(selector)].filter((el) => getComputedStyle(el).display !== "none"); const animations = floats.map((el) => { const animation = el.getAnimations()[0]; animation.pause(); return animation; }); const misses = []; let samples = 0; for (let step = 0; step <= 40; step += 1) { const phase = step / 40; for (const animation of animations) { const timing = animation.effect.getTiming(); const wanted = phase * timing.duration + (timing.delay ?? 0); animation.currentTime = ((wanted % timing.duration) + timing.duration) % timing.duration; } await new Promise((resolve) => requestAnimationFrame(resolve)); floats.forEach((el) => { const opacity = Number(getComputedStyle(el).opacity); if (opacity <= 0.05) return; samples += 1; const rect = el.getBoundingClientRect(); const out = rect.top < band.top - 2 || rect.bottom > band.bottom + 2 || (band.left > 0 && rect.left < band.left - 2); if (out) misses.push(`${el.className} @${phase}: ${Math.round(rect.top)}-${Math.round(rect.bottom)} x${Math.round(rect.left)} (${opacity.toFixed(2)})`); }); } return { floats: floats.length, samples, misses }; }, selector); } /* 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.3 && orb.opacity < 0.45), String(orbs[0]?.opacity)); /* The floats layer: orbs + bubbles live in one wrapper that is the open band between the chrome — a plain rectangular clip, never a mask (a mask re-renders the whole animated layer offscreen on every frame). */ const floats = await page.evaluate(() => { const layer = document.querySelector(".ice-floats"); const style = layer ? getComputedStyle(layer) : null; const rect = layer?.getBoundingClientRect(); return { children: layer ? layer.children.length : 0, orbs: document.querySelectorAll(".ice-floats .ice-orb").length, bubbles: document.querySelectorAll(".ice-floats .ice-bubble").length, mask: style ? style.maskImage || style.webkitMaskImage || "none" : "", overflow: style?.overflow, top: rect?.top, bottom: rect?.bottom, left: rect?.left, right: rect?.right, viewport: { width: document.documentElement.clientWidth, height: window.innerHeight }, }; }); check( "floats: orbs and bubbles ride one layer", floats.children === 23 && floats.orbs === 5 && floats.bubbles === 18, JSON.stringify(floats), ); check("floats: a plain clip, no mask on the animated layer", floats.mask === "none" && floats.overflow === "hidden", `${floats.mask} / ${floats.overflow}`); check( "floats: the band clears the header, the bottom chrome and the rail", floats.top === 68 && floats.viewport.height - floats.bottom === 151 && floats.left === 268 && floats.right === floats.viewport.width, JSON.stringify(floats), ); 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 band's bottom, ends at its top", Math.abs(start.bottom - start.band.bottom) < 8 && Math.abs(end.top - end.band.top) < 12, `start ${Math.round(start.bottom)}/${start.band.bottom}, end ${Math.round(end.top)}/${end.band.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.3 && end.opacity < 0.1, `${start.opacity} / ${mid.opacity} / ${end.opacity}`); const swept = await sweep(page, ".ice-orb"); check( "band: a visible orb never touches its edges (nothing to slice)", swept.floats === 5 && swept.samples > 100 && swept.misses.length === 0, swept.misses.slice(0, 3).join(" | ") || `${swept.samples} visible frames`, ); /* The cost contract of the static parts: the blobs are painted once (their drift was invisible but made every frame a full-screen redraw) and there is no blend-mode grain over the animated layer. */ const still = await page.evaluate(() => { const blobs = [...document.querySelectorAll(".ice-blob")].map((blob) => { const style = getComputedStyle(blob); return { animation: style.animationName, willChange: style.willChange }; }); const blended = [...document.querySelectorAll(".ice-scene, .ice-scene *")].filter( (el) => getComputedStyle(el).mixBlendMode !== "normal", ).length; return { blobs, blended, grain: document.querySelector(".ice-grain") !== null }; }); check( "blobs: static, painted once (no drift, no own layer)", still.blobs.length === 4 && still.blobs.every((blob) => blob.animation === "none" && blob.willChange === "auto"), JSON.stringify(still.blobs), ); check("scene: no blend-mode grain over the animated layer", !still.grain && still.blended === 0, JSON.stringify(still)); 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(); } /* 1b. Aero's bubbles ride the same band ------------------------------- */ for (const [width, height] of [[1280, 900], [390, 844]]) { const { context, page } = await makePage(browser, { width, height, deviceScaleFactor: 1 }); await seed(page, { decks: [DECK], theme: "aero" }); await page.goto(BASE, { waitUntil: "networkidle" }); await page.waitForTimeout(1000); const band = await page.evaluate(() => { const rect = document.querySelector(".ice-floats").getBoundingClientRect(); return { top: rect.top, bottom: window.innerHeight - rect.bottom, left: rect.left }; }); check( `aero ${width}px: the band clears the chrome`, band.top === 68 && band.bottom === (width >= 640 ? 151 : 172) && band.left === (width >= 1024 ? 268 : 0), JSON.stringify(band), ); const swept = await sweep(page, ".ice-bubble, .ice-orb"); check( `aero ${width}px: a visible bubble never touches the band's edges`, swept.floats >= 20 && swept.samples > 300 && swept.misses.length === 0, swept.misses.slice(0, 3).join(" | ") || `${swept.floats} floats, ${swept.samples} visible frames`, ); 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(); const floats = document.querySelector(".ice-floats").getBoundingClientRect(); return { animation: style.animationName, willChange: style.willChange, opacity: Number(style.opacity), top: rect.top, left: rect.left, floats: { top: floats.top, bottom: floats.bottom, viewport: window.innerHeight }, }; }); check( "reduced motion: the floats cover the whole scene again (parked orbs keep their spots)", state.floats.top === 0 && state.floats.bottom === state.floats.viewport, JSON.stringify(state.floats), ); 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.08 && 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, window blurred, dialog open: 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, 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: 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)); /* A visible window without focus (another app in front, the browser on a second screen) parks too, and focus brings it back. */ const focus = await page.evaluate(async () => { const frame = () => new Promise((resolve) => setTimeout(resolve, 150)); const read = () => ({ scene: document.querySelector(".ice-scene").classList.contains("ice-paused"), orb: getComputedStyle(document.querySelector(".ice-orb-1")).animationPlayState, }); document.hasFocus = () => false; window.dispatchEvent(new Event("blur")); await frame(); const blurred = read(); document.hasFocus = () => true; window.dispatchEvent(new Event("focus")); await frame(); return { blurred, focused: read() }; }); check("window blurred: the scene parks", focus.blurred.scene && focus.blurred.orb === "paused", JSON.stringify(focus.blurred)); check("window focused again: the rise resumes", !focus.focused.scene && focus.focused.orb === "running", JSON.stringify(focus.focused)); /* A dialog or sheet: its scrim (and the sheet's glass) blur whatever is behind them, so the scene holds still while one is open. */ const scrim = await page.evaluate(async () => { const frame = () => new Promise((resolve) => setTimeout(resolve, 100)); const overlay = document.createElement("div"); overlay.className = "scene-pause"; document.body.append(overlay); await frame(); const open = getComputedStyle(document.querySelector(".ice-orb-1")).animationPlayState; overlay.remove(); await frame(); return { open, closed: getComputedStyle(document.querySelector(".ice-orb-1")).animationPlayState }; }); check("dialog scrim: the scene parks while it is open, resumes after", scrim.open === "paused" && scrim.closed === "running", JSON.stringify(scrim)); await context.close(); } /* 5. glass that sits still never blurs -------------------------------- */ { /* The study feed: only its floating chrome (header + pager pills, which the slides scroll under) keeps a backdrop blur. */ const { context, page } = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 }); await seed(page, { decks: [DECK] }); await page.goto(`${BASE}/deck/${DECK.id}`, { waitUntil: "networkidle" }); await page.waitForTimeout(1200); const blurred = await page.evaluate(() => [...document.querySelectorAll("body *")] .filter((el) => { const style = getComputedStyle(el); const filter = style.backdropFilter || style.webkitBackdropFilter; return filter && filter !== "none" && style.display !== "none" && el.getClientRects().length > 0; }) .map((el) => String(el.className).split(" ")[0]), ); check( "study feed: only the floating chrome blurs (no blur on tiles, chips or hints)", blurred.length > 0 && blurred.every((name) => name === "study-chrome"), blurred.join(", "), ); await context.close(); /* Aero desktop: under the opaque bake a live blur never shows, so the chat chrome carries none; touch drops the bake and keeps the blur. */ for (const touch of [false, true]) { const aero = await makePage(browser, touch ? { width: 390, height: 844, isMobile: true, hasTouch: true } : { width: 1280, height: 900, deviceScaleFactor: 1 }); await seed(aero.page, { decks: [DECK], theme: "aero" }); await aero.page.goto(`${BASE}/chat`, { waitUntil: "networkidle" }); await aero.page.waitForTimeout(1000); const chrome = await aero.page.evaluate(() => [".chat-header", ".chat-composer-band"].map((selector) => { const style = getComputedStyle(document.querySelector(selector)); return { filter: style.backdropFilter || style.webkitBackdropFilter, bake: style.backgroundImage.includes("background-blur") }; }), ); check( touch ? "aero touch: the chat chrome keeps its live blur (no bake)" : "aero desktop: the baked chat chrome drops the hidden live blur", touch ? chrome.every((panel) => !panel.bake && panel.filter.includes("blur")) : chrome.every((panel) => panel.bake && panel.filter === "none"), JSON.stringify(chrome), ); await aero.context.close(); } } await browser.close(); finish();