diff --git a/AGENTS.md b/AGENTS.md index 434761f..decd690 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/heat-probe.mjs` (macOS), `tests/model-compare.mjs`. + `tests/bubble-perf.mjs`, `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 0589ef1..99ca516 100644 --- a/README.md +++ b/README.md @@ -217,18 +217,16 @@ 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 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). + 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). 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, 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) +- **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 - **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 @@ -1436,53 +1434,6 @@ 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 fc248dc..a1a4300 100644 --- a/app/globals.css +++ b/app/globals.css @@ -197,12 +197,14 @@ --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: @@ -263,6 +265,7 @@ --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, @@ -318,17 +321,14 @@ } /* Rising bubbles. Hidden entirely in the other themes, so they cost nothing - 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. */ + there; under reduced motion they sit still instead of rising. */ .ice-bubble { display: none; } .aero .ice-bubble { display: block; position: absolute; - bottom: 0; + bottom: -14vh; border-radius: 50%; opacity: 0; background: @@ -351,13 +351,16 @@ 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; } - 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; } + 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; } } .aero .ice-bubble-1 { width: 104px; height: 104px; left: 7%; --bubble-duration: 19s; --bubble-delay: -2s; --bubble-sway: 4vw; } @@ -381,14 +384,15 @@ /* 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 — the - shared frosted recipe below, not a live blur. */ + 2.5:1. A small glass chip fixes it without hiding the wallpaper. */ .aero .hint-line { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 0.75rem; - background-color: var(--glass-bg); + background: 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 @@ -450,7 +454,6 @@ .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. */ @@ -481,15 +484,6 @@ 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). */ @@ -686,15 +680,10 @@ .aero .chat-header, .aero .chat-empty, .aero .chat-composer-band, - .aero .segmented, - .aero .hint-line { + .aero .segmented { 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. */ @@ -910,9 +899,8 @@ 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, 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. */ +/* The fixed ice scene: navy/pale base, drifting glacier blobs, glossy ice + orbs and a whisper of grain. Purely decorative. */ .ice-scene { position: fixed; inset: 0; @@ -926,45 +914,61 @@ 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 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. */ + 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. */ } .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; } -/* 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 +@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 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; - bottom: 0; + top: 100%; border-radius: 50%; opacity: var(--ice-orb-opacity); will-change: transform; @@ -985,58 +989,120 @@ 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: --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. */ +/* 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; } - 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; } + 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; } } -/* 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. */ +/* 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. */ .ice-floats { position: absolute; - inset: 68px 0 172px; + inset: 0; overflow: hidden; - --ice-rise: calc(100% - 100vh + 240px); + -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) + ); } @media (min-width: 640px) { .ice-floats { - bottom: 151px; - --ice-rise: calc(100% - 100vh + 219px); + -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) + ); } } @media (min-width: 1024px) { - /* …and the rail's strip plus its gutter (268px, see Sidebar). */ - .ice-floats { left: 268px; } + /* …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"); } @media (prefers-reduced-motion: reduce) { - .ice-floats { inset: 0; } + .ice-blob { animation: none; } /* Bubbles sit still (and visible) instead of rising. */ - .aero .ice-bubble { animation: none; opacity: 0.55; transform: translate3d(0, -46vh, 0); } + .aero .ice-bubble { animation: none; opacity: 0.55; transform: translate3d(0, -60vh, 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 at the bottom of - the band) and give their composited layers back. */ + 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; @@ -1048,19 +1114,15 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { .ice-orb-5 { top: auto; bottom: 16%; left: 38%; } } -/* 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) { +/* 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 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. */ + /* Phones: fewer, softer blobs so the drift animation stays cheap. Unlayered + on purpose — layered rules would lose to the .ice-blob declarations above. */ @media (max-width: 1023px) { .ice-blob-c, .ice-blob-d, @@ -1121,16 +1183,13 @@ body:has(.scene-pause) .ice-scene :is(.ice-orb, .ice-bubble) { 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, 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. + - **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 @@ -1241,6 +1300,7 @@ body:has(.scene-pause) .ice-scene :is(.ice-orb, .ice-bubble) { /* 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; @@ -1289,6 +1349,7 @@ body:has(.scene-pause) .ice-scene :is(.ice-orb, .ice-bubble) { gap: 2px; padding: 3px; border-radius: var(--radius-control); + @apply backdrop-blur-sm; background: var(--surface-sunken); border: 1px solid var(--hairline); } @@ -1680,26 +1741,6 @@ body:has(.scene-pause) .ice-scene :is(.ice-orb, .ice-bubble) { .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) { @@ -1761,9 +1802,7 @@ body:has(.scene-pause) .ice-scene :is(.ice-orb, .ice-bubble) { every CSS animation/transition collapses to nothing. framer-motion gets told separately (components/MotionPreference). --------------------------------------------------------------------------- */ -html.reduce-motion .ice-floats { - inset: 0; -} +html.reduce-motion .ice-blob, html.reduce-motion .ice-orb, html.reduce-motion .ice-bubble { animation: none !important; @@ -1771,7 +1810,7 @@ html.reduce-motion .ice-bubble { } html.reduce-motion.aero .ice-bubble { opacity: 0.55; - transform: translate3d(0, -46vh, 0); + transform: translate3d(0, -60vh, 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 3be8a8d..8c8f6bd 100644 --- a/components/ApiKeyGate.tsx +++ b/components/ApiKeyGate.tsx @@ -411,7 +411,7 @@ function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) { {open ? (