The scene stops heating the MacBook #1
27 changed files with 613 additions and 249 deletions
|
|
@ -51,7 +51,7 @@ Production only ever runs a pushed, verified commit deployed by
|
|||
9. **The test suites live in `tests/`** (`npm run test:e2e`, filterable:
|
||||
`npm run test:e2e -- chat`). Add or extend a suite for every feature; a
|
||||
green suite set is part of "done". Diagnostics that are not gates:
|
||||
`tests/bubble-perf.mjs`, `tests/model-compare.mjs`.
|
||||
`tests/bubble-perf.mjs`, `tests/heat-probe.mjs` (macOS), `tests/model-compare.mjs`.
|
||||
10. **Keep the service worker honest**: bump `CACHE_VERSION` in `public/sw.js`
|
||||
for every user-visible deploy (the deploy itself does not do this).
|
||||
11. **Version the app once per task.** Bump `package.json` `version` with
|
||||
|
|
|
|||
61
README.md
61
README.md
|
|
@ -217,16 +217,18 @@ the saved notes) with the matching words highlighted green, and the feed chrome'
|
|||
their own bottom action bar instead of the tab bar, and the in-page segmented switch moves between
|
||||
the deck and homework wizards.
|
||||
- **Frosted ice glass, both themes**: light page `#eef5fd` (pale ice) with white glass panels;
|
||||
dark page `#0a1120` with `rgba(255,255,255,.06)` glass. Every large surface shares one recipe —
|
||||
translucent tint, 24px `backdrop-blur` (16px on phones), a bright 1px rim, an inner top highlight
|
||||
and a blue-tinted shadow — over a fixed ice scene (navy/pale base, drifting glacier blobs, glossy
|
||||
ice orbs and a whisper of grain).
|
||||
dark page `#0a1120` with `rgba(255,255,255,.06)` glass. Every surface shares one recipe —
|
||||
translucent tint, a bright 1px rim, an inner top highlight and a blue-tinted shadow, plus a real
|
||||
16px `backdrop-blur` (12px on phones) only on floating glass that content scrolls under — over a
|
||||
fixed ice scene (navy/pale base, soft glacier blobs and glossy ice orbs rising through the open
|
||||
band between the chrome).
|
||||
Text tiers are documented AA ratios (see below), and the cards fill the row with
|
||||
`repeat(auto-fill, minmax(280px, 1fr))` — no stranded cards, no dead space
|
||||
- Deck cards: title → muted metadata → neutral tags (difficulty carries the only colour:
|
||||
green/amber/red) → summary → a single **Study** button, with everything else in the `⋯` menu
|
||||
- **Frosted UI**: the sticky header, tab bar, sheets and panels use real `backdrop-filter` blur over
|
||||
the fixed ice scene (drifting glacier blobs, glossy orbs, a whisper of grain) — depth without noise
|
||||
- **Frosted UI**: the sticky header, tab bar, study chrome and sheets use real `backdrop-filter`
|
||||
blur over the fixed ice scene — depth without noise. Glass that sits still never blurs, and the
|
||||
scene parks whenever nobody is watching it (hidden tab, unfocused window, an open dialog)
|
||||
- **Pill language**: primary CTAs are near-black in light mode and white in dark mode; secondaries
|
||||
are outlined or ghost. Colour is reserved for meaning — activity tints (sky/lilac/mint/peach/rose),
|
||||
correct/wrong feedback, key status
|
||||
|
|
@ -1434,6 +1436,53 @@ Things I chose while building, with the reasoning:
|
|||
(measured: 268..1280 at a 1280 window) — a flat pane floating 40px inside the scene read as
|
||||
"cut off next to the sidebar".
|
||||
|
||||
98. **The MacBook ran hot on the scene, so the scene's per-frame bill came down.** "It has
|
||||
terribly high CPU usage and the MacBook gets really hot when I'm on it" — a fanless MacBook Air
|
||||
driving a 144Hz display. Measured on that machine with real headed engines (process CPU plus
|
||||
the per-process GPU time macOS keeps — `tests/heat-probe.mjs`), not frame times in a throttled
|
||||
software renderer: an idle Aero library cost LibreWolf (Gecko) ~110% CPU and ~230 ms of GPU
|
||||
per second, the study feed ~130% / ~320 ms, Chromium ~57% CPU and up to ~350 ms/s of GPU, and
|
||||
Safari ~220-250 ms/s of WindowServer GPU above the desktop. Each suspect was switched off on its
|
||||
own to price it:
|
||||
- **The gradient mask on `.ice-floats`** (from 91) re-rendered the whole animated layer
|
||||
offscreen on every frame — ~75 points of Gecko CPU by itself. The floats now rise through a
|
||||
plain rectangular band (68px below the top, 151px above the bottom — 172px on phones — and
|
||||
right of the 268px rail strip on desktop), and every orb and bubble starts at the band's
|
||||
bottom and ends at its top at zero opacity: the fades live in the keyframes (in by 18%, out
|
||||
from 90%; `--ice-rise` is the band height minus the float's own) and the 1.06 end scale is
|
||||
taken back by a 3% nudge, so no visible float ever touches the clip. The bubbles suite sweeps
|
||||
every float through 41 phases at 1280 and 390 and fails on any visible frame outside the band.
|
||||
- **The overlay grain** (`mix-blend-mode` over the animated layer) forced a backdrop readback
|
||||
per frame for a texture that measured a max 2-6/255 and mean ≤0.11/255 difference —
|
||||
invisible. Gone.
|
||||
- **The blob drift**: four viewport-sized layers moving a few vw over 44-60s at 0.045-0.1
|
||||
opacity turned every frame into a full-screen redraw (Chromium GPU 128 → 80 ms/s with them
|
||||
still). They are painted once now.
|
||||
- **Backdrop blurs on glass that sits still**: answer tiles, the feedback panel, chips, inputs,
|
||||
dropzones, grouped lists, segmented controls and Aero's hint chips re-blurred the moving
|
||||
scene every frame (the study feed's tiles alone: ~20% Chromium CPU, ~270 ms/s of GPU). Only
|
||||
floating glass that content scrolls under blurs now; the hint chips use the shared bake, and
|
||||
the baked Aero chat header/composer drop a live blur that never showed under the opaque bake
|
||||
(touch has no bake there and keeps it). Their bake is the glass on desktop, so 97's flat
|
||||
nested tiles still sit on the right backdrop.
|
||||
- **Nobody watching**: besides the hidden tab, the scene now parks while the window has no
|
||||
focus (another app in front, the browser on the other screen) and while any dialog or sheet
|
||||
is open — its scrim carries `.scene-pause`, so the blurred scrim and sheet render once.
|
||||
- The loading screen's rocking icon was a framer-motion loop writing the transform from the
|
||||
main thread every frame for the whole generation; it is a compositor-run CSS animation on
|
||||
`rotate`/`scale` now.
|
||||
|
||||
Same machine, focused and animating (before → after): LibreWolf library Aero 109% → 33% CPU
|
||||
(GPU 227 → 101 ms/s), study 134% → 34% (316 → 141), chat 130% → 42%, dark/light library ~94% →
|
||||
24-26%; Chromium's GPU time 2.5-4.4× lower (study Aero 354 → 80 ms/s) with 7-22 points less CPU;
|
||||
Safari's WindowServer GPU above the desktop −20-26% in Aero and about half in dark. Unfocused,
|
||||
or with a sheet open, the scene costs nothing. What remains is the price of the ambient motion
|
||||
itself — the orbs (23 floats in Aero) composited at the display's refresh rate, on the
|
||||
compositor only (Chromium's main thread: ~9ms busy per 4s); Settings → Reduce motion parks it
|
||||
entirely. The visuals are unchanged apart from where a bubble happens to be at a given moment,
|
||||
and the pixel-contrast scan lost a finding (the Aero study chip, 4.49:1). The suites also find
|
||||
Playwright's Chromium on macOS now (`~/Library/Caches/ms-playwright`).
|
||||
|
||||
97. **Nested tiles inside the real-blur chrome drop the baked backdrop.** "Frutiger aero theme —
|
||||
the attachment button looks completely different to the other ones." The composer band, the
|
||||
chat header and the study pager keep a real `backdrop-filter` (they float over scrolling
|
||||
|
|
|
|||
277
app/globals.css
277
app/globals.css
|
|
@ -197,14 +197,12 @@
|
|||
--ice-base-bottom: #e6f1fb;
|
||||
--ice-blob-opacity: 0.045;
|
||||
--ice-orb-opacity: 0.22;
|
||||
--ice-grain-opacity: 0.05;
|
||||
}
|
||||
.dark {
|
||||
--ice-base-top: #070b16;
|
||||
--ice-base-bottom: #0c1426;
|
||||
--ice-blob-opacity: 0.1;
|
||||
--ice-orb-opacity: 0.38;
|
||||
--ice-grain-opacity: 0.045;
|
||||
}
|
||||
|
||||
/* Frutiger Aero — the third theme (Settings → Theme). An underwater scene:
|
||||
|
|
@ -265,7 +263,6 @@
|
|||
--ice-base-bottom: #0a5490;
|
||||
--ice-blob-opacity: 0.5;
|
||||
--ice-orb-opacity: 0.6;
|
||||
--ice-grain-opacity: 0.02;
|
||||
}
|
||||
|
||||
/* The scene is the wallpaper itself (a Frutiger Aero scene made with Gemini,
|
||||
|
|
@ -321,14 +318,17 @@
|
|||
}
|
||||
|
||||
/* Rising bubbles. Hidden entirely in the other themes, so they cost nothing
|
||||
there; under reduced motion they sit still instead of rising. */
|
||||
there; under reduced motion they sit still instead of rising. Each one
|
||||
rises from the bottom of the open band to its top (see .ice-floats) and
|
||||
fades in and out inside it, so the band's clip never cuts a visible
|
||||
bubble. */
|
||||
.ice-bubble {
|
||||
display: none;
|
||||
}
|
||||
.aero .ice-bubble {
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: -14vh;
|
||||
bottom: 0;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
background:
|
||||
|
|
@ -351,16 +351,13 @@
|
|||
animation-delay: var(--bubble-delay, 0s);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.ice-paused .ice-bubble {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@keyframes aero-bubble-rise {
|
||||
0% { transform: translate3d(0, 0, 0) scale(0.85); opacity: 0; }
|
||||
6% { opacity: var(--bubble-opacity, 0.9); }
|
||||
50% { transform: translate3d(var(--bubble-sway, 3vw), -52vh, 0) scale(1); }
|
||||
94% { opacity: var(--bubble-opacity, 0.9); }
|
||||
100% { transform: translate3d(calc(var(--bubble-sway, 3vw) * -0.5), -104vh, 0) scale(1.06); opacity: 0; }
|
||||
18% { opacity: var(--bubble-opacity, 0.9); }
|
||||
50% { transform: translate3d(var(--bubble-sway, 3vw), calc(var(--ice-rise) * 0.5), 0) scale(1); }
|
||||
90% { opacity: var(--bubble-opacity, 0.9); }
|
||||
100% { transform: translate3d(calc(var(--bubble-sway, 3vw) * -0.5), calc(var(--ice-rise) + 3%), 0) scale(1.06); opacity: 0; }
|
||||
}
|
||||
|
||||
.aero .ice-bubble-1 { width: 104px; height: 104px; left: 7%; --bubble-duration: 19s; --bubble-delay: -2s; --bubble-sway: 4vw; }
|
||||
|
|
@ -384,15 +381,14 @@
|
|||
|
||||
/* Helper lines that sit straight on the wallpaper (the keyboard tip, the AI
|
||||
note) need their own surface in Aero: dark text on the grass measured
|
||||
2.5:1. A small glass chip fixes it without hiding the wallpaper. */
|
||||
2.5:1. A small glass chip fixes it without hiding the wallpaper — the
|
||||
shared frosted recipe below, not a live blur. */
|
||||
.aero .hint-line {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--glass-bg);
|
||||
background-color: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
/* Same trap as the buttons above: use the Tailwind utilities. */
|
||||
@apply backdrop-blur-md backdrop-saturate-150;
|
||||
}
|
||||
|
||||
/* Bright white rims on the study/homework segments (the frosted fill itself
|
||||
|
|
@ -454,6 +450,7 @@
|
|||
.aero .chat-empty,
|
||||
.aero .chat-composer-band,
|
||||
.aero .segmented,
|
||||
.aero .hint-line,
|
||||
/* Hand-rolled frost fills in the study, exam, chat and homework flows:
|
||||
white/xx utilities and the surface tokens — without this they render as
|
||||
flat white chips next to the glass. */
|
||||
|
|
@ -484,6 +481,15 @@
|
|||
background-repeat: no-repeat, no-repeat, no-repeat;
|
||||
}
|
||||
|
||||
/* The chat header and composer are floating glass (a live backdrop blur),
|
||||
but the bake above is opaque: the blur under it never shows, yet it still
|
||||
re-ran on every frame. The touch fallback below drops the bake, so it
|
||||
brings the blur back there. */
|
||||
.aero .chat-header,
|
||||
.aero .chat-composer-band {
|
||||
@apply backdrop-filter-none;
|
||||
}
|
||||
|
||||
/* The wizard's phone action bar: Aero's page token is the saturated water
|
||||
blue, which read as a heavy slab over the photo — give it the same thin
|
||||
glass as everything else (it already has a real backdrop blur). */
|
||||
|
|
@ -680,10 +686,15 @@
|
|||
.aero .chat-header,
|
||||
.aero .chat-empty,
|
||||
.aero .chat-composer-band,
|
||||
.aero .segmented {
|
||||
.aero .segmented,
|
||||
.aero .hint-line {
|
||||
background-image: none;
|
||||
background-color: rgb(255 255 255 / 0.32);
|
||||
}
|
||||
.aero .chat-header,
|
||||
.aero .chat-composer-band {
|
||||
@apply backdrop-blur-md;
|
||||
}
|
||||
/* Reading surfaces keep a touch more body than the chrome — long answers
|
||||
have to stay comfortable over grass and water — but they are still glass,
|
||||
not white panes. */
|
||||
|
|
@ -899,8 +910,9 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
|
|||
|
||||
/* The key gate rides on the shared ice scene (see .ice-scene). */
|
||||
|
||||
/* The fixed ice scene: navy/pale base, drifting glacier blobs, glossy ice
|
||||
orbs and a whisper of grain. Purely decorative. */
|
||||
/* The fixed ice scene: navy/pale base, soft glacier blobs and glossy ice
|
||||
orbs. Purely decorative. Only the orbs (and Aero's bubbles) move; the base
|
||||
and the blobs are painted once. */
|
||||
.ice-scene {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
|
@ -914,61 +926,45 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
|
|||
position: absolute;
|
||||
border-radius: 50%;
|
||||
opacity: var(--ice-blob-opacity);
|
||||
will-change: transform;
|
||||
/* No filter: blur() here on purpose. A radial gradient fading to
|
||||
transparent is already a soft blob, and blurring a huge layer that is
|
||||
also being animated forces a re-rasterisation every frame (measured
|
||||
150ms vs 33ms per frame in a software renderer). The falloff stops below
|
||||
keep the same softness. */
|
||||
transparent is already a soft blob, and blurring a huge layer forces a
|
||||
re-rasterisation (measured 150ms vs 33ms per frame in a software
|
||||
renderer). The falloff stops below keep the same softness.
|
||||
No drift either: a few vw over 44-60s never showed at this opacity, but
|
||||
four viewport-sized moving layers turned every frame into a full-screen
|
||||
redraw. */
|
||||
}
|
||||
.ice-blob-a {
|
||||
width: 52vw; height: 52vw; min-width: 24rem; min-height: 24rem;
|
||||
top: -14vh; left: -10vw;
|
||||
background: radial-gradient(circle, rgb(56 189 248 / 0.9) 0%, rgb(56 189 248 / 0.45) 38%, transparent 72%);
|
||||
animation: ice-drift-a 44s ease-in-out infinite;
|
||||
}
|
||||
.ice-blob-b {
|
||||
width: 46vw; height: 46vw; min-width: 22rem; min-height: 22rem;
|
||||
top: 4vh; right: -12vw;
|
||||
background: radial-gradient(circle, rgb(96 165 250 / 0.9) 0%, rgb(96 165 250 / 0.45) 38%, transparent 72%);
|
||||
animation: ice-drift-b 52s ease-in-out infinite;
|
||||
}
|
||||
.ice-blob-c {
|
||||
width: 48vw; height: 48vw; min-width: 22rem; min-height: 22rem;
|
||||
bottom: -16vh; left: 14vw;
|
||||
background: radial-gradient(circle, rgb(129 140 248 / 0.9) 0%, rgb(129 140 248 / 0.42) 38%, transparent 74%);
|
||||
animation: ice-drift-c 60s ease-in-out infinite;
|
||||
}
|
||||
.ice-blob-d {
|
||||
width: 34vw; height: 34vw; min-width: 16rem; min-height: 16rem;
|
||||
bottom: 2vh; right: 6vw;
|
||||
background: radial-gradient(circle, rgb(56 189 248 / 0.85) 0%, rgb(56 189 248 / 0.4) 38%, transparent 74%);
|
||||
animation: ice-drift-a 48s ease-in-out infinite reverse;
|
||||
}
|
||||
|
||||
@keyframes ice-drift-a {
|
||||
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
|
||||
50% { transform: translate3d(4vw, 3vh, 0) scale(1.08); }
|
||||
}
|
||||
@keyframes ice-drift-b {
|
||||
0%, 100% { transform: translate3d(0, 0, 0) scale(1.05); }
|
||||
50% { transform: translate3d(-5vw, 4vh, 0) scale(1); }
|
||||
}
|
||||
@keyframes ice-drift-c {
|
||||
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
|
||||
50% { transform: translate3d(3vw, -4vh, 0) scale(1.1); }
|
||||
}
|
||||
|
||||
/* Glossy ice orbs: slow bubbles that rise from just below the bottom edge to
|
||||
above the top edge, swaying gently and fading in/out near the edges so the
|
||||
loop never pops. Only transform + opacity animate, each orb on its own
|
||||
/* Glossy ice orbs: slow bubbles that rise through the open band (see
|
||||
.ice-floats), swaying gently and fading in/out at its edges so the loop
|
||||
never pops. Only transform + opacity animate, each orb on its own
|
||||
composited layer (will-change: transform): no filter/backdrop-filter, and no
|
||||
animated size, offsets or shadow. Per-orb duration, delay (negative, so the
|
||||
scene is already populated on load), horizontal position and sway keep them
|
||||
out of sync. */
|
||||
.ice-orb {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
bottom: 0;
|
||||
border-radius: 50%;
|
||||
opacity: var(--ice-orb-opacity);
|
||||
will-change: transform;
|
||||
|
|
@ -989,120 +985,58 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
|
|||
.ice-orb-4 { width: 52px; height: 52px; right: 28%; --ice-orb-duration: 64s; --ice-orb-delay: -52s; --ice-sway: -4vw; }
|
||||
.ice-orb-5 { width: 120px; height: 120px; left: 38%; --ice-orb-duration: 52s; --ice-orb-delay: -27s; --ice-sway: 3vw; }
|
||||
|
||||
/* The rise: -100vh - 100% clears the top edge (100% = the orb's own height);
|
||||
the sway crosses the path twice and the scale drifts slightly. Opacity
|
||||
reaches the token value by 12% and holds it until 88%, so the fades happen
|
||||
while the orb is still partly outside the scene. */
|
||||
/* The rise: --ice-rise lifts an orb from the bottom of the band until its
|
||||
top edge meets the band's top (100% = the orb's own height; the extra 3%
|
||||
at the end takes back what the 1.06 scale adds above it); the sway crosses
|
||||
the path twice and the scale drifts slightly. Opacity reaches the token
|
||||
value by 18% and holds it until 90%, so a visible orb never touches the
|
||||
band's edges. */
|
||||
@keyframes ice-rise {
|
||||
0% { transform: translate3d(0, 0, 0) scale(0.94); opacity: 0; }
|
||||
12% { opacity: var(--ice-orb-opacity); }
|
||||
32% { transform: translate3d(var(--ice-sway, 0px), calc(-32vh - 32%), 0) scale(0.97); }
|
||||
58% { transform: translate3d(calc(var(--ice-sway, 0px) * -0.55), calc(-58vh - 58%), 0) scale(1.02); }
|
||||
80% { transform: translate3d(calc(var(--ice-sway, 0px) * 0.4), calc(-80vh - 80%), 0) scale(1.04); }
|
||||
88% { opacity: var(--ice-orb-opacity); }
|
||||
100% { transform: translate3d(0, calc(-100vh - 100%), 0) scale(1.06); opacity: 0; }
|
||||
18% { opacity: var(--ice-orb-opacity); }
|
||||
32% { transform: translate3d(var(--ice-sway, 0px), calc(var(--ice-rise) * 0.32), 0) scale(0.97); }
|
||||
58% { transform: translate3d(calc(var(--ice-sway, 0px) * -0.55), calc(var(--ice-rise) * 0.58), 0) scale(1.02); }
|
||||
80% { transform: translate3d(calc(var(--ice-sway, 0px) * 0.4), calc(var(--ice-rise) * 0.8), 0) scale(1.04); }
|
||||
90% { opacity: var(--ice-orb-opacity); }
|
||||
100% { transform: translate3d(0, calc(var(--ice-rise) + 3%), 0) scale(1.06); opacity: 0; }
|
||||
}
|
||||
|
||||
/* The rising orbs and bubbles are clipped to the scene, but the floating
|
||||
glass panels (the rail, the chat header/composer, the tab bar) sit on top of
|
||||
it — a bubble reaching one of those edges looked sliced by a straight line.
|
||||
This layer keeps them inside the open middle band: they fade in below the
|
||||
top chrome, out above the bottom chrome (the values come from where the
|
||||
header and the composer actually end) and, on desktop, stay out of the rail
|
||||
strip. Static under reduced motion: nothing moves there, and the parked
|
||||
orbs keep their spots. */
|
||||
/* The open band the orbs and bubbles rise through. The floating glass
|
||||
panels (the rail, the chat header/composer, the tab bar) sit on top of the
|
||||
scene, and a bubble reaching one of their edges looked sliced by a straight
|
||||
line — so the floats stay clear of them: below the top chrome, above the
|
||||
bottom chrome (the values come from where the header and the composer
|
||||
actually end) and, on desktop, right of the rail strip. Every float starts
|
||||
and ends inside the band at zero opacity, so this clip never cuts a visible
|
||||
one. It is a plain rectangular clip on purpose: the gradient mask it
|
||||
replaces re-rendered the whole animated layer offscreen on every frame
|
||||
(Gecko on a 144Hz screen: ~114% CPU idle on the library, ~39% without the
|
||||
mask). Static under reduced motion: nothing moves there, and the parked
|
||||
orbs keep their full-screen spots. */
|
||||
.ice-floats {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
inset: 68px 0 172px;
|
||||
overflow: hidden;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
transparent 68px,
|
||||
black 150px,
|
||||
black calc(100% - 330px),
|
||||
transparent calc(100% - 172px)
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
transparent 68px,
|
||||
black 150px,
|
||||
black calc(100% - 330px),
|
||||
transparent calc(100% - 172px)
|
||||
);
|
||||
--ice-rise: calc(100% - 100vh + 240px);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.ice-floats {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
transparent 68px,
|
||||
black 150px,
|
||||
black calc(100% - 300px),
|
||||
transparent calc(100% - 151px)
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
transparent 68px,
|
||||
black 150px,
|
||||
black calc(100% - 300px),
|
||||
transparent calc(100% - 151px)
|
||||
);
|
||||
bottom: 151px;
|
||||
--ice-rise: calc(100% - 100vh + 219px);
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
/* …and the rail's strip (plus its gutter) stays clear too. */
|
||||
.ice-floats {
|
||||
-webkit-mask-image:
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
transparent 68px,
|
||||
black 150px,
|
||||
black calc(100% - 300px),
|
||||
transparent calc(100% - 151px)
|
||||
),
|
||||
linear-gradient(to right, transparent 0, transparent 260px, black 360px);
|
||||
mask-image:
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
transparent 0,
|
||||
transparent 68px,
|
||||
black 150px,
|
||||
black calc(100% - 300px),
|
||||
transparent calc(100% - 151px)
|
||||
),
|
||||
linear-gradient(to right, transparent 0, transparent 260px, black 360px);
|
||||
-webkit-mask-composite: destination-in;
|
||||
mask-composite: intersect;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ice-floats { -webkit-mask-image: none !important; mask-image: none !important; }
|
||||
}
|
||||
html.reduce-motion .ice-floats {
|
||||
-webkit-mask-image: none !important;
|
||||
mask-image: none !important;
|
||||
}
|
||||
|
||||
/* Frosted grain over the whole scene. */
|
||||
.ice-grain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: var(--ice-grain-opacity);
|
||||
mix-blend-mode: overlay;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
/* …and the rail's strip plus its gutter (268px, see Sidebar). */
|
||||
.ice-floats { left: 268px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ice-blob { animation: none; }
|
||||
.ice-floats { inset: 0; }
|
||||
/* Bubbles sit still (and visible) instead of rising. */
|
||||
.aero .ice-bubble { animation: none; opacity: 0.55; transform: translate3d(0, -60vh, 0); }
|
||||
.aero .ice-bubble { animation: none; opacity: 0.55; transform: translate3d(0, -46vh, 0); }
|
||||
/* Orbs go back to the pre-rise static layout (a paused animation is not
|
||||
applied at all in Chrome, which would leave them parked below the fold)
|
||||
and give their composited layers back. */
|
||||
applied at all in Chrome, which would leave them parked at the bottom of
|
||||
the band) and give their composited layers back. */
|
||||
.ice-orb {
|
||||
animation: none;
|
||||
will-change: auto;
|
||||
|
|
@ -1114,15 +1048,19 @@ html.reduce-motion .ice-floats {
|
|||
.ice-orb-5 { top: auto; bottom: 16%; left: 38%; }
|
||||
}
|
||||
|
||||
/* Background tab: park every scene animation, so an invisible page costs no
|
||||
compositor work. */
|
||||
.ice-paused .ice-orb,
|
||||
.ice-paused .ice-blob {
|
||||
/* Park every scene animation while nobody is watching it, so the page costs
|
||||
no compositor work: a background tab or a window without focus (IceBackdrop
|
||||
sets .ice-paused), and any dialog or sheet over the page — its scrim
|
||||
carries .scene-pause, and the blur of the scrim and the sheet would
|
||||
otherwise re-run on every frame of the scene moving behind them. */
|
||||
.ice-paused :is(.ice-orb, .ice-bubble),
|
||||
body:has(.scene-pause) .ice-scene :is(.ice-orb, .ice-bubble) {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
/* Phones: fewer, softer blobs so the drift animation stays cheap. Unlayered
|
||||
on purpose — layered rules would lose to the .ice-blob declarations above. */
|
||||
/* Phones and tablets: fewer blobs and orbs, so the scene stays calm and
|
||||
cheap. Unlayered on purpose — layered rules would lose to the .ice-blob
|
||||
declarations above. */
|
||||
@media (max-width: 1023px) {
|
||||
.ice-blob-c,
|
||||
.ice-blob-d,
|
||||
|
|
@ -1183,13 +1121,16 @@ html.reduce-motion .ice-floats {
|
|||
the tint below plus a backdrop blur — the blur is what reads as glass
|
||||
when content scrolls underneath (the app header, the study chrome, the
|
||||
tab bar, a sheet over the page).
|
||||
- **Static glass** (cards, the rail, the gate, the study prompt card): the
|
||||
exact same tint, rim, highlight and shadow, but **no** backdrop-filter.
|
||||
On a smooth gradient the blur is invisible — and because the ice scene
|
||||
animates, every blurred surface re-runs the filter on every frame.
|
||||
Measured on desktop (2x throttle, software renderer): p50 frame time
|
||||
66ms while the static surfaces blurred the scene, 33ms without them,
|
||||
17ms with the scene's animation also stopped.
|
||||
- **Static glass** (cards, the rail, the gate, the study prompt card, and
|
||||
everything that sits in the page flow: answer tiles, chips, inputs,
|
||||
dropzones, grouped lists, segmented controls): the exact same tint,
|
||||
rim, highlight and shadow, but **no** backdrop-filter. On a smooth
|
||||
gradient the blur is invisible — and because the ice scene animates,
|
||||
every blurred surface re-runs the filter on every frame. Measured on
|
||||
desktop (2x throttle, software renderer): p50 frame time 66ms while the
|
||||
static surfaces blurred the scene, 33ms without them, 17ms with the
|
||||
scene's animation also stopped; on a 144Hz MacBook screen the study
|
||||
feed's answer tiles alone cost Chromium ~20% CPU.
|
||||
|
||||
Nested glass (chips, inputs) never adds a second blur layer. Tailwind's
|
||||
backdrop utilities emit the -webkit- prefix too (hand-written
|
||||
|
|
@ -1300,7 +1241,6 @@ html.reduce-motion .ice-floats {
|
|||
/* Inset grouped list ---------------------------------------------------- */
|
||||
.list-group {
|
||||
border-radius: var(--radius-control);
|
||||
@apply backdrop-blur-lg backdrop-saturate-150;
|
||||
background: color-mix(in oklab, var(--surface) 82%, transparent);
|
||||
border: 1px solid var(--hairline);
|
||||
overflow: hidden;
|
||||
|
|
@ -1349,7 +1289,6 @@ html.reduce-motion .ice-floats {
|
|||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: var(--radius-control);
|
||||
@apply backdrop-blur-sm;
|
||||
background: var(--surface-sunken);
|
||||
border: 1px solid var(--hairline);
|
||||
}
|
||||
|
|
@ -1741,6 +1680,26 @@ html.reduce-motion .ice-floats {
|
|||
.app-shell-in { animation: none; }
|
||||
}
|
||||
|
||||
/* The loading screen's icon: a slow rock and swell for as long as a model
|
||||
runs. CSS on the individual transform properties, so the compositor runs
|
||||
it — the framer-motion loop it replaces rewrote the transform from the
|
||||
main thread on every frame. */
|
||||
@keyframes loading-rock {
|
||||
0%, 100% { rotate: 0deg; }
|
||||
33.333% { rotate: 8deg; }
|
||||
66.667% { rotate: -8deg; }
|
||||
}
|
||||
@keyframes loading-swell {
|
||||
0%, 100% { scale: 1; }
|
||||
50% { scale: 1.08; }
|
||||
}
|
||||
.loading-bob {
|
||||
animation: loading-rock 3.2s ease-in-out infinite, loading-swell 3.2s ease-in-out infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.loading-bob { animation: none; }
|
||||
}
|
||||
|
||||
/* Phones: a quick fade when the study feed takes over (the generation
|
||||
hand-off), so the result doesn't hard-cut in. */
|
||||
@media (max-width: 639px) {
|
||||
|
|
@ -1802,7 +1761,9 @@ html.reduce-motion .ice-floats {
|
|||
every CSS animation/transition collapses to nothing. framer-motion gets
|
||||
told separately (components/MotionPreference).
|
||||
--------------------------------------------------------------------------- */
|
||||
html.reduce-motion .ice-blob,
|
||||
html.reduce-motion .ice-floats {
|
||||
inset: 0;
|
||||
}
|
||||
html.reduce-motion .ice-orb,
|
||||
html.reduce-motion .ice-bubble {
|
||||
animation: none !important;
|
||||
|
|
@ -1810,7 +1771,7 @@ html.reduce-motion .ice-bubble {
|
|||
}
|
||||
html.reduce-motion.aero .ice-bubble {
|
||||
opacity: 0.55;
|
||||
transform: translate3d(0, -60vh, 0);
|
||||
transform: translate3d(0, -46vh, 0);
|
||||
}
|
||||
html.reduce-motion .ice-orb-1 { top: 14%; left: 12%; }
|
||||
html.reduce-motion .ice-orb-2 { top: 58%; left: 4%; }
|
||||
|
|
|
|||
|
|
@ -411,7 +411,7 @@ function HelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|||
<AnimatePresence>
|
||||
{open ? (
|
||||
<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 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
|
|
|
|||
|
|
@ -3,25 +3,32 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* The fixed ice scene behind every screen: a navy (or pale) base, four slow
|
||||
* drifting glacier blobs, glossy ice orbs that rise slowly and fade at the
|
||||
* edges, and a whisper of grain. Purely decorative — it never intercepts
|
||||
* pointer events, only transform/opacity animate (each on its own composited
|
||||
* layer), the rise stops under `prefers-reduced-motion`, and everything parks
|
||||
* while the tab is hidden so a background tab costs no compositor work.
|
||||
* The fixed ice scene behind every screen: a navy (or pale) base, four soft
|
||||
* glacier blobs and glossy ice orbs that rise slowly through the open band
|
||||
* and fade at its edges. Purely decorative — it never intercepts pointer
|
||||
* events, only transform/opacity animate (each on its own composited layer),
|
||||
* the rise stops under `prefers-reduced-motion`, and everything parks while
|
||||
* the tab is hidden or the window has lost focus, so a page nobody is
|
||||
* looking at costs no compositor work.
|
||||
*/
|
||||
export function IceBackdrop() {
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const apply = () => setHidden(document.hidden);
|
||||
const apply = () => setPaused(document.hidden || !document.hasFocus());
|
||||
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 (
|
||||
<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-b" />
|
||||
<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-18" />
|
||||
</div>
|
||||
<span className="ice-grain" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ export function InstallSheet() {
|
|||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
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
|
||||
role="dialog"
|
||||
|
|
|
|||
|
|
@ -587,7 +587,7 @@ export function LibraryScreen({ initialTab = "decks" }: { initialTab?: "decks" |
|
|||
type="button"
|
||||
aria-label={t("common.cancel")}
|
||||
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
|
||||
role="dialog"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useAppReducedMotion as useReducedMotion } from "@/lib/motion-pref";
|
|||
|
||||
import { Button, ProgressBar } from "@/components/ui";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* 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,
|
||||
sidebar and empty states. */}
|
||||
<motion.div
|
||||
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"
|
||||
>
|
||||
<div aria-hidden className={cn("relative size-28", !reduceMotion && "loading-bob")}>
|
||||
{/* 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" />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full max-w-md space-y-3">
|
||||
<motion.p
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ export function ResultsScreen({
|
|||
{missed.map((activity) => (
|
||||
<li
|
||||
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>
|
||||
{results[activity.id]?.userAnswer ? (
|
||||
|
|
@ -192,7 +192,7 @@ export function ResultsScreen({
|
|||
{unanswered.map((activity) => (
|
||||
<li
|
||||
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)}
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ export function SettingsDialog({
|
|||
<AnimatePresence>
|
||||
{open ? (
|
||||
<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 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
|
|
|
|||
|
|
@ -495,12 +495,12 @@ export function UploadScreen() {
|
|||
setDifficulty(option.value);
|
||||
}}
|
||||
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
|
||||
? "border-transparent"
|
||||
: selected
|
||||
? "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 ? (
|
||||
|
|
@ -776,7 +776,7 @@ export function UploadScreen() {
|
|||
autoComplete="off"
|
||||
autoCapitalize="sentences"
|
||||
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}
|
||||
|
||||
|
|
@ -922,7 +922,7 @@ export function UploadScreen() {
|
|||
onChange={(event) => setText(event.target.value)}
|
||||
rows={4}
|
||||
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 className="space-y-1.5">
|
||||
|
|
@ -942,7 +942,7 @@ export function UploadScreen() {
|
|||
autoComplete="off"
|
||||
autoCapitalize="sentences"
|
||||
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>
|
||||
|
|
@ -1149,7 +1149,7 @@ export function UploadScreen() {
|
|||
<button
|
||||
type="button"
|
||||
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">
|
||||
<SnowflakeIcon className="size-12" />
|
||||
|
|
@ -1189,7 +1189,7 @@ export function UploadScreen() {
|
|||
<button
|
||||
type="button"
|
||||
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")}
|
||||
>
|
||||
+
|
||||
|
|
@ -1204,7 +1204,7 @@ export function UploadScreen() {
|
|||
{documents.map((entry) => (
|
||||
<li
|
||||
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">
|
||||
{KIND_LABELS[entry.kind]}
|
||||
|
|
@ -1300,7 +1300,7 @@ export function UploadScreen() {
|
|||
onChange={(event) => setText(event.target.value)}
|
||||
rows={4}
|
||||
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>
|
||||
|
||||
|
|
@ -1313,7 +1313,7 @@ export function UploadScreen() {
|
|||
value={focus}
|
||||
onChange={(event) => setFocus(event.target.value)}
|
||||
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>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ export function FillBlank({ activity, active, language, onAnswered, onNext, isLa
|
|||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
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
|
||||
? "border-frost-300 focus:border-frost-500"
|
||||
: result
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export function Matching({ activity, language, onAnswered, onNext, isLast, nextL
|
|||
disabled={isMatched || done}
|
||||
onClick={() => setSelectedTerm(isSelected ? null : term.index)}
|
||||
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
|
||||
? "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
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export function Ordering({ activity, language, onAnswered, onNext, isLast, nextL
|
|||
<Reorder.Item
|
||||
key={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">
|
||||
{index + 1}
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export function ShortAnswer({ activity, active, language, onAnswered, onNext, is
|
|||
) : null}
|
||||
|
||||
{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" />
|
||||
{t("short.grading")}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export function ActivityHeader({
|
|||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
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,
|
||||
)}
|
||||
>
|
||||
|
|
@ -129,7 +129,7 @@ export function OptionTile({
|
|||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
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],
|
||||
!disabled && "active:scale-[0.99]",
|
||||
className,
|
||||
|
|
@ -179,7 +179,7 @@ export function FeedbackPanel({
|
|||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 32 }}
|
||||
className={cn(
|
||||
"rounded-3xl border p-4 shadow-lift backdrop-blur-xl",
|
||||
"rounded-3xl border p-4 shadow-lift",
|
||||
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-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",
|
||||
|
|
|
|||
|
|
@ -198,7 +198,7 @@ export function ContextPicker({
|
|||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
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")}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ export function HistorySheet({ open, onClose, ...props }: HistoryProps & { open:
|
|||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
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")}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ export function ModelPicker({
|
|||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
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")}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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="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 })}
|
||||
</span>
|
||||
</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="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")}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ export function HomeworkInput({
|
|||
autoComplete="off"
|
||||
autoCapitalize="sentences"
|
||||
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">
|
||||
|
|
@ -531,7 +531,7 @@ export function HomeworkInput({
|
|||
autoComplete="off"
|
||||
autoCapitalize="sentences"
|
||||
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}
|
||||
|
||||
|
|
@ -794,7 +794,7 @@ export function HomeworkInput({
|
|||
<button
|
||||
type="button"
|
||||
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">
|
||||
<PlusIcon className="size-10" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "freezmo",
|
||||
"version": "0.6.6",
|
||||
"version": "0.6.7",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*
|
||||
* 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 ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ reboot cannot wipe the harness.
|
|||
|
||||
- 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
|
||||
`~/.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`).
|
||||
|
||||
## Running
|
||||
|
|
@ -27,7 +28,7 @@ prints a per-suite summary at the end.
|
|||
| Suite | Covers |
|
||||
| --- | --- |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
|
@ -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,
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
/**
|
||||
* Ice-scene bubbles: the glossy orbs must rise from below the bottom edge to
|
||||
* above the top edge with sway + slight scale, fade at the edges, animate only
|
||||
* transform/opacity on their own composited layers, stay behind everything,
|
||||
* park under reduced motion and while the tab is hidden, and keep the
|
||||
* per-breakpoint count (5 desktop / 3 tablet / 2 phones).
|
||||
* Ice-scene bubbles: the glossy orbs (and Aero's bubbles) must rise through
|
||||
* the open band — clear of the header, the bottom chrome and the desktop
|
||||
* rail — with sway + slight scale, fade in and out inside it so no visible
|
||||
* float ever touches the band's clip, animate only transform/opacity on their
|
||||
* 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";
|
||||
|
||||
|
|
@ -24,16 +28,56 @@ async function orbAt(page, phase) {
|
|||
animation.currentTime = ((wanted % timing.duration) + timing.duration) % timing.duration;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const rect = orb.getBoundingClientRect();
|
||||
const band = document.querySelector(".ice-floats").getBoundingClientRect();
|
||||
return {
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
opacity: Number(getComputedStyle(orb).opacity),
|
||||
viewport: window.innerHeight,
|
||||
band: { top: band.top, bottom: band.bottom, left: band.left },
|
||||
};
|
||||
}, 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) ---------------------------------------- */
|
||||
{
|
||||
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: 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
|
||||
them out before the chrome bands, so none of them is sliced by a panel
|
||||
edge (see .ice-floats in globals.css). */
|
||||
/* The floats layer: orbs + bubbles live in one wrapper that is the open
|
||||
band between the chrome — a plain rectangular clip, never a mask (a mask
|
||||
re-renders the whole animated layer offscreen on every frame). */
|
||||
const floats = await page.evaluate(() => {
|
||||
const layer = document.querySelector(".ice-floats");
|
||||
const style = layer ? getComputedStyle(layer) : null;
|
||||
const rect = layer?.getBoundingClientRect();
|
||||
return {
|
||||
children: layer ? layer.children.length : 0,
|
||||
orbs: document.querySelectorAll(".ice-floats .ice-orb").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(
|
||||
"floats: orbs and bubbles ride one masked layer",
|
||||
"floats: orbs and bubbles ride one layer",
|
||||
floats.children === 23 && floats.orbs === 5 && floats.bubbles === 18,
|
||||
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(() => {
|
||||
for (const sheet of document.styleSheets) {
|
||||
|
|
@ -119,12 +175,43 @@ async function orbAt(page, phase) {
|
|||
const mid = await orbAt(page, 0.5);
|
||||
const upper = await orbAt(page, 0.8);
|
||||
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("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("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 }));
|
||||
await page.waitForTimeout(1400);
|
||||
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();
|
||||
}
|
||||
|
||||
/* 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 ----------------------------- */
|
||||
{
|
||||
const counts = {};
|
||||
|
|
@ -173,17 +284,21 @@ async function orbAt(page, phase) {
|
|||
const orb = document.querySelector(".ice-orb-1");
|
||||
const style = getComputedStyle(orb);
|
||||
const rect = orb.getBoundingClientRect();
|
||||
const floats = getComputedStyle(document.querySelector(".ice-floats"));
|
||||
const floats = document.querySelector(".ice-floats").getBoundingClientRect();
|
||||
return {
|
||||
animation: style.animationName,
|
||||
willChange: style.willChange,
|
||||
opacity: Number(style.opacity),
|
||||
top: rect.top,
|
||||
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: 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);
|
||||
|
|
@ -195,7 +310,7 @@ async function orbAt(page, phase) {
|
|||
await context.close();
|
||||
}
|
||||
|
||||
/* 4. tab hidden: everything parks ------------------------------------- */
|
||||
/* 4. tab hidden, window blurred, dialog open: everything parks -------- */
|
||||
{
|
||||
const { context, page } = await makePage(browser);
|
||||
await seed(page, { decks: [DECK] });
|
||||
|
|
@ -209,12 +324,10 @@ async function orbAt(page, phase) {
|
|||
const paused = await page.evaluate(() => ({
|
||||
scene: document.querySelector(".ice-scene").classList.contains("ice-paused"),
|
||||
orb: getComputedStyle(document.querySelector(".ice-orb-1")).animationPlayState,
|
||||
blob: getComputedStyle(document.querySelector(".ice-blob-a")).animationPlayState,
|
||||
transform: getComputedStyle(document.querySelector(".ice-orb-1")).transform,
|
||||
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: blob drift parked too", paused.blob === "paused", paused.blob);
|
||||
check("tab hidden: parked orb keeps its frame (no jump)", paused.transform !== "none", paused.transform);
|
||||
await page.waitForTimeout(500);
|
||||
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,
|
||||
}));
|
||||
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();
|
||||
}
|
||||
|
||||
/* 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();
|
||||
finish();
|
||||
|
|
|
|||
138
tests/heat-probe.mjs
Normal file
138
tests/heat-probe.mjs
Normal 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();
|
||||
}
|
||||
|
|
@ -30,16 +30,26 @@ export const SPAWN_CWD = process.env.FREEZMO_SPAWN_CWD || REPO;
|
|||
|
||||
export function resolveChrome() {
|
||||
if (process.env.CHROME && existsSync(process.env.CHROME)) return process.env.CHROME;
|
||||
const root = path.join(os.homedir(), ".cache", "ms-playwright");
|
||||
const dirs = existsSync(root)
|
||||
? 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 ["chrome-linux64", "chrome-linux"]) {
|
||||
const candidate = path.join(root, dir, sub, "chrome");
|
||||
if (existsSync(candidate)) return candidate;
|
||||
const roots = [
|
||||
path.join(os.homedir(), ".cache", "ms-playwright"),
|
||||
path.join(os.homedir(), "Library", "Caches", "ms-playwright"),
|
||||
];
|
||||
const binaries = [
|
||||
"chrome-linux64/chrome",
|
||||
"chrome-linux/chrome",
|
||||
"chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
||||
"chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
||||
"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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue