From bfc0f806b9aef10180fbc5973839cb67bbc6fb5a Mon Sep 17 00:00:00 2001 From: pepaskopec-blip Date: Thu, 15 Oct 2026 19:39:29 +0200 Subject: [PATCH] The scene stops heating the MacBook "Terribly high CPU usage, the MacBook gets really hot" - a fanless MacBook Air on a 144Hz screen. Measured on that machine with headed engines (process CPU plus the per-process GPU time macOS keeps): an idle Aero library cost LibreWolf ~110% CPU and ~230ms/s of GPU, the study feed ~130%, Chromium up to ~57% CPU and ~350ms/s of GPU, Safari ~220-250ms/s of WindowServer GPU. Priced one by one: - the gradient mask on .ice-floats re-rendered the animated layer offscreen on every frame (~75 points of Gecko CPU). The floats now rise through a plain rectangular band clear of the header, the bottom chrome and the rail, and fade in and out inside it, so the clip never cuts a visible one; - the overlay grain forced a backdrop readback per frame for a texture that measured a mean <=0.11/255 difference: removed; - the invisible blob drift made every frame a full-screen redraw: the blobs are painted once; - static glass (answer tiles, feedback panel, chips, inputs, dropzones, lists, segmented controls, Aero hint chips) re-blurred the moving scene every frame: only floating glass blurs now, and Aero's baked chat chrome drops a live blur that never showed under the bake; - the scene also parks while the window has no focus and under any dialog or sheet scrim (.scene-pause); the loading icon is a compositor-run CSS animation instead of a framer-motion main-thread loop. After, focused and animating: LibreWolf library 109% -> 33% CPU, study 134% -> 34%, chat 130% -> 42%; Chromium GPU 2.5-4.4x lower with 7-22 points less CPU; Safari's WindowServer GPU -20-26% in Aero, about half in dark. Unfocused or under a sheet: nothing. bubbles-test sweeps every float through 41 phases against the band and pins the parking and the cost contract; tests/heat-probe.mjs (macOS) repeats the measurement; the suites find Playwright's Chromium on macOS. All 15 suites pass, lint clean, the contrast scan lost a finding. Version 0.6.7, SW v89. Co-authored-by: Cursor --- AGENTS.md | 2 +- README.md | 61 +++++- app/globals.css | 277 +++++++++++--------------- components/ApiKeyGate.tsx | 2 +- components/IceBackdrop.tsx | 28 ++- components/InstallSheet.tsx | 2 +- components/LibraryScreen.tsx | 2 +- components/LoadingState.tsx | 10 +- components/ResultsScreen.tsx | 4 +- components/SettingsDialog.tsx | 2 +- components/UploadScreen.tsx | 20 +- components/activities/FillBlank.tsx | 2 +- components/activities/Matching.tsx | 2 +- components/activities/Ordering.tsx | 2 +- components/activities/ShortAnswer.tsx | 2 +- components/activities/shared.tsx | 6 +- components/chat/ContextPicker.tsx | 2 +- components/chat/HistoryPanel.tsx | 2 +- components/chat/ModelPicker.tsx | 2 +- components/homework/HomeworkGuide.tsx | 4 +- components/homework/HomeworkInput.tsx | 6 +- package.json | 2 +- public/sw.js | 2 +- tests/README.md | 12 +- tests/bubbles-test.mjs | 234 ++++++++++++++++++++-- tests/heat-probe.mjs | 138 +++++++++++++ tests/lib.mjs | 30 ++- 27 files changed, 611 insertions(+), 247 deletions(-) create mode 100644 tests/heat-probe.mjs diff --git a/AGENTS.md b/AGENTS.md index decd690..434761f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ Production only ever runs a pushed, verified commit deployed by 9. **The test suites live in `tests/`** (`npm run test:e2e`, filterable: `npm run test:e2e -- chat`). Add or extend a suite for every feature; a green suite set is part of "done". Diagnostics that are not gates: - `tests/bubble-perf.mjs`, `tests/model-compare.mjs`. + `tests/bubble-perf.mjs`, `tests/heat-probe.mjs` (macOS), `tests/model-compare.mjs`. 10. **Keep the service worker honest**: bump `CACHE_VERSION` in `public/sw.js` for every user-visible deploy (the deploy itself does not do this). 11. **Version the app once per task.** Bump `package.json` `version` with diff --git a/README.md b/README.md index 99ca516..0589ef1 100644 --- a/README.md +++ b/README.md @@ -217,16 +217,18 @@ the saved notes) with the matching words highlighted green, and the feed chrome' their own bottom action bar instead of the tab bar, and the in-page segmented switch moves between the deck and homework wizards. - **Frosted ice glass, both themes**: light page `#eef5fd` (pale ice) with white glass panels; - dark page `#0a1120` with `rgba(255,255,255,.06)` glass. Every large surface shares one recipe — - translucent tint, 24px `backdrop-blur` (16px on phones), a bright 1px rim, an inner top highlight - and a blue-tinted shadow — over a fixed ice scene (navy/pale base, drifting glacier blobs, glossy - ice orbs and a whisper of grain). + dark page `#0a1120` with `rgba(255,255,255,.06)` glass. Every surface shares one recipe — + translucent tint, a bright 1px rim, an inner top highlight and a blue-tinted shadow, plus a real + 16px `backdrop-blur` (12px on phones) only on floating glass that content scrolls under — over a + fixed ice scene (navy/pale base, soft glacier blobs and glossy ice orbs rising through the open + band between the chrome). Text tiers are documented AA ratios (see below), and the cards fill the row with `repeat(auto-fill, minmax(280px, 1fr))` — no stranded cards, no dead space - Deck cards: title → muted metadata → neutral tags (difficulty carries the only colour: green/amber/red) → summary → a single **Study** button, with everything else in the `⋯` menu -- **Frosted UI**: the sticky header, tab bar, sheets and panels use real `backdrop-filter` blur over - the fixed ice scene (drifting glacier blobs, glossy orbs, a whisper of grain) — depth without noise +- **Frosted UI**: the sticky header, tab bar, study chrome and sheets use real `backdrop-filter` + blur over the fixed ice scene — depth without noise. Glass that sits still never blurs, and the + scene parks whenever nobody is watching it (hidden tab, unfocused window, an open dialog) - **Pill language**: primary CTAs are near-black in light mode and white in dark mode; secondaries are outlined or ghost. Colour is reserved for meaning — activity tints (sky/lilac/mint/peach/rose), correct/wrong feedback, key status @@ -1434,6 +1436,53 @@ Things I chose while building, with the reasoning: (measured: 268..1280 at a 1280 window) — a flat pane floating 40px inside the scene read as "cut off next to the sidebar". +98. **The MacBook ran hot on the scene, so the scene's per-frame bill came down.** "It has + terribly high CPU usage and the MacBook gets really hot when I'm on it" — a fanless MacBook Air + driving a 144Hz display. Measured on that machine with real headed engines (process CPU plus + the per-process GPU time macOS keeps — `tests/heat-probe.mjs`), not frame times in a throttled + software renderer: an idle Aero library cost LibreWolf (Gecko) ~110% CPU and ~230 ms of GPU + per second, the study feed ~130% / ~320 ms, Chromium ~57% CPU and up to ~350 ms/s of GPU, and + Safari ~220-250 ms/s of WindowServer GPU above the desktop. Each suspect was switched off on its + own to price it: + - **The gradient mask on `.ice-floats`** (from 91) re-rendered the whole animated layer + offscreen on every frame — ~75 points of Gecko CPU by itself. The floats now rise through a + plain rectangular band (68px below the top, 151px above the bottom — 172px on phones — and + right of the 268px rail strip on desktop), and every orb and bubble starts at the band's + bottom and ends at its top at zero opacity: the fades live in the keyframes (in by 18%, out + from 90%; `--ice-rise` is the band height minus the float's own) and the 1.06 end scale is + taken back by a 3% nudge, so no visible float ever touches the clip. The bubbles suite sweeps + every float through 41 phases at 1280 and 390 and fails on any visible frame outside the band. + - **The overlay grain** (`mix-blend-mode` over the animated layer) forced a backdrop readback + per frame for a texture that measured a max 2-6/255 and mean ≤0.11/255 difference — + invisible. Gone. + - **The blob drift**: four viewport-sized layers moving a few vw over 44-60s at 0.045-0.1 + opacity turned every frame into a full-screen redraw (Chromium GPU 128 → 80 ms/s with them + still). They are painted once now. + - **Backdrop blurs on glass that sits still**: answer tiles, the feedback panel, chips, inputs, + dropzones, grouped lists, segmented controls and Aero's hint chips re-blurred the moving + scene every frame (the study feed's tiles alone: ~20% Chromium CPU, ~270 ms/s of GPU). Only + floating glass that content scrolls under blurs now; the hint chips use the shared bake, and + the baked Aero chat header/composer drop a live blur that never showed under the opaque bake + (touch has no bake there and keeps it). Their bake is the glass on desktop, so 97's flat + nested tiles still sit on the right backdrop. + - **Nobody watching**: besides the hidden tab, the scene now parks while the window has no + focus (another app in front, the browser on the other screen) and while any dialog or sheet + is open — its scrim carries `.scene-pause`, so the blurred scrim and sheet render once. + - The loading screen's rocking icon was a framer-motion loop writing the transform from the + main thread every frame for the whole generation; it is a compositor-run CSS animation on + `rotate`/`scale` now. + + Same machine, focused and animating (before → after): LibreWolf library Aero 109% → 33% CPU + (GPU 227 → 101 ms/s), study 134% → 34% (316 → 141), chat 130% → 42%, dark/light library ~94% → + 24-26%; Chromium's GPU time 2.5-4.4× lower (study Aero 354 → 80 ms/s) with 7-22 points less CPU; + Safari's WindowServer GPU above the desktop −20-26% in Aero and about half in dark. Unfocused, + or with a sheet open, the scene costs nothing. What remains is the price of the ambient motion + itself — the orbs (23 floats in Aero) composited at the display's refresh rate, on the + compositor only (Chromium's main thread: ~9ms busy per 4s); Settings → Reduce motion parks it + entirely. The visuals are unchanged apart from where a bubble happens to be at a given moment, + and the pixel-contrast scan lost a finding (the Aero study chip, 4.49:1). The suites also find + Playwright's Chromium on macOS now (`~/Library/Caches/ms-playwright`). + 97. **Nested tiles inside the real-blur chrome drop the baked backdrop.** "Frutiger aero theme — the attachment button looks completely different to the other ones." The composer band, the chat header and the study pager keep a real `backdrop-filter` (they float over scrolling diff --git a/app/globals.css b/app/globals.css index a1a4300..fc248dc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -197,14 +197,12 @@ --ice-base-bottom: #e6f1fb; --ice-blob-opacity: 0.045; --ice-orb-opacity: 0.22; - --ice-grain-opacity: 0.05; } .dark { --ice-base-top: #070b16; --ice-base-bottom: #0c1426; --ice-blob-opacity: 0.1; --ice-orb-opacity: 0.38; - --ice-grain-opacity: 0.045; } /* Frutiger Aero — the third theme (Settings → Theme). An underwater scene: @@ -265,7 +263,6 @@ --ice-base-bottom: #0a5490; --ice-blob-opacity: 0.5; --ice-orb-opacity: 0.6; - --ice-grain-opacity: 0.02; } /* The scene is the wallpaper itself (a Frutiger Aero scene made with Gemini, @@ -321,14 +318,17 @@ } /* Rising bubbles. Hidden entirely in the other themes, so they cost nothing - there; under reduced motion they sit still instead of rising. */ + there; under reduced motion they sit still instead of rising. Each one + rises from the bottom of the open band to its top (see .ice-floats) and + fades in and out inside it, so the band's clip never cuts a visible + bubble. */ .ice-bubble { display: none; } .aero .ice-bubble { display: block; position: absolute; - bottom: -14vh; + bottom: 0; border-radius: 50%; opacity: 0; background: @@ -351,16 +351,13 @@ animation-delay: var(--bubble-delay, 0s); will-change: transform, opacity; } -.ice-paused .ice-bubble { - animation-play-state: paused; -} @keyframes aero-bubble-rise { 0% { transform: translate3d(0, 0, 0) scale(0.85); opacity: 0; } - 6% { opacity: var(--bubble-opacity, 0.9); } - 50% { transform: translate3d(var(--bubble-sway, 3vw), -52vh, 0) scale(1); } - 94% { opacity: var(--bubble-opacity, 0.9); } - 100% { transform: translate3d(calc(var(--bubble-sway, 3vw) * -0.5), -104vh, 0) scale(1.06); opacity: 0; } + 18% { opacity: var(--bubble-opacity, 0.9); } + 50% { transform: translate3d(var(--bubble-sway, 3vw), calc(var(--ice-rise) * 0.5), 0) scale(1); } + 90% { opacity: var(--bubble-opacity, 0.9); } + 100% { transform: translate3d(calc(var(--bubble-sway, 3vw) * -0.5), calc(var(--ice-rise) + 3%), 0) scale(1.06); opacity: 0; } } .aero .ice-bubble-1 { width: 104px; height: 104px; left: 7%; --bubble-duration: 19s; --bubble-delay: -2s; --bubble-sway: 4vw; } @@ -384,15 +381,14 @@ /* Helper lines that sit straight on the wallpaper (the keyboard tip, the AI note) need their own surface in Aero: dark text on the grass measured - 2.5:1. A small glass chip fixes it without hiding the wallpaper. */ + 2.5:1. A small glass chip fixes it without hiding the wallpaper — the + shared frosted recipe below, not a live blur. */ .aero .hint-line { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 0.75rem; - background: var(--glass-bg); + background-color: var(--glass-bg); border: 1px solid var(--glass-border); - /* Same trap as the buttons above: use the Tailwind utilities. */ - @apply backdrop-blur-md backdrop-saturate-150; } /* Bright white rims on the study/homework segments (the frosted fill itself @@ -454,6 +450,7 @@ .aero .chat-empty, .aero .chat-composer-band, .aero .segmented, +.aero .hint-line, /* Hand-rolled frost fills in the study, exam, chat and homework flows: white/xx utilities and the surface tokens — without this they render as flat white chips next to the glass. */ @@ -484,6 +481,15 @@ background-repeat: no-repeat, no-repeat, no-repeat; } +/* The chat header and composer are floating glass (a live backdrop blur), + but the bake above is opaque: the blur under it never shows, yet it still + re-ran on every frame. The touch fallback below drops the bake, so it + brings the blur back there. */ +.aero .chat-header, +.aero .chat-composer-band { + @apply backdrop-filter-none; +} + /* The wizard's phone action bar: Aero's page token is the saturated water blue, which read as a heavy slab over the photo — give it the same thin glass as everything else (it already has a real backdrop blur). */ @@ -680,10 +686,15 @@ .aero .chat-header, .aero .chat-empty, .aero .chat-composer-band, - .aero .segmented { + .aero .segmented, + .aero .hint-line { background-image: none; background-color: rgb(255 255 255 / 0.32); } + .aero .chat-header, + .aero .chat-composer-band { + @apply backdrop-blur-md; + } /* Reading surfaces keep a touch more body than the chrome — long answers have to stay comfortable over grass and water — but they are still glass, not white panes. */ @@ -899,8 +910,9 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { /* The key gate rides on the shared ice scene (see .ice-scene). */ -/* The fixed ice scene: navy/pale base, drifting glacier blobs, glossy ice - orbs and a whisper of grain. Purely decorative. */ +/* The fixed ice scene: navy/pale base, soft glacier blobs and glossy ice + orbs. Purely decorative. Only the orbs (and Aero's bubbles) move; the base + and the blobs are painted once. */ .ice-scene { position: fixed; inset: 0; @@ -914,61 +926,45 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { position: absolute; border-radius: 50%; opacity: var(--ice-blob-opacity); - will-change: transform; /* No filter: blur() here on purpose. A radial gradient fading to - transparent is already a soft blob, and blurring a huge layer that is - also being animated forces a re-rasterisation every frame (measured - 150ms vs 33ms per frame in a software renderer). The falloff stops below - keep the same softness. */ + transparent is already a soft blob, and blurring a huge layer forces a + re-rasterisation (measured 150ms vs 33ms per frame in a software + renderer). The falloff stops below keep the same softness. + No drift either: a few vw over 44-60s never showed at this opacity, but + four viewport-sized moving layers turned every frame into a full-screen + redraw. */ } .ice-blob-a { width: 52vw; height: 52vw; min-width: 24rem; min-height: 24rem; top: -14vh; left: -10vw; background: radial-gradient(circle, rgb(56 189 248 / 0.9) 0%, rgb(56 189 248 / 0.45) 38%, transparent 72%); - animation: ice-drift-a 44s ease-in-out infinite; } .ice-blob-b { width: 46vw; height: 46vw; min-width: 22rem; min-height: 22rem; top: 4vh; right: -12vw; background: radial-gradient(circle, rgb(96 165 250 / 0.9) 0%, rgb(96 165 250 / 0.45) 38%, transparent 72%); - animation: ice-drift-b 52s ease-in-out infinite; } .ice-blob-c { width: 48vw; height: 48vw; min-width: 22rem; min-height: 22rem; bottom: -16vh; left: 14vw; background: radial-gradient(circle, rgb(129 140 248 / 0.9) 0%, rgb(129 140 248 / 0.42) 38%, transparent 74%); - animation: ice-drift-c 60s ease-in-out infinite; } .ice-blob-d { width: 34vw; height: 34vw; min-width: 16rem; min-height: 16rem; bottom: 2vh; right: 6vw; background: radial-gradient(circle, rgb(56 189 248 / 0.85) 0%, rgb(56 189 248 / 0.4) 38%, transparent 74%); - animation: ice-drift-a 48s ease-in-out infinite reverse; } -@keyframes ice-drift-a { - 0%, 100% { transform: translate3d(0, 0, 0) scale(1); } - 50% { transform: translate3d(4vw, 3vh, 0) scale(1.08); } -} -@keyframes ice-drift-b { - 0%, 100% { transform: translate3d(0, 0, 0) scale(1.05); } - 50% { transform: translate3d(-5vw, 4vh, 0) scale(1); } -} -@keyframes ice-drift-c { - 0%, 100% { transform: translate3d(0, 0, 0) scale(1); } - 50% { transform: translate3d(3vw, -4vh, 0) scale(1.1); } -} - -/* 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 +/* Glossy ice orbs: slow bubbles that rise through the open band (see + .ice-floats), swaying gently and fading in/out at its 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%; + bottom: 0; border-radius: 50%; opacity: var(--ice-orb-opacity); will-change: transform; @@ -989,120 +985,58 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { .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. */ +/* The rise: --ice-rise lifts an orb from the bottom of the band until its + top edge meets the band's top (100% = the orb's own height; the extra 3% + at the end takes back what the 1.06 scale adds above it); the sway crosses + the path twice and the scale drifts slightly. Opacity reaches the token + value by 18% and holds it until 90%, so a visible orb never touches the + band's edges. */ @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; } + 18% { opacity: var(--ice-orb-opacity); } + 32% { transform: translate3d(var(--ice-sway, 0px), calc(var(--ice-rise) * 0.32), 0) scale(0.97); } + 58% { transform: translate3d(calc(var(--ice-sway, 0px) * -0.55), calc(var(--ice-rise) * 0.58), 0) scale(1.02); } + 80% { transform: translate3d(calc(var(--ice-sway, 0px) * 0.4), calc(var(--ice-rise) * 0.8), 0) scale(1.04); } + 90% { opacity: var(--ice-orb-opacity); } + 100% { transform: translate3d(0, calc(var(--ice-rise) + 3%), 0) scale(1.06); opacity: 0; } } -/* The rising orbs and bubbles are clipped to the scene, but the floating - glass panels (the rail, the chat header/composer, the tab bar) sit on top of - it — a bubble reaching one of those edges looked sliced by a straight line. - This layer keeps them inside the open middle band: they fade in below the - top chrome, out above the bottom chrome (the values come from where the - header and the composer actually end) and, on desktop, stay out of the rail - strip. Static under reduced motion: nothing moves there, and the parked - orbs keep their spots. */ +/* The open band the orbs and bubbles rise through. The floating glass + panels (the rail, the chat header/composer, the tab bar) sit on top of the + scene, and a bubble reaching one of their edges looked sliced by a straight + line — so the floats stay clear of them: below the top chrome, above the + bottom chrome (the values come from where the header and the composer + actually end) and, on desktop, right of the rail strip. Every float starts + and ends inside the band at zero opacity, so this clip never cuts a visible + one. It is a plain rectangular clip on purpose: the gradient mask it + replaces re-rendered the whole animated layer offscreen on every frame + (Gecko on a 144Hz screen: ~114% CPU idle on the library, ~39% without the + mask). Static under reduced motion: nothing moves there, and the parked + orbs keep their full-screen spots. */ .ice-floats { position: absolute; - inset: 0; + inset: 68px 0 172px; overflow: hidden; - -webkit-mask-image: linear-gradient( - to bottom, - transparent 0, - transparent 68px, - black 150px, - black calc(100% - 330px), - transparent calc(100% - 172px) - ); - mask-image: linear-gradient( - to bottom, - transparent 0, - transparent 68px, - black 150px, - black calc(100% - 330px), - transparent calc(100% - 172px) - ); + --ice-rise: calc(100% - 100vh + 240px); } @media (min-width: 640px) { .ice-floats { - -webkit-mask-image: linear-gradient( - to bottom, - transparent 0, - transparent 68px, - black 150px, - black calc(100% - 300px), - transparent calc(100% - 151px) - ); - mask-image: linear-gradient( - to bottom, - transparent 0, - transparent 68px, - black 150px, - black calc(100% - 300px), - transparent calc(100% - 151px) - ); + bottom: 151px; + --ice-rise: calc(100% - 100vh + 219px); } } @media (min-width: 1024px) { - /* …and the rail's strip (plus its gutter) stays clear too. */ - .ice-floats { - -webkit-mask-image: - linear-gradient( - to bottom, - transparent 0, - transparent 68px, - black 150px, - black calc(100% - 300px), - transparent calc(100% - 151px) - ), - linear-gradient(to right, transparent 0, transparent 260px, black 360px); - mask-image: - linear-gradient( - to bottom, - transparent 0, - transparent 68px, - black 150px, - black calc(100% - 300px), - transparent calc(100% - 151px) - ), - linear-gradient(to right, transparent 0, transparent 260px, black 360px); - -webkit-mask-composite: destination-in; - mask-composite: intersect; - } -} -@media (prefers-reduced-motion: reduce) { - .ice-floats { -webkit-mask-image: none !important; mask-image: none !important; } -} -html.reduce-motion .ice-floats { - -webkit-mask-image: none !important; - mask-image: none !important; -} - -/* Frosted grain over the whole scene. */ -.ice-grain { - position: absolute; - inset: 0; - opacity: var(--ice-grain-opacity); - mix-blend-mode: overlay; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E"); + /* …and the rail's strip plus its gutter (268px, see Sidebar). */ + .ice-floats { left: 268px; } } @media (prefers-reduced-motion: reduce) { - .ice-blob { animation: none; } + .ice-floats { inset: 0; } /* Bubbles sit still (and visible) instead of rising. */ - .aero .ice-bubble { animation: none; opacity: 0.55; transform: translate3d(0, -60vh, 0); } + .aero .ice-bubble { animation: none; opacity: 0.55; transform: translate3d(0, -46vh, 0); } /* 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. */ + applied at all in Chrome, which would leave them parked at the bottom of + the band) and give their composited layers back. */ .ice-orb { animation: none; will-change: auto; @@ -1114,15 +1048,19 @@ html.reduce-motion .ice-floats { .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 { +/* Park every scene animation while nobody is watching it, so the page costs + no compositor work: a background tab or a window without focus (IceBackdrop + sets .ice-paused), and any dialog or sheet over the page — its scrim + carries .scene-pause, and the blur of the scrim and the sheet would + otherwise re-run on every frame of the scene moving behind them. */ +.ice-paused :is(.ice-orb, .ice-bubble), +body:has(.scene-pause) .ice-scene :is(.ice-orb, .ice-bubble) { animation-play-state: paused; } - /* Phones: fewer, softer blobs so the drift animation stays cheap. Unlayered - on purpose — layered rules would lose to the .ice-blob declarations above. */ +/* Phones and tablets: fewer blobs and orbs, so the scene stays calm and + cheap. Unlayered on purpose — layered rules would lose to the .ice-blob + declarations above. */ @media (max-width: 1023px) { .ice-blob-c, .ice-blob-d, @@ -1183,13 +1121,16 @@ html.reduce-motion .ice-floats { 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. + - **Static glass** (cards, the rail, the gate, the study prompt card, and + everything that sits in the page flow: answer tiles, chips, inputs, + dropzones, grouped lists, segmented controls): 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; on a 144Hz MacBook screen the study + feed's answer tiles alone cost Chromium ~20% CPU. Nested glass (chips, inputs) never adds a second blur layer. Tailwind's backdrop utilities emit the -webkit- prefix too (hand-written @@ -1300,7 +1241,6 @@ html.reduce-motion .ice-floats { /* Inset grouped list ---------------------------------------------------- */ .list-group { border-radius: var(--radius-control); - @apply backdrop-blur-lg backdrop-saturate-150; background: color-mix(in oklab, var(--surface) 82%, transparent); border: 1px solid var(--hairline); overflow: hidden; @@ -1349,7 +1289,6 @@ html.reduce-motion .ice-floats { gap: 2px; padding: 3px; border-radius: var(--radius-control); - @apply backdrop-blur-sm; background: var(--surface-sunken); border: 1px solid var(--hairline); } @@ -1741,6 +1680,26 @@ html.reduce-motion .ice-floats { .app-shell-in { animation: none; } } +/* The loading screen's icon: a slow rock and swell for as long as a model + runs. CSS on the individual transform properties, so the compositor runs + it — the framer-motion loop it replaces rewrote the transform from the + main thread on every frame. */ +@keyframes loading-rock { + 0%, 100% { rotate: 0deg; } + 33.333% { rotate: 8deg; } + 66.667% { rotate: -8deg; } +} +@keyframes loading-swell { + 0%, 100% { scale: 1; } + 50% { scale: 1.08; } +} +.loading-bob { + animation: loading-rock 3.2s ease-in-out infinite, loading-swell 3.2s ease-in-out infinite; +} +@media (prefers-reduced-motion: reduce) { + .loading-bob { animation: none; } +} + /* Phones: a quick fade when the study feed takes over (the generation hand-off), so the result doesn't hard-cut in. */ @media (max-width: 639px) { @@ -1802,7 +1761,9 @@ html.reduce-motion .ice-floats { every CSS animation/transition collapses to nothing. framer-motion gets told separately (components/MotionPreference). --------------------------------------------------------------------------- */ -html.reduce-motion .ice-blob, +html.reduce-motion .ice-floats { + inset: 0; +} html.reduce-motion .ice-orb, html.reduce-motion .ice-bubble { animation: none !important; @@ -1810,7 +1771,7 @@ html.reduce-motion .ice-bubble { } html.reduce-motion.aero .ice-bubble { opacity: 0.55; - transform: translate3d(0, -60vh, 0); + transform: translate3d(0, -46vh, 0); } html.reduce-motion .ice-orb-1 { top: 14%; left: 12%; } html.reduce-motion .ice-orb-2 { top: 58%; left: 4%; } diff --git a/components/ApiKeyGate.tsx b/components/ApiKeyGate.tsx index 8c8f6bd..3be8a8d 100644 --- a/components/ApiKeyGate.tsx +++ b/components/ApiKeyGate.tsx @@ -411,7 +411,7 @@ function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) { {open ? (