feat(tiles): Tile Animations setting, entrance styles for the tile grid

Tiles become the fifth entrance surface (data-tile-anim), switched on in App
Settings > Appearance > Tile Animations and OFF by default: the default is the
grid's own quick fade (`settle`), exactly as before.

A styled tile plays in two beats: its frame enters as it mounts (fly out of
its session tab, dealt from the Tiles button, CRT power-on, beam down from its
tab, cascade, pop, soft), and its screen then plays the terminal pane style of
the entrance theme when its first capture lands, through the same
html[data-term-anim] rules on .tile-body. Each style has its own exit when the
Tiles button closes the grid (back into the tabs, a CRT switch-off, ...).

Picking an Entrance Animations theme presets the tile style (new Launch theme:
tiles fly from the tabs); the theme readout ignores the tile row, so changing
it never shows "Custom". Frames move transform and opacity only (one fit and
one PTY resize per tile), a reload's restore always settles, and nothing moves
under reduced motion. The lab (?animlab=1) gets a Tile grid group, a cascade
order picker and in-place replay / close + reopen.

Also removes the terminal pane's `boot` entrance style (owner decision); a
saved `boot` falls back to off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 17:43:55 +02:00
parent 3a0cee6b90
commit decd263b17
10 changed files with 1556 additions and 122 deletions
+588 -51
View File
@@ -1270,6 +1270,26 @@ html[data-tab-anim="blur"] .session-tab.tab-enter {
}
.anim-lab-range output { justify-self: end; color: var(--text); font-variant-numeric: tabular-nums; }
.anim-lab-select {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: -2px 0 10px;
padding: 0 2px;
color: var(--text-dim);
font-size: 0.66rem;
}
.anim-lab-select select {
background: var(--bg-dark, #111);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.66rem;
padding: 2px 4px;
}
.anim-lab-range input { grid-column: 1 / -1; width: 100%; accent-color: #00ff66; }
.anim-lab-demo {
@@ -1456,11 +1476,20 @@ html[data-win-anim="blur"] .ultracode-window.win-enter {
frame. `will-change` is deliberately not set, the base rule needs its
`will-change: contents` for terminal compositing. */
.terminal-container.term-enter {
/* A tile's screen (.tile-body, tile-grid.js) plays the same style when its
first capture lands: the same rules, so a tile combines its frame's own
entrance with the pane style. Its xterm's opacity fade stands aside for it. */
.terminal-container.term-enter,
.tile-body.term-enter {
animation-fill-mode: both;
}
.terminal-container.term-enter::before {
.tile-body.term-enter .xterm {
transition: none;
}
.terminal-container.term-enter::before,
.tile-body.term-enter::before {
content: "";
position: absolute;
inset: 0;
@@ -1470,14 +1499,29 @@ html[data-win-anim="blur"] .ultracode-window.win-enter {
animation-fill-mode: both;
}
/* A screen whose content lands while its frame is still entering waits for
it (--tile-screen-delay, entrance-animations.js): the body holds its first
keyframe meanwhile (hidden), its wash does not (a held wash is a bright
static block). */
.tile-body.term-enter {
animation-delay: var(--tile-screen-delay, 0ms);
}
.tile-body.term-enter::before {
animation-delay: var(--tile-screen-delay, 0ms);
animation-fill-mode: forwards;
}
/* CRT, power-on: a hot line that expands to full height. */
html[data-term-anim="crt"] .terminal-container.term-enter {
html[data-term-anim="crt"] .terminal-container.term-enter,
html[data-term-anim="crt"] .tile-body.term-enter {
animation-name: term-enter-crt;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
}
html[data-term-anim="crt"] .terminal-container.term-enter::before {
html[data-term-anim="crt"] .terminal-container.term-enter::before,
html[data-term-anim="crt"] .tile-body.term-enter::before {
animation-name: term-enter-crt-flash;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
@@ -1498,55 +1542,16 @@ html[data-term-anim="crt"] .terminal-container.term-enter::before {
100% { opacity: 0; background: transparent; }
}
/* Boot, flickers on under a green scan sweep. */
html[data-term-anim="boot"] .terminal-container.term-enter {
animation-name: term-enter-boot;
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
animation-timing-function: linear;
}
html[data-term-anim="boot"] .terminal-container.term-enter::before {
background: linear-gradient(
180deg,
transparent 0%,
rgba(0, 255, 102, 0.05) 40%,
rgba(200, 255, 220, 0.28) 50%,
rgba(0, 255, 102, 0.05) 60%,
transparent 100%
);
background-size: 100% 300%;
animation-name: term-enter-boot-scan;
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-in-out;
}
@keyframes term-enter-boot {
0% { opacity: 0; transform: scale(0.995); }
8% { opacity: 0.85; }
15% { opacity: 0.1; }
24% { opacity: 1; }
33% { opacity: 0.35; }
45% { opacity: 1; transform: none; }
58% { opacity: 0.7; }
70% { opacity: 1; }
100% { opacity: 1; transform: none; }
}
@keyframes term-enter-boot-scan {
0% { opacity: 0; background-position: 0 -150%; }
12% { opacity: 1; }
86% { opacity: 1; }
100% { opacity: 0; background-position: 0 150%; }
}
/* Wipe, reveals top-to-bottom behind a bright edge. */
html[data-term-anim="wipe"] .terminal-container.term-enter {
html[data-term-anim="wipe"] .terminal-container.term-enter,
html[data-term-anim="wipe"] .tile-body.term-enter {
animation-name: term-enter-wipe;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.35, 0.85, 0.3, 1);
}
html[data-term-anim="wipe"] .terminal-container.term-enter::before {
html[data-term-anim="wipe"] .terminal-container.term-enter::before,
html[data-term-anim="wipe"] .tile-body.term-enter::before {
background: linear-gradient(180deg, rgba(0, 255, 102, 0.16) 0%, rgba(190, 255, 215, 0.5) 82%, transparent 100%);
animation-name: term-enter-wipe-edge;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
@@ -1565,7 +1570,8 @@ html[data-term-anim="wipe"] .terminal-container.term-enter::before {
}
/* Slide up, rises into place from below. */
html[data-term-anim="slide"] .terminal-container.term-enter {
html[data-term-anim="slide"] .terminal-container.term-enter,
html[data-term-anim="slide"] .tile-body.term-enter {
animation-name: term-enter-slide;
animation-duration: calc(420ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
@@ -1577,7 +1583,8 @@ html[data-term-anim="slide"] .terminal-container.term-enter {
}
/* Fade, quiet, with a touch of scale. */
html[data-term-anim="fade"] .terminal-container.term-enter {
html[data-term-anim="fade"] .terminal-container.term-enter,
html[data-term-anim="fade"] .tile-body.term-enter {
animation-name: term-enter-fade;
animation-duration: calc(340ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
@@ -1615,7 +1622,8 @@ html[data-term-anim="fade"] .terminal-container.term-enter {
resize() and through to the PTY. `filter` is paint-only (measured live: 178x38
before, during and after a run), and the property allowlist for every one of
these keyframes is pinned by test/entrance-animations.test.ts. */
html[data-term-anim="blur"] .terminal-container.term-enter {
html[data-term-anim="blur"] .terminal-container.term-enter,
html[data-term-anim="blur"] .tile-body.term-enter {
animation-name: term-enter-blur;
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
@@ -1738,10 +1746,20 @@ html[data-line-anim="blur"] .connection-line.line-enter {
.ultracode-window.win-enter::before,
.terminal-container.term-enter,
.terminal-container.term-enter::before,
.tile-body.term-enter,
.tile-body.term-enter::before,
.tile.tile--entering,
.tile.tile--entering::before,
.connection-line.line-enter,
.connection-line-packet {
.connection-line-packet,
.connection-line.tile-beam-line,
.session-tab.tab-launch::after {
animation: none !important;
}
.tile-beam-lines {
display: none;
}
}
.session-tab .tab-status {
@@ -20062,6 +20080,314 @@ body.tile-grid-resizing--row * {
}
}
/* ── Tile grid entrance styles (entrance-animations.js TILE_ANIM_STYLES) ───
Picked by the Entrance Animations theme (App Settings → Appearance) or per
surface in the lab (?animlab=1); `settle` is the rule above, the default,
and what a reload restores with. Any other style is held one frame
(.tile--enter-hold: invisible, not animating) while the entrance module
orders the cascade and measures the tile's source on the final layout, then
arrives with .tile--enter-themed and an inline --tile-enter-delay (and, for
`fly` and `deal`, the --tile-from-* offset onto the tab or the Tiles button).
FRAME keyframes animate transform and opacity only: FitAddon reads the
untransformed layout box (#464), and six tiles run at once, so no filter
(a blur belongs to the screen beat, one tile at a time). Colour goes on a
::before wash. Each style also has its own way out on the closing grid's
still copy (tile-leave-*, below). */
html[data-tile-anim="settle"] .tile.tile--entering {
animation-name: tile-enter;
}
.tile.tile--entering.tile--enter-themed {
animation-delay: var(--tile-enter-delay, 0ms);
animation-fill-mode: both;
will-change: transform, opacity;
}
.tile.tile--entering.tile--enter-themed::before {
content: '';
position: absolute;
inset: 0;
z-index: 6;
border-radius: inherit;
pointer-events: none;
opacity: 0;
animation-delay: var(--tile-enter-delay, 0ms);
animation-fill-mode: both;
}
.tile.tile--enter-hold {
opacity: 0;
}
.tile.tile--enter-hold,
.tile.tile--enter-hold::before {
animation: none !important;
}
/* Fly from tab: grows out of its session tab (FLIP onto the tab's own box),
overshoots a hair and lands. */
html[data-tile-anim="fly"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-fly;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.2, 0.9, 0.25, 1);
}
html[data-tile-anim="fly"] .tile.tile--entering.tile--enter-themed::before {
animation-name: tile-wash-launch;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-fly {
0% {
opacity: 0;
transform: translate(var(--tile-from-x, 0px), var(--tile-from-y, -48px))
scale(var(--tile-from-sx, 0.3), var(--tile-from-sy, 0.1));
}
16% {
opacity: 1;
}
74% {
transform: translate(0, 0) scale(1.01);
}
100% {
opacity: 1;
transform: none;
}
}
@keyframes tile-wash-launch {
0% {
opacity: 1;
background: color-mix(in srgb, var(--accent, #4a9eff) 45%, transparent);
box-shadow: inset 0 0 0 2px var(--accent, #4a9eff);
}
55% {
opacity: 0.55;
background: color-mix(in srgb, var(--accent, #4a9eff) 12%, transparent);
}
100% {
opacity: 0;
background: transparent;
box-shadow: inset 0 0 0 1px transparent;
}
}
/* Deal: dealt out of the Tiles button like cards, each turned a little, then
squared up in its cell. */
html[data-tile-anim="deal"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-deal;
animation-duration: calc(600ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 0.85, 0.3, 1);
}
@keyframes tile-enter-deal {
0% {
opacity: 0;
transform: translate(var(--tile-from-x, 0px), var(--tile-from-y, -240px)) scale(var(--tile-from-sx, 0.1))
rotate(var(--tile-from-rot, -10deg));
}
10% {
opacity: 1;
}
70% {
transform: translate(0, 0) scale(1.02) rotate(calc(var(--tile-from-rot, -10deg) * -0.1));
}
88% {
transform: scale(0.996) rotate(0deg);
}
100% {
opacity: 1;
transform: none;
}
}
/* CRT: powers on as a hot line, then unfolds, under the agent windows' flash. */
html[data-tile-anim="crt"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-crt;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
}
html[data-tile-anim="crt"] .tile.tile--entering.tile--enter-themed::before {
animation-name: win-enter-crt-flash;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-crt {
0% {
opacity: 0;
transform: scale3d(0.5, 0.006, 1);
}
22% {
opacity: 1;
transform: scale3d(1, 0.01, 1);
}
58% {
opacity: 1;
transform: scale3d(1, 1.04, 1);
}
80% {
opacity: 1;
transform: scale3d(1, 0.985, 1);
}
100% {
opacity: 1;
transform: none;
}
}
/* Beam down: holds still while a beam draws down from its tab (the entrance
module's own overlay, .tile-beam-line), then materializes out of the agent
windows' green wash. No transform, so the beam lands where the tile is. */
html[data-tile-anim="beam"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-beam;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
html[data-tile-anim="beam"] .tile.tile--entering.tile--enter-themed::before {
animation-name: win-enter-beam-wash;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-beam {
0% {
opacity: 0;
}
20% {
opacity: 0.55;
}
32% {
opacity: 0.2;
}
48% {
opacity: 0.9;
}
100% {
opacity: 1;
}
}
/* Cascade: swings down from its top edge, a diagonal at a time. */
html[data-tile-anim="cascade"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-cascade;
animation-duration: calc(600ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
transform-origin: 50% 0%;
}
@keyframes tile-enter-cascade {
0% {
opacity: 0;
transform: perspective(1400px) rotateX(-80deg);
}
55% {
opacity: 1;
transform: perspective(1400px) rotateX(8deg);
}
80% {
transform: perspective(1400px) rotateX(-2.5deg);
}
100% {
opacity: 1;
transform: perspective(1400px) rotateX(0deg);
}
}
/* Pop: springs open from its centre, rippling out from the focused tile. */
html[data-tile-anim="pop"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-pop;
animation-duration: calc(480ms * var(--anim-enter-scale, 1));
animation-timing-function: ease-out;
}
@keyframes tile-enter-pop {
0% {
opacity: 0;
transform: scale(0.6);
}
58% {
opacity: 1;
transform: scale(1.03);
}
80% {
transform: scale(0.993);
}
100% {
opacity: 1;
transform: none;
}
}
/* Soft: a slow drift up into place; with the Soft focus theme each screen
then focus-pulls in (the pane's `blur` style on .tile-body). */
html[data-tile-anim="soft"] .tile.tile--entering.tile--enter-themed {
animation-name: tile-enter-soft;
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes tile-enter-soft {
0% {
opacity: 0;
transform: translateY(16px) scale(0.94);
}
100% {
opacity: 1;
transform: none;
}
}
/* The tab a tile flies (or beams) out of glows as it goes. */
.session-tab.tab-launch:not(.tab-loading)::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
animation: tab-launch-flash calc(520ms * var(--anim-enter-scale, 1)) ease-out var(--tab-launch-delay, 0ms) both;
}
@keyframes tab-launch-flash {
0% {
opacity: 0;
background: color-mix(in srgb, var(--accent, #4a9eff) 40%, transparent);
box-shadow: 0 0 0 1px var(--accent, #4a9eff), 0 0 14px 2px color-mix(in srgb, var(--accent, #4a9eff) 70%, transparent);
}
18% {
opacity: 1;
}
100% {
opacity: 0;
background: transparent;
box-shadow: 0 0 0 1px transparent, 0 0 0 0 transparent;
}
}
/* `beam`'s lines: the connection-line look, drawn from the tab down to the
tile, held while the tile materializes, then faded. Their own overlay
(#tileBeamLines), removed by the entrance module once they are done. */
.connection-line.tile-beam-line {
stroke-dasharray: var(--line-len);
animation:
line-enter-draw calc(380ms * var(--anim-enter-scale, 1)) cubic-bezier(0.32, 0.8, 0.3, 1)
var(--line-enter-delay, 0ms) both,
tile-beam-out calc(420ms * var(--anim-enter-scale, 1)) ease-in
calc(var(--line-enter-delay, 0ms) + 640ms * var(--anim-enter-scale, 1)) forwards;
}
@keyframes tile-beam-out {
from {
opacity: 0.9;
}
to {
opacity: 0;
}
}
.tile-body .xterm {
transition: opacity 160ms ease-out;
}
@@ -20136,9 +20462,220 @@ body.tile-grid-resizing--row * {
}
}
/* The entrance style's own way out (entrance-animations.js _stageTileExit),
on the still copy only and only for the Tiles toggle's close: a re-form's
copy (--now) covers tiles that stay, so it keeps the plain fade above.
Copies leave in reading order (--tile-exit-delay), so the last one ends
last and takes the layer with it. */
.tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
content: '';
position: absolute;
inset: 0;
z-index: 6;
border-radius: inherit;
pointer-events: none;
opacity: 0;
}
html[data-tile-anim="fly"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-fly calc(460ms * var(--anim-enter-scale, 1)) cubic-bezier(0.55, 0, 0.75, 0.2)
var(--tile-exit-delay, 0ms) both;
}
html[data-tile-anim="fly"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
animation: tile-wash-return calc(460ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-fly {
0% {
opacity: 0.8;
transform: scale(0.985);
}
75% {
opacity: 0.8;
}
100% {
opacity: 0;
transform: translate(var(--tile-to-x, 0px), var(--tile-to-y, -48px))
scale(var(--tile-to-sx, 0.3), var(--tile-to-sy, 0.1));
}
}
@keyframes tile-wash-return {
0% {
opacity: 0;
background: transparent;
}
100% {
opacity: 1;
background: color-mix(in srgb, var(--accent, #4a9eff) 55%, transparent);
}
}
html[data-tile-anim="deal"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-deal calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.5, 0, 0.75, 0.25)
var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-deal {
0% {
opacity: 0.8;
transform: scale(0.985);
}
22% {
transform: scale(1.02) rotate(calc(var(--tile-to-rot, -10deg) * -0.15));
}
85% {
opacity: 0.85;
}
100% {
opacity: 0;
transform: translate(var(--tile-to-x, 0px), var(--tile-to-y, -240px)) scale(var(--tile-to-sx, 0.1))
rotate(var(--tile-to-rot, -10deg));
}
}
/* CRT: the classic switch-off, down to a line, then a dot, then dark. */
html[data-tile-anim="crt"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-crt calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.4, 0, 0.6, 1)
var(--tile-exit-delay, 0ms) both;
}
html[data-tile-anim="crt"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
animation: tile-wash-crt-off calc(520ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-crt {
0% {
opacity: 0.8;
transform: scale(0.985);
}
42% {
opacity: 1;
transform: scale3d(1, 0.012, 1);
}
72% {
opacity: 1;
transform: scale3d(0.03, 0.012, 1);
}
100% {
opacity: 0;
transform: scale3d(0, 0, 1);
}
}
@keyframes tile-wash-crt-off {
0% {
opacity: 0;
background: transparent;
}
38% {
opacity: 1;
background: rgba(220, 255, 235, 0.95);
}
100% {
opacity: 1;
background: rgba(255, 255, 255, 1);
}
}
/* Beam down: beamed back up, flickering out under a rising wash. */
html[data-tile-anim="beam"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-beam calc(480ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
html[data-tile-anim="beam"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
animation: tile-wash-beam-up calc(480ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-beam {
0% {
opacity: 0.8;
}
40% {
opacity: 0.9;
}
55% {
opacity: 0.3;
}
70% {
opacity: 0.6;
}
100% {
opacity: 0;
}
}
@keyframes tile-wash-beam-up {
0% {
opacity: 0;
background: linear-gradient(0deg, rgba(0, 255, 102, 0.35), rgba(0, 255, 102, 0));
}
100% {
opacity: 1;
background: linear-gradient(0deg, rgba(190, 255, 215, 0.85), rgba(0, 255, 102, 0.2));
}
}
/* Cascade: folds back up on its top edge. */
html[data-tile-anim="cascade"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-cascade calc(480ms * var(--anim-enter-scale, 1)) cubic-bezier(0.5, 0, 0.75, 0.2)
var(--tile-exit-delay, 0ms) both;
transform-origin: 50% 0%;
}
@keyframes tile-leave-cascade {
0% {
opacity: 0.8;
transform: perspective(1400px) rotateX(0deg) scale(0.985);
}
100% {
opacity: 0;
transform: perspective(1400px) rotateX(82deg) scale(0.985);
}
}
/* Pop: a last swell, then it pops away. */
html[data-tile-anim="pop"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-pop calc(380ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-pop {
0% {
opacity: 0.8;
transform: scale(0.985);
}
35% {
opacity: 0.9;
transform: scale(1.03);
}
100% {
opacity: 0;
transform: scale(0.5);
}
}
/* Soft: sinks away slowly. */
html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
animation: tile-leave-soft calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.4, 0, 0.7, 0.4)
var(--tile-exit-delay, 0ms) both;
}
@keyframes tile-leave-soft {
0% {
opacity: 0.8;
transform: scale(0.985);
}
100% {
opacity: 0;
transform: translateY(14px) scale(0.94);
}
}
@media (prefers-reduced-motion: reduce) {
.tile-count-menu,
.tile.tile--entering,
.tile.tile--entering::before,
.tile.tile--needs::after,
.tile.tile--loading .tile-body::after,
.tile-grid-ghosts .tile.tile--leaving,