Compare commits

...

2 commits

Author SHA1 Message Date
9ed886372d Merge pull request 'The scene stops heating the MacBook' (#1) from skopcaak/scene-heat into main
Reviewed-on: #1
2026-10-15 18:12:26 +00:00
pepaskopec-blip
bfc0f806b9 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 <cursoragent@cursor.com>
2026-10-15 19:42:29 +02:00
27 changed files with 613 additions and 249 deletions

View file

@ -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: 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 `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: 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` 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). for every user-visible deploy (the deploy itself does not do this).
11. **Version the app once per task.** Bump `package.json` `version` with 11. **Version the app once per task.** Bump `package.json` `version` with

View file

@ -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 their own bottom action bar instead of the tab bar, and the in-page segmented switch moves between
the deck and homework wizards. the deck and homework wizards.
- **Frosted ice glass, both themes**: light page `#eef5fd` (pale ice) with white glass panels; - **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 — dark page `#0a1120` with `rgba(255,255,255,.06)` glass. Every surface shares one recipe —
translucent tint, 24px `backdrop-blur` (16px on phones), a bright 1px rim, an inner top highlight translucent tint, a bright 1px rim, an inner top highlight and a blue-tinted shadow, plus a real
and a blue-tinted shadow — over a fixed ice scene (navy/pale base, drifting glacier blobs, glossy 16px `backdrop-blur` (12px on phones) only on floating glass that content scrolls under — over a
ice orbs and a whisper of grain). 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 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 `repeat(auto-fill, minmax(280px, 1fr))` — no stranded cards, no dead space
- Deck cards: title → muted metadata → neutral tags (difficulty carries the only colour: - 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 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 - **Frosted UI**: the sticky header, tab bar, study chrome and sheets use real `backdrop-filter`
the fixed ice scene (drifting glacier blobs, glossy orbs, a whisper of grain) — depth without noise 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 - **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), are outlined or ghost. Colour is reserved for meaning — activity tints (sky/lilac/mint/peach/rose),
correct/wrong feedback, key status 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 (measured: 268..1280 at a 1280 window) — a flat pane floating 40px inside the scene read as
"cut off next to the sidebar". "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 — 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 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 chat header and the study pager keep a real `backdrop-filter` (they float over scrolling

View file

@ -197,14 +197,12 @@
--ice-base-bottom: #e6f1fb; --ice-base-bottom: #e6f1fb;
--ice-blob-opacity: 0.045; --ice-blob-opacity: 0.045;
--ice-orb-opacity: 0.22; --ice-orb-opacity: 0.22;
--ice-grain-opacity: 0.05;
} }
.dark { .dark {
--ice-base-top: #070b16; --ice-base-top: #070b16;
--ice-base-bottom: #0c1426; --ice-base-bottom: #0c1426;
--ice-blob-opacity: 0.1; --ice-blob-opacity: 0.1;
--ice-orb-opacity: 0.38; --ice-orb-opacity: 0.38;
--ice-grain-opacity: 0.045;
} }
/* Frutiger Aero — the third theme (Settings → Theme). An underwater scene: /* Frutiger Aero — the third theme (Settings → Theme). An underwater scene:
@ -265,7 +263,6 @@
--ice-base-bottom: #0a5490; --ice-base-bottom: #0a5490;
--ice-blob-opacity: 0.5; --ice-blob-opacity: 0.5;
--ice-orb-opacity: 0.6; --ice-orb-opacity: 0.6;
--ice-grain-opacity: 0.02;
} }
/* The scene is the wallpaper itself (a Frutiger Aero scene made with Gemini, /* 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 /* 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 { .ice-bubble {
display: none; display: none;
} }
.aero .ice-bubble { .aero .ice-bubble {
display: block; display: block;
position: absolute; position: absolute;
bottom: -14vh; bottom: 0;
border-radius: 50%; border-radius: 50%;
opacity: 0; opacity: 0;
background: background:
@ -351,16 +351,13 @@
animation-delay: var(--bubble-delay, 0s); animation-delay: var(--bubble-delay, 0s);
will-change: transform, opacity; will-change: transform, opacity;
} }
.ice-paused .ice-bubble {
animation-play-state: paused;
}
@keyframes aero-bubble-rise { @keyframes aero-bubble-rise {
0% { transform: translate3d(0, 0, 0) scale(0.85); opacity: 0; } 0% { transform: translate3d(0, 0, 0) scale(0.85); opacity: 0; }
6% { opacity: var(--bubble-opacity, 0.9); } 18% { opacity: var(--bubble-opacity, 0.9); }
50% { transform: translate3d(var(--bubble-sway, 3vw), -52vh, 0) scale(1); } 50% { transform: translate3d(var(--bubble-sway, 3vw), calc(var(--ice-rise) * 0.5), 0) scale(1); }
94% { opacity: var(--bubble-opacity, 0.9); } 90% { opacity: var(--bubble-opacity, 0.9); }
100% { transform: translate3d(calc(var(--bubble-sway, 3vw) * -0.5), -104vh, 0) scale(1.06); opacity: 0; } 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; } .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 /* 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 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 { .aero .hint-line {
display: inline-block; display: inline-block;
padding: 0.2rem 0.6rem; padding: 0.2rem 0.6rem;
border-radius: 0.75rem; border-radius: 0.75rem;
background: var(--glass-bg); background-color: var(--glass-bg);
border: 1px solid var(--glass-border); 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 /* Bright white rims on the study/homework segments (the frosted fill itself
@ -454,6 +450,7 @@
.aero .chat-empty, .aero .chat-empty,
.aero .chat-composer-band, .aero .chat-composer-band,
.aero .segmented, .aero .segmented,
.aero .hint-line,
/* Hand-rolled frost fills in the study, exam, chat and homework flows: /* Hand-rolled frost fills in the study, exam, chat and homework flows:
white/xx utilities and the surface tokens — without this they render as white/xx utilities and the surface tokens — without this they render as
flat white chips next to the glass. */ flat white chips next to the glass. */
@ -484,6 +481,15 @@
background-repeat: no-repeat, no-repeat, no-repeat; 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 /* 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 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). */ glass as everything else (it already has a real backdrop blur). */
@ -680,10 +686,15 @@
.aero .chat-header, .aero .chat-header,
.aero .chat-empty, .aero .chat-empty,
.aero .chat-composer-band, .aero .chat-composer-band,
.aero .segmented { .aero .segmented,
.aero .hint-line {
background-image: none; background-image: none;
background-color: rgb(255 255 255 / 0.32); 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 /* 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, have to stay comfortable over grass and water — but they are still glass,
not white panes. */ 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 key gate rides on the shared ice scene (see .ice-scene). */
/* The fixed ice scene: navy/pale base, drifting glacier blobs, glossy ice /* The fixed ice scene: navy/pale base, soft glacier blobs and glossy ice
orbs and a whisper of grain. Purely decorative. */ orbs. Purely decorative. Only the orbs (and Aero's bubbles) move; the base
and the blobs are painted once. */
.ice-scene { .ice-scene {
position: fixed; position: fixed;
inset: 0; inset: 0;
@ -914,61 +926,45 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
opacity: var(--ice-blob-opacity); opacity: var(--ice-blob-opacity);
will-change: transform;
/* No filter: blur() here on purpose. A radial gradient fading to /* No filter: blur() here on purpose. A radial gradient fading to
transparent is already a soft blob, and blurring a huge layer that is transparent is already a soft blob, and blurring a huge layer forces a
also being animated forces a re-rasterisation every frame (measured re-rasterisation (measured 150ms vs 33ms per frame in a software
150ms vs 33ms per frame in a software renderer). The falloff stops below renderer). The falloff stops below keep the same softness.
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 { .ice-blob-a {
width: 52vw; height: 52vw; min-width: 24rem; min-height: 24rem; width: 52vw; height: 52vw; min-width: 24rem; min-height: 24rem;
top: -14vh; left: -10vw; top: -14vh; left: -10vw;
background: radial-gradient(circle, rgb(56 189 248 / 0.9) 0%, rgb(56 189 248 / 0.45) 38%, transparent 72%); 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 { .ice-blob-b {
width: 46vw; height: 46vw; min-width: 22rem; min-height: 22rem; width: 46vw; height: 46vw; min-width: 22rem; min-height: 22rem;
top: 4vh; right: -12vw; top: 4vh; right: -12vw;
background: radial-gradient(circle, rgb(96 165 250 / 0.9) 0%, rgb(96 165 250 / 0.45) 38%, transparent 72%); 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 { .ice-blob-c {
width: 48vw; height: 48vw; min-width: 22rem; min-height: 22rem; width: 48vw; height: 48vw; min-width: 22rem; min-height: 22rem;
bottom: -16vh; left: 14vw; bottom: -16vh; left: 14vw;
background: radial-gradient(circle, rgb(129 140 248 / 0.9) 0%, rgb(129 140 248 / 0.42) 38%, transparent 74%); 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 { .ice-blob-d {
width: 34vw; height: 34vw; min-width: 16rem; min-height: 16rem; width: 34vw; height: 34vw; min-width: 16rem; min-height: 16rem;
bottom: 2vh; right: 6vw; bottom: 2vh; right: 6vw;
background: radial-gradient(circle, rgb(56 189 248 / 0.85) 0%, rgb(56 189 248 / 0.4) 38%, transparent 74%); 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 { /* Glossy ice orbs: slow bubbles that rise through the open band (see
0%, 100% { transform: translate3d(0, 0, 0) scale(1); } .ice-floats), swaying gently and fading in/out at its edges so the loop
50% { transform: translate3d(4vw, 3vh, 0) scale(1.08); } never pops. Only transform + opacity animate, each orb on its own
}
@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 composited layer (will-change: transform): no filter/backdrop-filter, and no
animated size, offsets or shadow. Per-orb duration, delay (negative, so the animated size, offsets or shadow. Per-orb duration, delay (negative, so the
scene is already populated on load), horizontal position and sway keep them scene is already populated on load), horizontal position and sway keep them
out of sync. */ out of sync. */
.ice-orb { .ice-orb {
position: absolute; position: absolute;
top: 100%; bottom: 0;
border-radius: 50%; border-radius: 50%;
opacity: var(--ice-orb-opacity); opacity: var(--ice-orb-opacity);
will-change: transform; 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-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; } .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 rise: --ice-rise lifts an orb from the bottom of the band until its
the sway crosses the path twice and the scale drifts slightly. Opacity top edge meets the band's top (100% = the orb's own height; the extra 3%
reaches the token value by 12% and holds it until 88%, so the fades happen at the end takes back what the 1.06 scale adds above it); the sway crosses
while the orb is still partly outside the scene. */ 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 { @keyframes ice-rise {
0% { transform: translate3d(0, 0, 0) scale(0.94); opacity: 0; } 0% { transform: translate3d(0, 0, 0) scale(0.94); opacity: 0; }
12% { opacity: var(--ice-orb-opacity); } 18% { opacity: var(--ice-orb-opacity); }
32% { transform: translate3d(var(--ice-sway, 0px), calc(-32vh - 32%), 0) scale(0.97); } 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(-58vh - 58%), 0) scale(1.02); } 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(-80vh - 80%), 0) scale(1.04); } 80% { transform: translate3d(calc(var(--ice-sway, 0px) * 0.4), calc(var(--ice-rise) * 0.8), 0) scale(1.04); }
88% { opacity: var(--ice-orb-opacity); } 90% { opacity: var(--ice-orb-opacity); }
100% { transform: translate3d(0, calc(-100vh - 100%), 0) scale(1.06); opacity: 0; } 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 /* The open band the orbs and bubbles rise through. The floating glass
glass panels (the rail, the chat header/composer, the tab bar) sit on top of panels (the rail, the chat header/composer, the tab bar) sit on top of the
it — a bubble reaching one of those edges looked sliced by a straight line. scene, and a bubble reaching one of their edges looked sliced by a straight
This layer keeps them inside the open middle band: they fade in below the line — so the floats stay clear of them: below the top chrome, above the
top chrome, out above the bottom chrome (the values come from where the bottom chrome (the values come from where the header and the composer
header and the composer actually end) and, on desktop, stay out of the rail actually end) and, on desktop, right of the rail strip. Every float starts
strip. Static under reduced motion: nothing moves there, and the parked and ends inside the band at zero opacity, so this clip never cuts a visible
orbs keep their spots. */ 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 { .ice-floats {
position: absolute; position: absolute;
inset: 0; inset: 68px 0 172px;
overflow: hidden; overflow: hidden;
-webkit-mask-image: linear-gradient( --ice-rise: calc(100% - 100vh + 240px);
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) { @media (min-width: 640px) {
.ice-floats { .ice-floats {
-webkit-mask-image: linear-gradient( bottom: 151px;
to bottom, --ice-rise: calc(100% - 100vh + 219px);
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) { @media (min-width: 1024px) {
/* …and the rail's strip (plus its gutter) stays clear too. */ /* …and the rail's strip plus its gutter (268px, see Sidebar). */
.ice-floats { .ice-floats { left: 268px; }
-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) { @media (prefers-reduced-motion: reduce) {
.ice-blob { animation: none; } .ice-floats { inset: 0; }
/* Bubbles sit still (and visible) instead of rising. */ /* 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 /* 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) applied at all in Chrome, which would leave them parked at the bottom of
and give their composited layers back. */ the band) and give their composited layers back. */
.ice-orb { .ice-orb {
animation: none; animation: none;
will-change: auto; will-change: auto;
@ -1114,15 +1048,19 @@ html.reduce-motion .ice-floats {
.ice-orb-5 { top: auto; bottom: 16%; left: 38%; } .ice-orb-5 { top: auto; bottom: 16%; left: 38%; }
} }
/* Background tab: park every scene animation, so an invisible page costs no /* Park every scene animation while nobody is watching it, so the page costs
compositor work. */ no compositor work: a background tab or a window without focus (IceBackdrop
.ice-paused .ice-orb, sets .ice-paused), and any dialog or sheet over the page — its scrim
.ice-paused .ice-blob { 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; animation-play-state: paused;
} }
/* Phones: fewer, softer blobs so the drift animation stays cheap. Unlayered /* Phones and tablets: fewer blobs and orbs, so the scene stays calm and
on purpose — layered rules would lose to the .ice-blob declarations above. */ cheap. Unlayered on purpose — layered rules would lose to the .ice-blob
declarations above. */
@media (max-width: 1023px) { @media (max-width: 1023px) {
.ice-blob-c, .ice-blob-c,
.ice-blob-d, .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 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 when content scrolls underneath (the app header, the study chrome, the
tab bar, a sheet over the page). tab bar, a sheet over the page).
- **Static glass** (cards, the rail, the gate, the study prompt card): the - **Static glass** (cards, the rail, the gate, the study prompt card, and
exact same tint, rim, highlight and shadow, but **no** backdrop-filter. everything that sits in the page flow: answer tiles, chips, inputs,
On a smooth gradient the blur is invisible — and because the ice scene dropzones, grouped lists, segmented controls): the exact same tint,
animates, every blurred surface re-runs the filter on every frame. rim, highlight and shadow, but **no** backdrop-filter. On a smooth
Measured on desktop (2x throttle, software renderer): p50 frame time gradient the blur is invisible — and because the ice scene animates,
66ms while the static surfaces blurred the scene, 33ms without them, every blurred surface re-runs the filter on every frame. Measured on
17ms with the scene's animation also stopped. 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 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
@ -1300,7 +1241,6 @@ html.reduce-motion .ice-floats {
/* Inset grouped list ---------------------------------------------------- */ /* Inset grouped list ---------------------------------------------------- */
.list-group { .list-group {
border-radius: var(--radius-control); border-radius: var(--radius-control);
@apply backdrop-blur-lg backdrop-saturate-150;
background: color-mix(in oklab, var(--surface) 82%, transparent); background: color-mix(in oklab, var(--surface) 82%, transparent);
border: 1px solid var(--hairline); border: 1px solid var(--hairline);
overflow: hidden; overflow: hidden;
@ -1349,7 +1289,6 @@ html.reduce-motion .ice-floats {
gap: 2px; gap: 2px;
padding: 3px; padding: 3px;
border-radius: var(--radius-control); border-radius: var(--radius-control);
@apply backdrop-blur-sm;
background: var(--surface-sunken); background: var(--surface-sunken);
border: 1px solid var(--hairline); border: 1px solid var(--hairline);
} }
@ -1741,6 +1680,26 @@ html.reduce-motion .ice-floats {
.app-shell-in { animation: none; } .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 /* Phones: a quick fade when the study feed takes over (the generation
hand-off), so the result doesn't hard-cut in. */ hand-off), so the result doesn't hard-cut in. */
@media (max-width: 639px) { @media (max-width: 639px) {
@ -1802,7 +1761,9 @@ html.reduce-motion .ice-floats {
every CSS animation/transition collapses to nothing. framer-motion gets every CSS animation/transition collapses to nothing. framer-motion gets
told separately (components/MotionPreference). 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-orb,
html.reduce-motion .ice-bubble { html.reduce-motion .ice-bubble {
animation: none !important; animation: none !important;
@ -1810,7 +1771,7 @@ html.reduce-motion .ice-bubble {
} }
html.reduce-motion.aero .ice-bubble { html.reduce-motion.aero .ice-bubble {
opacity: 0.55; 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-1 { top: 14%; left: 12%; }
html.reduce-motion .ice-orb-2 { top: 58%; left: 4%; } html.reduce-motion .ice-orb-2 { top: 58%; left: 4%; }

View file

@ -411,7 +411,7 @@ function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
<AnimatePresence> <AnimatePresence>
{open ? ( {open ? (
<motion.div <motion.div
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center sm:p-6" className="scene-pause fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center sm:p-6"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}

View file

@ -3,25 +3,32 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
/** /**
* The fixed ice scene behind every screen: a navy (or pale) base, four slow * The fixed ice scene behind every screen: a navy (or pale) base, four soft
* drifting glacier blobs, glossy ice orbs that rise slowly and fade at the * glacier blobs and glossy ice orbs that rise slowly through the open band
* edges, and a whisper of grain. Purely decorative — it never intercepts * and fade at its edges. Purely decorative — it never intercepts pointer
* pointer events, only transform/opacity animate (each on its own composited * events, only transform/opacity animate (each on its own composited layer),
* layer), the rise stops under `prefers-reduced-motion`, and everything parks * the rise stops under `prefers-reduced-motion`, and everything parks while
* while the tab is hidden so a background tab costs no compositor work. * the tab is hidden or the window has lost focus, so a page nobody is
* looking at costs no compositor work.
*/ */
export function IceBackdrop() { export function IceBackdrop() {
const [hidden, setHidden] = useState(false); const [paused, setPaused] = useState(false);
useEffect(() => { useEffect(() => {
const apply = () => setHidden(document.hidden); const apply = () => setPaused(document.hidden || !document.hasFocus());
apply(); apply();
document.addEventListener("visibilitychange", apply); document.addEventListener("visibilitychange", apply);
return () => document.removeEventListener("visibilitychange", apply); window.addEventListener("focus", apply);
window.addEventListener("blur", apply);
return () => {
document.removeEventListener("visibilitychange", apply);
window.removeEventListener("focus", apply);
window.removeEventListener("blur", apply);
};
}, []); }, []);
return ( return (
<div className={`ice-scene${hidden ? " ice-paused" : ""}`} aria-hidden> <div className={`ice-scene${paused ? " ice-paused" : ""}`} aria-hidden>
<span className="ice-blob ice-blob-a" /> <span className="ice-blob ice-blob-a" />
<span className="ice-blob ice-blob-b" /> <span className="ice-blob ice-blob-b" />
<span className="ice-blob ice-blob-c" /> <span className="ice-blob ice-blob-c" />
@ -53,7 +60,6 @@ export function IceBackdrop() {
<span className="ice-bubble ice-bubble-17" /> <span className="ice-bubble ice-bubble-17" />
<span className="ice-bubble ice-bubble-18" /> <span className="ice-bubble ice-bubble-18" />
</div> </div>
<span className="ice-grain" />
</div> </div>
); );
} }

View file

@ -233,7 +233,7 @@ export function InstallSheet() {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.25 }} transition={{ duration: 0.25 }}
className="absolute inset-0 bg-black/50 backdrop-blur-[4px]" className="scene-pause absolute inset-0 bg-black/50 backdrop-blur-[4px]"
/> />
<motion.div <motion.div
role="dialog" role="dialog"

View file

@ -587,7 +587,7 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
type="button" type="button"
aria-label={t("common.cancel")} aria-label={t("common.cancel")}
onClick={closeSheet} onClick={closeSheet}
className="absolute inset-0 bg-black/40 backdrop-blur-sm" className="scene-pause absolute inset-0 bg-black/40 backdrop-blur-sm"
/> />
<motion.div <motion.div
role="dialog" role="dialog"

View file

@ -6,6 +6,7 @@ import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
import { Button, ProgressBar } from "@/components/ui"; import { Button, ProgressBar } from "@/components/ui";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/utils";
/** /**
* The one loading screen for every model pipeline — deck/notes creation, * The one loading screen for every model pipeline — deck/notes creation,
@ -80,15 +81,10 @@ export function LoadingState({
> >
{/* The app icon, not the bare snowflake — same logo as the header, {/* The app icon, not the bare snowflake — same logo as the header,
sidebar and empty states. */} sidebar and empty states. */}
<motion.div <div aria-hidden className={cn("relative size-28", !reduceMotion && "loading-bob")}>
aria-hidden
animate={reduceMotion ? undefined : { rotate: [0, 8, -8, 0], scale: [1, 1.08, 1] }}
transition={{ duration: 3.2, repeat: Infinity, ease: "easeInOut" }}
className="relative size-28"
>
{/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */} {/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */}
<img src="/icons/icon-512.png" alt="" className="size-28 rounded-[1.75rem] shadow-lift" /> <img src="/icons/icon-512.png" alt="" className="size-28 rounded-[1.75rem] shadow-lift" />
</motion.div> </div>
<div className="relative w-full max-w-md space-y-3"> <div className="relative w-full max-w-md space-y-3">
<motion.p <motion.p

View file

@ -164,7 +164,7 @@ export function ResultsScreen({
{missed.map((activity) => ( {missed.map((activity) => (
<li <li
key={activity.id} key={activity.id}
className="rounded-2xl border border-white/50 bg-white/45 backdrop-blur-xl p-3 text-sm dark:border-white/10 dark:bg-white/5" className="rounded-2xl border border-white/50 bg-white/45 p-3 text-sm dark:border-white/10 dark:bg-white/5"
> >
<p className="font-semibold text-frost-950 dark:text-frost-50">{promptFor(activity)}</p> <p className="font-semibold text-frost-950 dark:text-frost-50">{promptFor(activity)}</p>
{results[activity.id]?.userAnswer ? ( {results[activity.id]?.userAnswer ? (
@ -192,7 +192,7 @@ export function ResultsScreen({
{unanswered.map((activity) => ( {unanswered.map((activity) => (
<li <li
key={activity.id} key={activity.id}
className="rounded-2xl border border-white/50 bg-white/45 p-3 text-sm font-semibold text-frost-950 backdrop-blur-xl dark:border-white/10 dark:bg-white/5 dark:text-frost-50" className="rounded-2xl border border-white/50 bg-white/45 p-3 text-sm font-semibold text-frost-950 dark:border-white/10 dark:bg-white/5 dark:text-frost-50"
> >
{promptFor(activity)} {promptFor(activity)}
</li> </li>

View file

@ -177,7 +177,7 @@ export function SettingsDialog({
<AnimatePresence> <AnimatePresence>
{open ? ( {open ? (
<motion.div <motion.div
className="fixed inset-0 z-50 flex items-end justify-center bg-frost-950/35 p-0 backdrop-blur-md sm:items-center sm:p-6" className="scene-pause fixed inset-0 z-50 flex items-end justify-center bg-frost-950/35 p-0 backdrop-blur-md sm:items-center sm:p-6"
initial={reduceMotion ? false : { opacity: 0 }} initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}

View file

@ -495,12 +495,12 @@ export function UploadScreen() {
setDifficulty(option.value); setDifficulty(option.value);
}} }}
className={cn( className={cn(
"relative flex flex-col items-center gap-1 overflow-hidden rounded-2xl border px-2 py-3 text-center backdrop-blur-xl transition-colors", "relative flex flex-col items-center gap-1 overflow-hidden rounded-2xl border px-2 py-3 text-center transition-colors",
selected && isPhone selected && isPhone
? "border-transparent" ? "border-transparent"
: selected : selected
? "border-accent-400 bg-accent-50/80 shadow-lift dark:bg-accent-500/20" ? "border-accent-400 bg-accent-50/80 shadow-lift dark:bg-accent-500/20"
: "border-white/60 bg-white/45 backdrop-blur-xl hover:bg-white/70 dark:border-white/12 dark:bg-white/[0.05]", : "border-white/60 bg-white/45 hover:bg-white/70 dark:border-white/12 dark:bg-white/[0.05]",
)} )}
> >
{selected && isPhone ? ( {selected && isPhone ? (
@ -776,7 +776,7 @@ export function UploadScreen() {
autoComplete="off" autoComplete="off"
autoCapitalize="sentences" autoCapitalize="sentences"
spellCheck spellCheck
className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50" className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/> />
) : null} ) : null}
@ -922,7 +922,7 @@ export function UploadScreen() {
onChange={(event) => setText(event.target.value)} onChange={(event) => setText(event.target.value)}
rows={4} rows={4}
placeholder={t("upload.pastedPlaceholder")} placeholder={t("upload.pastedPlaceholder")}
className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50" className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
@ -942,7 +942,7 @@ export function UploadScreen() {
autoComplete="off" autoComplete="off"
autoCapitalize="sentences" autoCapitalize="sentences"
spellCheck={false} spellCheck={false}
className="w-full rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50" className="w-full rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/> />
</div> </div>
</div> </div>
@ -1149,7 +1149,7 @@ export function UploadScreen() {
<button <button
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className="mt-4 flex w-full flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-white/70 bg-white/40 px-4 py-10 text-center backdrop-blur-xl transition hover:border-accent-300 hover:bg-white/65 dark:border-white/15 dark:bg-white/[0.05] dark:hover:bg-white/[0.09]" className="mt-4 flex w-full flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-white/70 bg-white/40 px-4 py-10 text-center transition hover:border-accent-300 hover:bg-white/65 dark:border-white/15 dark:bg-white/[0.05] dark:hover:bg-white/[0.09]"
> >
<span aria-hidden className="animate-float text-frost-500 dark:text-frost-200"> <span aria-hidden className="animate-float text-frost-500 dark:text-frost-200">
<SnowflakeIcon className="size-12" /> <SnowflakeIcon className="size-12" />
@ -1189,7 +1189,7 @@ export function UploadScreen() {
<button <button
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className="grid aspect-square w-full place-items-center rounded-2xl border-2 border-dashed border-white/70 text-2xl text-accent-500 backdrop-blur-xl transition hover:border-accent-300 hover:bg-white/60 dark:border-white/15 dark:text-accent-200" className="grid aspect-square w-full place-items-center rounded-2xl border-2 border-dashed border-white/70 text-2xl text-accent-500 transition hover:border-accent-300 hover:bg-white/60 dark:border-white/15 dark:text-accent-200"
aria-label={t("upload.addMorePhotos")} aria-label={t("upload.addMorePhotos")}
> >
+ +
@ -1204,7 +1204,7 @@ export function UploadScreen() {
{documents.map((entry) => ( {documents.map((entry) => (
<li <li
key={entry.id} key={entry.id}
className="flex items-start gap-3 rounded-2xl border border-white/60 bg-white/50 p-3 backdrop-blur-xl dark:border-white/12 dark:bg-white/[0.06]" className="flex items-start gap-3 rounded-2xl border border-white/60 bg-white/50 p-3 dark:border-white/12 dark:bg-white/[0.06]"
> >
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-frost-100 text-[10px] font-extrabold uppercase tracking-wide text-frost-700 dark:bg-white/10 dark:text-frost-100"> <span className="grid size-10 shrink-0 place-items-center rounded-xl bg-frost-100 text-[10px] font-extrabold uppercase tracking-wide text-frost-700 dark:bg-white/10 dark:text-frost-100">
{KIND_LABELS[entry.kind]} {KIND_LABELS[entry.kind]}
@ -1300,7 +1300,7 @@ export function UploadScreen() {
onChange={(event) => setText(event.target.value)} onChange={(event) => setText(event.target.value)}
rows={4} rows={4}
placeholder={t("upload.pastedPlaceholder")} placeholder={t("upload.pastedPlaceholder")}
className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50" className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/> />
</div> </div>
@ -1313,7 +1313,7 @@ export function UploadScreen() {
value={focus} value={focus}
onChange={(event) => setFocus(event.target.value)} onChange={(event) => setFocus(event.target.value)}
placeholder={t("upload.topicPlaceholder")} placeholder={t("upload.topicPlaceholder")}
className="w-full rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50" className="w-full rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/> />
</div> </div>
</section> </section>

View file

@ -130,7 +130,7 @@ export function FillBlank({ activity, active, language, onAnswered, onNext, isLa
autoCapitalize="off" autoCapitalize="off"
spellCheck={false} spellCheck={false}
className={cn( className={cn(
"mx-1 inline-block rounded-xl border-b-4 bg-white/70 px-2 py-0.5 text-center text-frost-950 outline-none backdrop-blur-lg placeholder:text-frost-300 dark:bg-white/10 dark:text-frost-50 dark:placeholder:text-white/30", "mx-1 inline-block rounded-xl border-b-4 bg-white/70 px-2 py-0.5 text-center text-frost-950 outline-none placeholder:text-frost-300 dark:bg-white/10 dark:text-frost-50 dark:placeholder:text-white/30",
result === null result === null
? "border-frost-300 focus:border-frost-500" ? "border-frost-300 focus:border-frost-500"
: result : result

View file

@ -94,7 +94,7 @@ export function Matching({ activity, language, onAnswered, onNext, isLast, nextL
disabled={isMatched || done} disabled={isMatched || done}
onClick={() => setSelectedTerm(isSelected ? null : term.index)} onClick={() => setSelectedTerm(isSelected ? null : term.index)}
className={cn( className={cn(
"min-h-16 rounded-2xl border px-3 py-2 text-left text-sm font-semibold backdrop-blur-xl transition-all", "min-h-16 rounded-2xl border px-3 py-2 text-left text-sm font-semibold transition-all",
isMatched isMatched
? "border-emerald-200/80 bg-emerald-50/85 text-emerald-800 dark:border-emerald-400/40 dark:bg-emerald-400/15 dark:text-emerald-100" ? "border-emerald-200/80 bg-emerald-50/85 text-emerald-800 dark:border-emerald-400/40 dark:bg-emerald-400/15 dark:text-emerald-100"
: isSelected : isSelected

View file

@ -92,7 +92,7 @@ export function Ordering({ activity, language, onAnswered, onNext, isLast, nextL
<Reorder.Item <Reorder.Item
key={item} key={item}
value={item} value={item}
className="flex cursor-grab items-center gap-2 rounded-2xl border border-white/60 bg-white/60 px-3 py-3 text-sm font-semibold text-frost-950 shadow-sm backdrop-blur-xl active:cursor-grabbing dark:border-white/12 dark:bg-white/[0.08] dark:text-frost-50" className="flex cursor-grab items-center gap-2 rounded-2xl border border-white/60 bg-white/60 px-3 py-3 text-sm font-semibold text-frost-950 shadow-sm active:cursor-grabbing dark:border-white/12 dark:bg-white/[0.08] dark:text-frost-50"
> >
<span className="grid size-7 shrink-0 place-items-center rounded-full bg-frost-100 text-xs font-extrabold text-frost-700 dark:bg-white/15 dark:text-frost-100"> <span className="grid size-7 shrink-0 place-items-center rounded-full bg-frost-100 text-xs font-extrabold text-frost-700 dark:bg-white/15 dark:text-frost-100">
{index + 1} {index + 1}

View file

@ -120,7 +120,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
) : null} ) : null}
{state.status === "grading" ? ( {state.status === "grading" ? (
<div className="flex items-center gap-3 rounded-2xl border border-white/60 bg-white/50 backdrop-blur-xl px-4 py-3 text-sm font-semibold text-frost-800 dark:border-white/15 dark:bg-white/10 dark:text-frost-100"> <div className="flex items-center gap-3 rounded-2xl border border-white/60 bg-white/50 px-4 py-3 text-sm font-semibold text-frost-800 dark:border-white/15 dark:bg-white/10 dark:text-frost-100">
<Spinner className="size-5" /> <Spinner className="size-5" />
{t("short.grading")} {t("short.grading")}
</div> </div>

View file

@ -55,7 +55,7 @@ export function ActivityHeader({
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span <span
className={cn( className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ring-1 ring-white/50 backdrop-blur-sm", "inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ring-1 ring-white/50",
meta.tint, meta.tint,
)} )}
> >
@ -129,7 +129,7 @@ export function OptionTile({
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className={cn( className={cn(
"flex w-full items-center gap-3 rounded-2xl border px-4 py-3.5 text-left text-[15px] font-medium backdrop-blur-xl transition-all duration-200 disabled:cursor-default", "flex w-full items-center gap-3 rounded-2xl border px-4 py-3.5 text-left text-[15px] font-medium transition-all duration-200 disabled:cursor-default",
states[state], states[state],
!disabled && "active:scale-[0.99]", !disabled && "active:scale-[0.99]",
className, className,
@ -179,7 +179,7 @@ export function FeedbackPanel({
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 380, damping: 32 }} transition={{ type: "spring", stiffness: 380, damping: 32 }}
className={cn( className={cn(
"rounded-3xl border p-4 shadow-lift backdrop-blur-xl", "rounded-3xl border p-4 shadow-lift",
correct correct
? "border-emerald-200/80 bg-gradient-to-b from-emerald-50/95 to-emerald-100/70 dark:border-emerald-400/30 dark:from-emerald-500/15 dark:to-emerald-500/5" ? "border-emerald-200/80 bg-gradient-to-b from-emerald-50/95 to-emerald-100/70 dark:border-emerald-400/30 dark:from-emerald-500/15 dark:to-emerald-500/5"
: "border-rose-200/80 bg-gradient-to-b from-rose-50/95 to-rose-100/70 dark:border-rose-400/30 dark:from-rose-500/15 dark:to-rose-500/5", : "border-rose-200/80 bg-gradient-to-b from-rose-50/95 to-rose-100/70 dark:border-rose-400/30 dark:from-rose-500/15 dark:to-rose-500/5",

View file

@ -198,7 +198,7 @@ export function ContextPicker({
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={reduceMotion ? { duration: 0 } : undefined} transition={reduceMotion ? { duration: 0 } : undefined}
className="absolute inset-0 bg-black/45" className="scene-pause absolute inset-0 bg-black/45"
aria-label={t("common.close")} aria-label={t("common.close")}
onClick={onClose} onClick={onClose}
/> />

View file

@ -184,7 +184,7 @@ export function HistorySheet({ open, onClose, ...props }: HistoryProps & { open:
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={reduceMotion ? { duration: 0 } : undefined} transition={reduceMotion ? { duration: 0 } : undefined}
className="absolute inset-0 bg-black/45" className="scene-pause absolute inset-0 bg-black/45"
aria-label={t("common.close")} aria-label={t("common.close")}
onClick={onClose} onClick={onClose}
/> />

View file

@ -147,7 +147,7 @@ export function ModelPicker({
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={reduceMotion ? { duration: 0 } : undefined} transition={reduceMotion ? { duration: 0 } : undefined}
className="absolute inset-0 bg-black/45" className="scene-pause absolute inset-0 bg-black/45"
aria-label={t("common.close")} aria-label={t("common.close")}
onClick={onClose} onClick={onClose}
/> />

View file

@ -189,7 +189,7 @@ export function HomeworkGuide({
> >
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center] [&>*]:shrink-0"> <div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center] [&>*]:shrink-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="tint-sky inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ring-1 ring-white/50 backdrop-blur-sm"> <span className="tint-sky inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ring-1 ring-white/50">
{t("homework.guide.stepOf", { n: stepIndex + 1, total: steps.length })} {t("homework.guide.stepOf", { n: stepIndex + 1, total: steps.length })}
</span> </span>
</div> </div>
@ -293,7 +293,7 @@ export function HomeworkGuide({
> >
<div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center] [&>*]:shrink-0"> <div className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-4 px-4 pb-24 pt-24 [justify-content:safe_center] [&>*]:shrink-0">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="tint-mint inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ring-1 ring-white/50 backdrop-blur-sm"> <span className="tint-mint inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ring-1 ring-white/50">
{t("homework.guide.solution")} {t("homework.guide.solution")}
</span> </span>
</div> </div>

View file

@ -204,7 +204,7 @@ export function HomeworkInput({
autoComplete="off" autoComplete="off"
autoCapitalize="sentences" autoCapitalize="sentences"
spellCheck spellCheck
className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50" className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/> />
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@ -531,7 +531,7 @@ export function HomeworkInput({
autoComplete="off" autoComplete="off"
autoCapitalize="sentences" autoCapitalize="sentences"
spellCheck spellCheck
className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none backdrop-blur-xl transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50" className="w-full resize-y rounded-2xl border border-white/60 bg-white/55 px-4 py-3 text-sm text-frost-950 outline-none transition placeholder:text-frost-400 focus:border-accent-400 dark:border-white/12 dark:bg-white/[0.07] dark:text-frost-50"
/> />
) : null} ) : null}
@ -794,7 +794,7 @@ export function HomeworkInput({
<button <button
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className="mt-4 flex w-full flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-white/70 bg-white/40 px-4 py-10 text-center backdrop-blur-xl transition hover:border-accent-300 hover:bg-white/65 dark:border-white/15 dark:bg-white/[0.05] dark:hover:bg-white/[0.09]" className="mt-4 flex w-full flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-white/70 bg-white/40 px-4 py-10 text-center transition hover:border-accent-300 hover:bg-white/65 dark:border-white/15 dark:bg-white/[0.05] dark:hover:bg-white/[0.09]"
> >
<span aria-hidden className="text-frost-500 dark:text-frost-200"> <span aria-hidden className="text-frost-500 dark:text-frost-200">
<PlusIcon className="size-10" /> <PlusIcon className="size-10" />

View file

@ -1,6 +1,6 @@
{ {
"name": "freezmo", "name": "freezmo",
"version": "0.6.6", "version": "0.6.7",
"private": true, "private": true,
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"scripts": { "scripts": {

View file

@ -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-v88"; const CACHE_VERSION = "freezmo-v89";
const SHELL_CACHE = `${CACHE_VERSION}-shell`; const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;

View file

@ -8,7 +8,8 @@ reboot cannot wipe the harness.
- The app is running on `http://127.0.0.1:3207` (`systemctl --user start freezmo`). - The app is running on `http://127.0.0.1:3207` (`systemctl --user start freezmo`).
- A Chromium build. The suites auto-detect the newest Playwright Chromium in - A Chromium build. The suites auto-detect the newest Playwright Chromium in
`~/.cache/ms-playwright/`; override with `CHROME=/path/to/chrome`. `~/.cache/ms-playwright/` (Linux) or `~/Library/Caches/ms-playwright/` (macOS);
override with `CHROME=/path/to/chrome`.
- `npm install` (dev deps: `playwright-core`, `axe-core`). - `npm install` (dev deps: `playwright-core`, `axe-core`).
## Running ## Running
@ -27,7 +28,7 @@ prints a per-suite summary at the end.
| Suite | Covers | | Suite | Covers |
| --- | --- | | --- | --- |
| `regression.mjs` | Library, deck card + menu, study feed to results, homework entry, desktop shell + settings/health panel. | | `regression.mjs` | Library, deck card + menu, study feed to results, homework entry, desktop shell + settings/health panel. |
| `bubbles-test.mjs` | Ice-scene bubbles: stacking/layout contract, keyframes only transform+opacity, rise/sway/scale/fade phases, per-breakpoint counts (5/3/2), phone pacing, reduced motion, tab-hidden parking. | | `bubbles-test.mjs` | Ice-scene bubbles: stacking/layout contract, the open band (a plain clip, no mask) and a 41-phase sweep that no visible orb or bubble ever touches its edges, keyframes only transform+opacity, rise/sway/scale/fade phases, per-breakpoint counts (5/3/2), phone pacing, reduced motion, parking (hidden tab, unfocused window, dialog scrim), and the cost contract (static blobs, no blend-mode grain, no blur on static glass, no hidden blur under Aero's bake). |
| `mobile-test.mjs` | Five-tab bar at 360/390 × EN/CS × dark/light × reduced motion (labels, targets, no hamburger/drawer), the library ⋯ sheet (Import JSON / New folder), the settings sheet (touch targets, key flow, drag-to-close, backdrop), axe contrast audits. | | `mobile-test.mjs` | Five-tab bar at 360/390 × EN/CS × dark/light × reduced motion (labels, targets, no hamburger/drawer), the library ⋯ sheet (Import JSON / New folder), the settings sheet (touch targets, key flow, drag-to-close, backdrop), axe contrast audits. |
| `wizard-test.mjs` | Deck 3-step flow and homework flow, flat action bar, in-page mode switch, the loading lock, abort-on-leave. | | `wizard-test.mjs` | Deck 3-step flow and homework flow, flat action bar, in-page mode switch, the loading lock, abort-on-leave. |
| `native-test.mjs` | Manifest/PWA head, offline banner + disabled generate, share target GET + POST, Back-gesture layers, undo toast, tab re-tap, haptics, draft restore. | | `native-test.mjs` | Manifest/PWA head, offline banner + disabled generate, share target GET + POST, Back-gesture layers, undo toast, tab re-tap, haptics, draft restore. |
@ -53,3 +54,10 @@ leak between suites. Scratch data for the VAPID test lives in `tests/.tmp/`
CDP performance trace (rAF frame timing, long tasks, main-thread busy time, CDP performance trace (rAF frame timing, long tasks, main-thread busy time,
style recalcs) on a 4× throttled phone profile and a 2× desktop profile. It style recalcs) on a 4× throttled phone profile and a 2× desktop profile. It
always exits 0; the numbers are for tuning the bubble count/speed. always exits 0; the numbers are for tuning the bubble count/speed.
`node tests/heat-probe.mjs [webkit|chromium]` (macOS only) measures what an
idle screen actually costs the machine: headed Playwright WebKit and Chromium
on the real display, the browser's CPU, the GPU time of its processes and of
WindowServer, per screen and theme, against an `about:blank` baseline and with
the scene parked. Install the engines once with
`npx playwright-core install webkit chromium` and keep the window in front.

View file

@ -1,9 +1,13 @@
/** /**
* Ice-scene bubbles: the glossy orbs must rise from below the bottom edge to * Ice-scene bubbles: the glossy orbs (and Aero's bubbles) must rise through
* above the top edge with sway + slight scale, fade at the edges, animate only * the open band — clear of the header, the bottom chrome and the desktop
* transform/opacity on their own composited layers, stay behind everything, * rail — with sway + slight scale, fade in and out inside it so no visible
* park under reduced motion and while the tab is hidden, and keep the * float ever touches the band's clip, animate only transform/opacity on their
* per-breakpoint count (5 desktop / 3 tablet / 2 phones). * 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"; import { BASE, DECK, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs";
@ -24,16 +28,56 @@ async function orbAt(page, phase) {
animation.currentTime = ((wanted % timing.duration) + timing.duration) % timing.duration; animation.currentTime = ((wanted % timing.duration) + timing.duration) % timing.duration;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const rect = orb.getBoundingClientRect(); const rect = orb.getBoundingClientRect();
const band = document.querySelector(".ice-floats").getBoundingClientRect();
return { return {
top: rect.top, top: rect.top,
bottom: rect.bottom,
left: rect.left, left: rect.left,
width: rect.width, width: rect.width,
opacity: Number(getComputedStyle(orb).opacity), opacity: Number(getComputedStyle(orb).opacity),
viewport: window.innerHeight, band: { top: band.top, bottom: band.bottom, left: band.left },
}; };
}, phase); }, 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) ---------------------------------------- */ /* 1. scene contract (desktop) ---------------------------------------- */
{ {
const { context, page } = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 }); const { context, page } = await makePage(browser, { width: 1280, height: 900, deviceScaleFactor: 1 });
@ -75,25 +119,37 @@ async function orbAt(page, phase) {
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: 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)); 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 masked wrapper that fades /* The floats layer: orbs + bubbles live in one wrapper that is the open
them out before the chrome bands, so none of them is sliced by a panel band between the chrome — a plain rectangular clip, never a mask (a mask
edge (see .ice-floats in globals.css). */ re-renders the whole animated layer offscreen on every frame). */
const floats = await page.evaluate(() => { const floats = await page.evaluate(() => {
const layer = document.querySelector(".ice-floats"); const layer = document.querySelector(".ice-floats");
const style = layer ? getComputedStyle(layer) : null; const style = layer ? getComputedStyle(layer) : null;
const rect = layer?.getBoundingClientRect();
return { return {
children: layer ? layer.children.length : 0, children: layer ? layer.children.length : 0,
orbs: document.querySelectorAll(".ice-floats .ice-orb").length, orbs: document.querySelectorAll(".ice-floats .ice-orb").length,
bubbles: document.querySelectorAll(".ice-floats .ice-bubble").length, bubbles: document.querySelectorAll(".ice-floats .ice-bubble").length,
mask: style ? style.maskImage || style.webkitMaskImage : "", 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( check(
"floats: orbs and bubbles ride one masked layer", "floats: orbs and bubbles ride one layer",
floats.children === 23 && floats.orbs === 5 && floats.bubbles === 18, floats.children === 23 && floats.orbs === 5 && floats.bubbles === 18,
JSON.stringify(floats), JSON.stringify(floats),
); );
check("floats: the chrome bands fade the layer out", floats.mask.includes("gradient"), floats.mask.slice(0, 60)); 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(() => { const keyframes = await page.evaluate(() => {
for (const sheet of document.styleSheets) { for (const sheet of document.styleSheets) {
@ -119,12 +175,43 @@ async function orbAt(page, phase) {
const mid = await orbAt(page, 0.5); const mid = await orbAt(page, 0.5);
const upper = await orbAt(page, 0.8); const upper = await orbAt(page, 0.8);
const end = await orbAt(page, 0.99); const end = await orbAt(page, 0.99);
check("rise: starts at the bottom edge, ends above the top edge", Math.abs(start.top - start.viewport) < 6 && end.top < -120, `start ${Math.round(start.top)}/${start.viewport}, end ${Math.round(end.top)}`); 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("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("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("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}`); 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 })); const layoutBefore = await page.evaluate(() => ({ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight }));
await page.waitForTimeout(1400); await page.waitForTimeout(1400);
const layoutAfter = await page.evaluate(() => ({ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight })); const layoutAfter = await page.evaluate(() => ({ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight }));
@ -138,6 +225,30 @@ async function orbAt(page, phase) {
await context.close(); 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 ----------------------------- */ /* 2. per-breakpoint counts + phone pacing ----------------------------- */
{ {
const counts = {}; const counts = {};
@ -173,17 +284,21 @@ async function orbAt(page, phase) {
const orb = document.querySelector(".ice-orb-1"); const orb = document.querySelector(".ice-orb-1");
const style = getComputedStyle(orb); const style = getComputedStyle(orb);
const rect = orb.getBoundingClientRect(); const rect = orb.getBoundingClientRect();
const floats = getComputedStyle(document.querySelector(".ice-floats")); const floats = document.querySelector(".ice-floats").getBoundingClientRect();
return { return {
animation: style.animationName, animation: style.animationName,
willChange: style.willChange, willChange: style.willChange,
opacity: Number(style.opacity), opacity: Number(style.opacity),
top: rect.top, top: rect.top,
left: rect.left, left: rect.left,
mask: floats.maskImage || floats.webkitMaskImage, floats: { top: floats.top, bottom: floats.bottom, viewport: window.innerHeight },
}; };
}); });
check("reduced motion: the floats mask is off (parked orbs keep their spots)", state.mask === "none", state.mask); 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: 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)); 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); await page.waitForTimeout(1100);
@ -195,7 +310,7 @@ async function orbAt(page, phase) {
await context.close(); await context.close();
} }
/* 4. tab hidden: everything parks ------------------------------------- */ /* 4. tab hidden, window blurred, dialog open: everything parks -------- */
{ {
const { context, page } = await makePage(browser); const { context, page } = await makePage(browser);
await seed(page, { decks: [DECK] }); await seed(page, { decks: [DECK] });
@ -209,12 +324,10 @@ async function orbAt(page, phase) {
const paused = await page.evaluate(() => ({ const paused = await page.evaluate(() => ({
scene: document.querySelector(".ice-scene").classList.contains("ice-paused"), scene: document.querySelector(".ice-scene").classList.contains("ice-paused"),
orb: getComputedStyle(document.querySelector(".ice-orb-1")).animationPlayState, orb: getComputedStyle(document.querySelector(".ice-orb-1")).animationPlayState,
blob: getComputedStyle(document.querySelector(".ice-blob-a")).animationPlayState,
transform: getComputedStyle(document.querySelector(".ice-orb-1")).transform, transform: getComputedStyle(document.querySelector(".ice-orb-1")).transform,
top: document.querySelector(".ice-orb-1").getBoundingClientRect().top, 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: scene class + orb parked", paused.scene && paused.orb === "paused", JSON.stringify(paused));
check("tab hidden: blob drift parked too", paused.blob === "paused", paused.blob);
check("tab hidden: parked orb keeps its frame (no jump)", paused.transform !== "none", paused.transform); check("tab hidden: parked orb keeps its frame (no jump)", paused.transform !== "none", paused.transform);
await page.waitForTimeout(500); await page.waitForTimeout(500);
const held = await page.evaluate(() => document.querySelector(".ice-orb-1").getBoundingClientRect().top); const held = await page.evaluate(() => document.querySelector(".ice-orb-1").getBoundingClientRect().top);
@ -229,8 +342,91 @@ async function orbAt(page, phase) {
orb: getComputedStyle(document.querySelector(".ice-orb-1")).animationPlayState, orb: getComputedStyle(document.querySelector(".ice-orb-1")).animationPlayState,
})); }));
check("tab visible again: rise resumes", !running.scene && running.orb === "running", JSON.stringify(running)); 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(); 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(); await browser.close();
finish(); finish();

138
tests/heat-probe.mjs Normal file
View file

@ -0,0 +1,138 @@
/**
* Diagnostic, NOT part of `npm run test:e2e`, macOS only: what an idle
* Freezmo screen costs the machine on its real display. Headed WebKit and
* Chromium (Playwright's builds) open each screen and sit still while the
* probe reads the browser's own CPU time and the GPU time of its processes
* and of WindowServer (macOS composites every window there, and Core
* Animation runs WebKit's CSS animations there), against an about:blank
* baseline and with the scene parked.
*
* npx playwright-core install webkit chromium # once
* node tests/heat-probe.mjs # both engines
* node tests/heat-probe.mjs webkit # one engine
*
* Unlike tests/bubble-perf.mjs (frame times in a throttled software renderer)
* this measures the cost itself, at the display's own refresh rate: on a
* 144Hz screen every running animation is composited 144 times a second.
* Keep the window in front while it runs (other apps add noise to the
* WindowServer column). Always exits 0.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { chromium, webkit } from "playwright-core";
import { BASE, CHROME, DECK, seed } from "./lib.mjs";
if (process.platform !== "darwin") {
console.log("heat-probe: macOS only (it reads ps and the IOKit GPU counters).");
process.exit(0);
}
const run = promisify(execFile);
const SETTLE_MS = 2500;
const SAMPLE_MS = 8000;
const ENGINES = {
webkit: { launch: () => webkit.launch({ headless: false }), match: "/ms-playwright/webkit-" },
chromium: { launch: () => chromium.launch({ headless: false, executablePath: CHROME }), match: CHROME.split("/Contents/")[0] },
};
const SCREENS = [
["library", "/"],
["study", `/deck/${DECK.id}`],
["chat", "/chat"],
];
function seconds(text) {
const [days, clock] = text.includes("-") ? text.split("-") : ["0", text];
return clock.split(":").reduce((total, part) => total * 60 + Number(part), 0) + Number(days) * 86400;
}
async function processes() {
const { stdout } = await run("ps", ["-axo", "pid=,time=,command="], { maxBuffer: 64 << 20 });
const map = new Map();
for (const line of stdout.split("\n")) {
const match = line.match(/^\s*(\d+)\s+(\S+)\s+(.*)$/);
if (match) map.set(Number(match[1]), { cpu: seconds(match[2]), command: match[3] });
}
return map;
}
/** Accumulated GPU time per process (ns) — the number Activity Monitor shows. */
async function gpuTimes() {
const { stdout } = await run("ioreg", ["-r", "-c", "AGXDeviceUserClient", "-l", "-w", "0"], {
maxBuffer: 256 << 20,
encoding: "latin1",
});
const map = new Map();
for (const block of stdout.split("+-o AGXDeviceUserClient")) {
const creator = block.match(/"IOUserClientCreator" = "pid (\d+),/);
if (!creator) continue;
let total = 0;
for (const match of block.matchAll(/"accumulatedGPUTime"=(\d+)/g)) total += Number(match[1]);
map.set(Number(creator[1]), (map.get(Number(creator[1])) ?? 0) + total);
}
return map;
}
async function measure(match) {
const [cpu0, gpu0] = [await processes(), await gpuTimes()];
const start = Date.now();
await new Promise((resolve) => setTimeout(resolve, SAMPLE_MS));
const [cpu1, gpu1] = [await processes(), await gpuTimes()];
const elapsed = (Date.now() - start) / 1000;
const owner = (pid) => {
const command = cpu1.get(pid)?.command ?? "";
return command.includes(match) ? "browser" : command.includes("WindowServer") ? "windowServer" : null;
};
const total = { browserCpu: 0, browserGpu: 0, windowServerGpu: 0 };
for (const [pid, after] of cpu1) {
const before = cpu0.get(pid);
if (before && owner(pid) === "browser") total.browserCpu += after.cpu - before.cpu;
}
for (const [pid, after] of gpu1) {
const before = gpu0.get(pid);
if (before === undefined) continue;
if (owner(pid) === "browser") total.browserGpu += (after - before) / 1e6;
if (owner(pid) === "windowServer") total.windowServerGpu += (after - before) / 1e6;
}
return {
cpu: (total.browserCpu / elapsed) * 100,
gpu: total.browserGpu / elapsed,
windowServer: total.windowServerGpu / elapsed,
};
}
const wanted = process.argv.slice(2);
for (const [name, engine] of Object.entries(ENGINES)) {
if (wanted.length && !wanted.includes(name)) continue;
const browser = await engine.launch();
const runs = [["baseline", null, null, false]];
for (const theme of ["aero", "dark"]) {
for (const [screen, path] of SCREENS) runs.push([`${screen} ${theme}`, path, theme, false]);
runs.push([`library ${theme}, parked`, "/", theme, true]);
}
console.log(`\n${name} — browser CPU %, browser GPU ms/s, WindowServer GPU ms/s (${SAMPLE_MS / 1000}s each)`);
for (const [label, path, theme, parked] of runs) {
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } });
const page = await context.newPage();
if (path) {
await seed(page, { decks: [DECK], theme });
await page.goto(BASE + path, { waitUntil: "load" });
} else {
await page.goto("about:blank");
}
await page.bringToFront();
if (parked) {
await page.evaluate(() => {
document.hasFocus = () => false;
window.dispatchEvent(new Event("blur"));
});
}
await page.waitForTimeout(SETTLE_MS);
const cost = await measure(engine.match);
console.log(
` ${label.padEnd(24)} CPU ${cost.cpu.toFixed(1).padStart(5)}% GPU ${cost.gpu.toFixed(0).padStart(4)} WindowServer ${cost.windowServer.toFixed(0).padStart(4)}`,
);
await context.close();
}
await browser.close();
}

View file

@ -30,16 +30,26 @@ export const SPAWN_CWD = process.env.FREEZMO_SPAWN_CWD || REPO;
export function resolveChrome() { export function resolveChrome() {
if (process.env.CHROME && existsSync(process.env.CHROME)) return process.env.CHROME; if (process.env.CHROME && existsSync(process.env.CHROME)) return process.env.CHROME;
const root = path.join(os.homedir(), ".cache", "ms-playwright"); const roots = [
const dirs = existsSync(root) path.join(os.homedir(), ".cache", "ms-playwright"),
? readdirSync(root) path.join(os.homedir(), "Library", "Caches", "ms-playwright"),
.filter((entry) => /^chromium-\d+$/.test(entry)) ];
.sort((a, b) => Number(b.split("-")[1]) - Number(a.split("-")[1])) const binaries = [
: []; "chrome-linux64/chrome",
for (const dir of dirs) { "chrome-linux/chrome",
for (const sub of ["chrome-linux64", "chrome-linux"]) { "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
const candidate = path.join(root, dir, sub, "chrome"); "chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
if (existsSync(candidate)) return candidate; "chrome-mac/Chromium.app/Contents/MacOS/Chromium",
];
for (const root of roots.filter((dir) => existsSync(dir))) {
const dirs = readdirSync(root)
.filter((entry) => /^chromium-\d+$/.test(entry))
.sort((a, b) => Number(b.split("-")[1]) - Number(a.split("-")[1]));
for (const dir of dirs) {
for (const sub of binaries) {
const candidate = path.join(root, dir, sub);
if (existsSync(candidate)) return candidate;
}
} }
} }
throw new Error( throw new Error(