Merge pull request 'The scene stops heating the MacBook' (#1) from skopcaak/scene-heat into main
Reviewed-on: #1
This commit is contained in:
commit
9ed886372d
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:
|
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
|
||||||
|
|
|
||||||
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
|
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
|
||||||
|
|
|
||||||
277
app/globals.css
277
app/globals.css
|
|
@ -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%; }
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
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,18 +30,28 @@ 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"),
|
||||||
|
];
|
||||||
|
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))
|
.filter((entry) => /^chromium-\d+$/.test(entry))
|
||||||
.sort((a, b) => Number(b.split("-")[1]) - Number(a.split("-")[1]))
|
.sort((a, b) => Number(b.split("-")[1]) - Number(a.split("-")[1]));
|
||||||
: [];
|
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
for (const sub of ["chrome-linux64", "chrome-linux"]) {
|
for (const sub of binaries) {
|
||||||
const candidate = path.join(root, dir, sub, "chrome");
|
const candidate = path.join(root, dir, sub);
|
||||||
if (existsSync(candidate)) return candidate;
|
if (existsSync(candidate)) return candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"No Chromium found. Set CHROME=/path/to/chrome, or install one with `npx playwright install chromium`.",
|
"No Chromium found. Set CHROME=/path/to/chrome, or install one with `npx playwright install chromium`.",
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue