Stop blurring the animated scene on static glass (2x frame time)
The MacBook ran hot browsing the app: the ice scene animates constantly, so every backdrop-filter surface re-ran its blur on every frame. Measured on desktop (2x CPU throttle, software renderer): p50 frame 66.6ms baseline, 33.3ms with the static surfaces not filtering, 16.7ms with the scene's animation also off; frames over 34ms: 128 -> 14. The glass recipe is split: floating layers (sheets, dropdowns, dialogs, the sticky header/chrome/tab bar) keep the blur (backdrop-blur-lg, what phones already used); static layers (cards, the sidebar rail, the gate, the study prompt card) keep the identical tint, rim, highlight and shadow without the filter. The study prompt card moves from glass-panel to card + glass-solid, which is the same surface recipe. Quality: a deterministic pixel diff (scene motion frozen, both themes, desktop + phone) shows a ~1% colour shift in the card backdrops — invisible. The mobile axe contrast audit and the bubbles suite stay green. tests/bubble-perf.mjs now also reports the glass cost. README: decision 61. SW -> freezmo-v49.
This commit is contained in:
parent
9b9e978ef2
commit
1f2e702b4e
5 changed files with 63 additions and 18 deletions
14
README.md
14
README.md
|
|
@ -1361,6 +1361,20 @@ Things I chose while building, with the reasoning:
|
||||||
phase-1 suite (43 checks, including the real route mapping against a stub Ollama returning a
|
phase-1 suite (43 checks, including the real route mapping against a stub Ollama returning a
|
||||||
monthly-limit body, a two-device sync round-trip, and the generation guards below).
|
monthly-limit body, a two-device sync round-trip, and the generation guards below).
|
||||||
|
|
||||||
|
61. **Glass that sits still doesn't blur.** The MacBook got hot just browsing: the ice scene animates
|
||||||
|
constantly, and every `backdrop-filter` surface re-runs its blur on every frame while the backdrop
|
||||||
|
moves — the whole card and rail area was re-blurring the scene ~60 times a second. Measured on
|
||||||
|
desktop (2x CPU throttle, software renderer): p50 frame 66ms with everything blurring, 33ms once
|
||||||
|
the static surfaces stopped, 17ms with the scene's animation also off — frames over 34ms dropped
|
||||||
|
from 128 to 14. The recipe is now split: **floating glass** (sheets, dropdowns, dialogs, the
|
||||||
|
sticky header/chrome/tab bar — the layers that can have content scroll underneath) keeps the
|
||||||
|
blur, now `backdrop-blur-lg` (which is what phones already used), while **static glass** (cards,
|
||||||
|
the rail, the gate, the study prompt card) keeps the exact same tint, rim, highlight and shadow
|
||||||
|
without the filter. A deterministic before/after pixel diff (scene motion frozen) shows the
|
||||||
|
change is a ~1% colour shift in the card backdrops — invisible in both themes.
|
||||||
|
`tests/bubble-perf.mjs` now reports the glass cost too, so a future blurred surface over the
|
||||||
|
scene shows up immediately.
|
||||||
|
|
||||||
60. **There is exactly one loading screen.** The deck wizard had the good one — the app icon, the
|
60. **There is exactly one loading screen.** The deck wizard had the good one — the app icon, the
|
||||||
rotating stage message as the headline, the progress bar, the stage checklist, a tip and
|
rotating stage message as the headline, the progress bar, the stage checklist, a tip and
|
||||||
Cancel — while the homework flows showed a spinner with a title and the route loads a pulsing
|
Cancel — while the homework flows showed a spinner with a title and the route loads a pulsing
|
||||||
|
|
|
||||||
|
|
@ -404,8 +404,20 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar) {
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
/* Frosted ice glass ------------------------------------------------------
|
/* Frosted ice glass ------------------------------------------------------
|
||||||
One shared recipe for the large surfaces only: translucent tint, 24px
|
Two recipes now:
|
||||||
blur, a bright rim, an inner top highlight and a blue-tinted shadow.
|
|
||||||
|
- **Floating glass** (sheets, dropdowns, dialogs and the sticky chrome):
|
||||||
|
the tint below plus a backdrop blur — the blur is what reads as glass
|
||||||
|
when content scrolls underneath (the app header, the study chrome, the
|
||||||
|
tab bar, a sheet over the page).
|
||||||
|
- **Static glass** (cards, the rail, the gate, the study prompt card): the
|
||||||
|
exact same tint, rim, highlight and shadow, but **no** backdrop-filter.
|
||||||
|
On a smooth gradient the blur is invisible — and because the ice scene
|
||||||
|
animates, every blurred surface re-runs the filter on every frame.
|
||||||
|
Measured on desktop (2x throttle, software renderer): p50 frame time
|
||||||
|
66ms while the static surfaces blurred the scene, 33ms without them,
|
||||||
|
17ms with the scene's animation also stopped.
|
||||||
|
|
||||||
Nested glass (chips, inputs) never adds a second blur layer. Tailwind's
|
Nested glass (chips, inputs) never adds a second blur layer. Tailwind's
|
||||||
backdrop utilities emit the -webkit- prefix too (hand-written
|
backdrop utilities emit the -webkit- prefix too (hand-written
|
||||||
backdrop-filter gets minified to -webkit- only, which paints nothing). */
|
backdrop-filter gets minified to -webkit- only, which paints nothing). */
|
||||||
|
|
@ -416,11 +428,18 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar) {
|
||||||
.tabbar,
|
.tabbar,
|
||||||
.glass,
|
.glass,
|
||||||
.gate-card {
|
.gate-card {
|
||||||
@apply backdrop-blur-xl backdrop-saturate-[1.4];
|
|
||||||
background: var(--glass-bg);
|
background: var(--glass-bg);
|
||||||
box-shadow: var(--glass-shadow), inset 0 1px 0 var(--glass-highlight);
|
box-shadow: var(--glass-shadow), inset 0 1px 0 var(--glass-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Only the layers that can have content behind them blur. */
|
||||||
|
.glass-panel,
|
||||||
|
.study-chrome,
|
||||||
|
.tabbar,
|
||||||
|
.glass {
|
||||||
|
@apply backdrop-blur-lg backdrop-saturate-[1.4];
|
||||||
|
}
|
||||||
|
|
||||||
.glass-panel {
|
.glass-panel {
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: 1.5rem;
|
border-radius: 1.5rem;
|
||||||
|
|
@ -460,28 +479,23 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar) {
|
||||||
border-radius: 1.75rem; /* 28px */
|
border-radius: 1.75rem; /* 28px */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Blur is expensive on phones: halve it, and thin out the ice scene. */
|
/* Blur is expensive: halve it on phones (floating glass only). */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.glass-panel,
|
.glass-panel,
|
||||||
.card,
|
|
||||||
.sidebar-surface,
|
|
||||||
.study-chrome,
|
.study-chrome,
|
||||||
.tabbar,
|
.tabbar,
|
||||||
.glass,
|
.glass {
|
||||||
.gate-card {
|
@apply backdrop-blur-md;
|
||||||
@apply backdrop-blur-lg;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* No backdrop-filter support: fall back to a more opaque surface. */
|
/* No backdrop-filter support: fall back to a more opaque surface (floating
|
||||||
|
glass only — the static surfaces already look right without a filter). */
|
||||||
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
|
||||||
.glass-panel,
|
.glass-panel,
|
||||||
.card,
|
|
||||||
.sidebar-surface,
|
|
||||||
.study-chrome,
|
.study-chrome,
|
||||||
.tabbar,
|
.tabbar,
|
||||||
.glass,
|
.glass {
|
||||||
.gate-card {
|
|
||||||
background: var(--glass-fallback);
|
background: var(--glass-fallback);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ export function PromptCard({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("glass-panel glass-solid rounded-[1.5rem] px-5 py-6 text-balance sm:px-6", className)}
|
className={cn("card glass-solid rounded-[1.5rem] px-5 py-6 text-balance sm:px-6", className)}
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
*
|
*
|
||||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||||
*/
|
*/
|
||||||
const CACHE_VERSION = "freezmo-v48";
|
const CACHE_VERSION = "freezmo-v49";
|
||||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { BASE, DECK, launchBrowser, makePage, seed } from "./lib.mjs";
|
||||||
const label = process.argv[2] ?? "run";
|
const label = process.argv[2] ?? "run";
|
||||||
const browser = await launchBrowser();
|
const browser = await launchBrowser();
|
||||||
|
|
||||||
async function measure(name, { width, height, throttle, reducedMotion }) {
|
async function measure(name, { width, height, throttle, reducedMotion, extraCss }) {
|
||||||
const { context, page } = await makePage(browser, { width, height, deviceScaleFactor: 2, reducedMotion });
|
const { context, page } = await makePage(browser, { width, height, deviceScaleFactor: 2, reducedMotion });
|
||||||
const cdp = await context.newCDPSession(page);
|
const cdp = await context.newCDPSession(page);
|
||||||
await cdp.send("Emulation.setCPUThrottlingRate", { rate: throttle });
|
await cdp.send("Emulation.setCPUThrottlingRate", { rate: throttle });
|
||||||
|
|
@ -22,6 +22,10 @@ async function measure(name, { width, height, throttle, reducedMotion }) {
|
||||||
await seed(page, { decks: [DECK] });
|
await seed(page, { decks: [DECK] });
|
||||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||||
await page.waitForTimeout(1500);
|
await page.waitForTimeout(1500);
|
||||||
|
if (extraCss) {
|
||||||
|
await page.addStyleTag({ content: extraCss });
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
}
|
||||||
|
|
||||||
const scene = await page.evaluate(() => {
|
const scene = await page.evaluate(() => {
|
||||||
const orb = document.querySelector(".ice-orb-1");
|
const orb = document.querySelector(".ice-orb-1");
|
||||||
|
|
@ -79,6 +83,19 @@ const phone = { width: 390, height: 844 };
|
||||||
const animated = await measure("phone 390 animated", { ...phone, throttle: 4 });
|
const animated = await measure("phone 390 animated", { ...phone, throttle: 4 });
|
||||||
const still = await measure("phone 390 reduced-motion", { ...phone, throttle: 4, reducedMotion: "reduce" });
|
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 });
|
const desktop = await measure("desktop 1280 animated", { width: 1280, height: 900, throttle: 2 });
|
||||||
|
/* The glass cost: how much of the frame time is backdrop-filter re-blurring
|
||||||
|
the (always animating) scene. Static surfaces no longer blur at all — see
|
||||||
|
the "Frosted ice glass" block in app/globals.css — so this number should
|
||||||
|
stay close to the animated run; a jump means a new blurred surface landed
|
||||||
|
on top of the scene. */
|
||||||
|
const noGlass = await measure("desktop 1280 no backdrop-filter", {
|
||||||
|
width: 1280,
|
||||||
|
height: 900,
|
||||||
|
throttle: 2,
|
||||||
|
extraCss: "*{backdrop-filter:none !important;-webkit-backdrop-filter:none !important;}",
|
||||||
|
});
|
||||||
|
|
||||||
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`);
|
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; glass cost on desktop: p95 ${(desktop.p95 - noGlass.p95).toFixed(1)}ms, over-34ms frames ${desktop.over34 - noGlass.over34}`,
|
||||||
|
);
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue