Ice scene: the glossy orbs become rising bubbles, ~38% more transparent

- Orbs rise from below the bottom edge to above the top edge, each with
  its own 36-68s pass (slower on phones), a negative delay (scene already
  populated on load), own horizontal position, a gentle sway and a slight
  scale drift; opacity fades in/out at 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, nothing animates
  size/offsets/shadow. The glacier blobs are untouched.
- Opacity lowered ~38% in both themes: 0.55->0.34 light, 0.8->0.5 dark,
  phone overrides 0.32->0.2 / 0.42->0.26.
- prefers-reduced-motion: rise removed, orbs return to the old static
  placements (a paused-from-start CSS animation is not applied in Chrome
  and would park them below the fold); tab hidden: a visibility listener
  parks the whole scene (.ice-paused), blob drift included.
- Measured with a CDP perf trace on a 4x throttled phone profile: p95
  33.4ms, 0 frames over 34ms, no long tasks - identical to the pre-change
  baseline; ~0.6% extra main-thread CPU. Desktop stays compositor-bound
  from the pre-existing blob drift, as before. tests/bubble-perf.mjs
  keeps it repeatable.
- New bubbles suite (34 checks) in the repo harness; the axe contrast
  matrix is now 16 runs (360/390/1280 x dark/light x EN/CS) and passes.
- SW cache -> freezmo-v31; README decision 46.

Full suite: 11/11 suites, 424 checks.
This commit is contained in:
Martin Dvořák 2026-10-11 15:50:17 +00:00
commit 6b6a6ea158
9 changed files with 419 additions and 31 deletions

View file

@ -178,14 +178,14 @@
--ice-base-top: #f4f9ff;
--ice-base-bottom: #e6f1fb;
--ice-blob-opacity: 0.18;
--ice-orb-opacity: 0.55;
--ice-orb-opacity: 0.34;
--ice-grain-opacity: 0.05;
}
.dark {
--ice-base-top: #070b16;
--ice-base-bottom: #0c1426;
--ice-blob-opacity: 0.4;
--ice-orb-opacity: 0.8;
--ice-orb-opacity: 0.5;
--ice-grain-opacity: 0.045;
}
@ -279,11 +279,21 @@ body {
50% { transform: translate3d(3vw, -4vh, 0) scale(1.1); }
}
/* Glossy ice orbs, placed so the glass panels blur them. */
/* Glossy ice orbs: slow bubbles that rise from just below the bottom edge to
above the top edge, swaying gently and fading in/out near the edges so the
loop never pops. Only transform + opacity animate, each orb on its own
composited layer (will-change: transform): no filter/backdrop-filter, and no
animated size, offsets or shadow. Per-orb duration, delay (negative, so the
scene is already populated on load), horizontal position and sway keep them
out of sync. */
.ice-orb {
position: absolute;
top: 100%;
border-radius: 50%;
opacity: var(--ice-orb-opacity);
will-change: transform;
animation: ice-rise var(--ice-orb-duration, 48s) ease-in-out infinite;
animation-delay: var(--ice-orb-delay, 0s);
background: radial-gradient(
circle at 30% 25%,
rgb(255 255 255 / 0.95) 0%,
@ -293,11 +303,25 @@ body {
);
box-shadow: 0 18px 40px -18px rgb(30 64 175 / 0.55), inset 0 -8px 18px -10px rgb(30 64 175 / 0.6);
}
.ice-orb-1 { width: 190px; height: 190px; top: 14%; left: 12%; }
.ice-orb-2 { width: 84px; height: 84px; top: 58%; left: 4%; }
.ice-orb-3 { width: 220px; height: 220px; bottom: -70px; right: 10%; }
.ice-orb-4 { width: 52px; height: 52px; top: 24%; right: 28%; }
.ice-orb-5 { width: 120px; height: 120px; bottom: 16%; left: 38%; }
.ice-orb-1 { width: 190px; height: 190px; left: 12%; --ice-orb-duration: 46s; --ice-orb-delay: -18s; --ice-sway: 4vw; }
.ice-orb-2 { width: 84px; height: 84px; left: 4%; --ice-orb-duration: 58s; --ice-orb-delay: -41s; --ice-sway: -3vw; }
.ice-orb-3 { width: 220px; height: 220px; right: 10%; --ice-orb-duration: 36s; --ice-orb-delay: -7s; --ice-sway: 5vw; }
.ice-orb-4 { width: 52px; height: 52px; right: 28%; --ice-orb-duration: 64s; --ice-orb-delay: -52s; --ice-sway: -4vw; }
.ice-orb-5 { width: 120px; height: 120px; left: 38%; --ice-orb-duration: 52s; --ice-orb-delay: -27s; --ice-sway: 3vw; }
/* The rise: -100vh - 100% clears the top edge (100% = the orb's own height);
the sway crosses the path twice and the scale drifts slightly. Opacity
reaches the token value by 12% and holds it until 88%, so the fades happen
while the orb is still partly outside the scene. */
@keyframes ice-rise {
0% { transform: translate3d(0, 0, 0) scale(0.94); opacity: 0; }
12% { opacity: var(--ice-orb-opacity); }
32% { transform: translate3d(var(--ice-sway, 0px), calc(-32vh - 32%), 0) scale(0.97); }
58% { transform: translate3d(calc(var(--ice-sway, 0px) * -0.55), calc(-58vh - 58%), 0) scale(1.02); }
80% { transform: translate3d(calc(var(--ice-sway, 0px) * 0.4), calc(-80vh - 80%), 0) scale(1.04); }
88% { opacity: var(--ice-orb-opacity); }
100% { transform: translate3d(0, calc(-100vh - 100%), 0) scale(1.06); opacity: 0; }
}
/* Frosted grain over the whole scene. */
.ice-grain {
@ -310,6 +334,25 @@ body {
@media (prefers-reduced-motion: reduce) {
.ice-blob { animation: none; }
/* Orbs go back to the pre-rise static layout (a paused animation is not
applied at all in Chrome, which would leave them parked below the fold)
and give their composited layers back. */
.ice-orb {
animation: none;
will-change: auto;
}
.ice-orb-1 { top: 14%; left: 12%; }
.ice-orb-2 { top: 58%; left: 4%; }
.ice-orb-3 { top: auto; bottom: -70px; right: 10%; }
.ice-orb-4 { top: 24%; right: 28%; }
.ice-orb-5 { top: auto; bottom: 16%; left: 38%; }
}
/* Background tab: park every scene animation, so an invisible page costs no
compositor work. */
.ice-paused .ice-orb,
.ice-paused .ice-blob {
animation-play-state: paused;
}
/* Phones: fewer, softer blobs so the drift animation stays cheap. Unlayered
@ -321,28 +364,30 @@ body {
.ice-orb-5 {
display: none;
}
.ice-orb-1 { width: 120px; height: 120px; }
.ice-orb-3 { width: 150px; height: 150px; }
.ice-orb-1 { width: 120px; height: 120px; --ice-orb-duration: 54s; --ice-orb-delay: -21s; }
.ice-orb-2 { --ice-orb-duration: 64s; --ice-orb-delay: -46s; }
.ice-orb-3 { width: 150px; height: 150px; --ice-orb-duration: 44s; --ice-orb-delay: -8s; }
}
/* Phones: the decoration is already *behind* the content (the scene is a
fixed, pointer-events:none layer at z-index -1), but it shines through the
translucent page and glass, so it reads as overlap. Thin it right down and
tuck the orbs to the edges, away from the text column and the tab bar. */
translucent page and glass, so it reads as overlap. Thin it right down,
tuck the orbs to the edges, away from the text column and the tab bar, and
slow their rise down further so the drifting slivers stay calm. */
@media (max-width: 639px) {
:root {
--ice-blob-opacity: 0.1;
--ice-orb-opacity: 0.32;
--ice-orb-opacity: 0.2;
}
.dark {
--ice-blob-opacity: 0.2;
--ice-orb-opacity: 0.42;
--ice-orb-opacity: 0.26;
}
/* Phones: orbs hug the screen edges so they can never sit under the
content column's cards or primary buttons. The lower-right orb still
read as a glow behind the wizard action bar, so phones drop it. */
.ice-orb-1 { width: 96px; height: 96px; top: -44px; left: -48px; }
.ice-orb-2 { width: 72px; height: 72px; top: 60%; left: -44px; }
.ice-orb-1 { width: 96px; height: 96px; left: -48px; --ice-orb-duration: 62s; --ice-orb-delay: -30s; --ice-sway: 5vw; }
.ice-orb-2 { width: 72px; height: 72px; left: -44px; --ice-orb-duration: 68s; --ice-orb-delay: -49s; --ice-sway: -4vw; }
.ice-orb-3 { display: none; }
}