From 1f2e702b4ef9fa2f0b62c73a32f995dfd1c61f6e Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Mon, 12 Oct 2026 19:47:36 +0000 Subject: [PATCH] Stop blurring the animated scene on static glass (2x frame time) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 14 +++++++++++ app/globals.css | 42 +++++++++++++++++++++----------- components/activities/shared.tsx | 2 +- public/sw.js | 2 +- tests/bubble-perf.mjs | 21 ++++++++++++++-- 5 files changed, 63 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b4be02e..2db47e0 100644 --- a/README.md +++ b/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 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 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 diff --git a/app/globals.css b/app/globals.css index 90a0b50..4357eb8 100644 --- a/app/globals.css +++ b/app/globals.css @@ -404,8 +404,20 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar) { @layer components { /* Frosted ice glass ------------------------------------------------------ - One shared recipe for the large surfaces only: translucent tint, 24px - blur, a bright rim, an inner top highlight and a blue-tinted shadow. + Two recipes now: + + - **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 backdrop utilities emit the -webkit- prefix too (hand-written backdrop-filter gets minified to -webkit- only, which paints nothing). */ @@ -416,11 +428,18 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar) { .tabbar, .glass, .gate-card { - @apply backdrop-blur-xl backdrop-saturate-[1.4]; background: var(--glass-bg); 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 { border: 1px solid var(--glass-border); border-radius: 1.5rem; @@ -460,28 +479,23 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar) { 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) { .glass-panel, - .card, - .sidebar-surface, .study-chrome, .tabbar, - .glass, - .gate-card { - @apply backdrop-blur-lg; + .glass { + @apply backdrop-blur-md; } } -/* 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))) { .glass-panel, - .card, - .sidebar-surface, .study-chrome, .tabbar, - .glass, - .gate-card { + .glass { background: var(--glass-fallback); } } diff --git a/components/activities/shared.tsx b/components/activities/shared.tsx index 4185cd9..9c520fd 100644 --- a/components/activities/shared.tsx +++ b/components/activities/shared.tsx @@ -78,7 +78,7 @@ export function PromptCard({ }) { return (
{children} diff --git a/public/sw.js b/public/sw.js index b083a25..6b8d422 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-v48"; +const CACHE_VERSION = "freezmo-v49"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`; diff --git a/tests/bubble-perf.mjs b/tests/bubble-perf.mjs index 94953e0..1888e2a 100644 --- a/tests/bubble-perf.mjs +++ b/tests/bubble-perf.mjs @@ -14,7 +14,7 @@ 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 }) { +async function measure(name, { width, height, throttle, reducedMotion, extraCss }) { const { context, page } = await makePage(browser, { width, height, deviceScaleFactor: 2, reducedMotion }); const cdp = await context.newCDPSession(page); 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 page.goto(BASE, { waitUntil: "networkidle" }); await page.waitForTimeout(1500); + if (extraCss) { + await page.addStyleTag({ content: extraCss }); + await page.waitForTimeout(400); + } const scene = await page.evaluate(() => { 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 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 }); +/* 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();