v3: the dump button keeps hovering once you point at it — a flat 1px jump was the whole of the header button's hover, so it now lifts and then rocks, tilting a couple of degrees either way over a shallow float. The three motions ride on three different properties — translate for the lift, rotate for the tilt, transform for the float — since that's what lets them compose rather than the last one declared winning outright; the tilt and the float run on periods that don't divide each other (2.6s against 3.4s) so the pair never lines up the same way twice and never settles into a metronome, and the pivot sits on the bottom edge so it reads as a head on a neck rather than as a button spinning about its middle. Arriving and leaving is what the machinery is for: the amplitude is a registered custom property the keyframes multiply through, so pointing at the button eases the rocking up from nothing while the lift is still settling, and leaving eases it back down under an animation that is merely paused — no switch from still to full throw, and no snap square when the cursor goes. Geocities and brutalist re-declare the whole transition shorthand on every button and neither list mentions translate, which outweighed the header's own rule and dropped the lift onto the frame it started on, so that rule takes a :not(:disabled) to win the weight back. nyt stays flat ink and brutalist goes on pressing into the page, both opting out by name; reduced motion keeps the lift and drops the rocking.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 40s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 40s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
92
src/App.css
92
src/App.css
@@ -2778,10 +2778,102 @@ body.has-player .chat-fab {
|
||||
padding: 0.35rem 0.85rem;
|
||||
}
|
||||
|
||||
/* Amplitude of the header button's idle rocking, as a 0–1 scale. Registered
|
||||
so it can be transitioned: the keyframes below multiply their offsets by it,
|
||||
which turns a hard switch between still and rocking into a fade. */
|
||||
@property --btn-bob {
|
||||
syntax: "<number>";
|
||||
inherits: false;
|
||||
initial-value: 0;
|
||||
}
|
||||
|
||||
.app-header-nav .btn-primary {
|
||||
padding: 0.35rem 1rem;
|
||||
}
|
||||
|
||||
/* `:not(:disabled)` earns this rule a third class's worth of weight, which it
|
||||
needs: geocities and brutalist re-declare the whole `transition` shorthand
|
||||
on every button at one class plus one element plus a :not, and neither list
|
||||
mentions `translate`, so without it their lists win and the lift lands
|
||||
instantly instead of easing. Nothing here applies to a disabled button
|
||||
anyway — it can't be hovered. */
|
||||
.app-header-nav .btn-primary:not(:disabled) {
|
||||
/* Pivot at the bottom edge, so the rock below reads as a head on a neck
|
||||
rather than as the whole button spinning about its middle. */
|
||||
transform-origin: 50% 100%;
|
||||
/* Both motions are declared here rather than on :hover, and held paused, so
|
||||
that leaving the button fades the rocking out instead of cutting it: the
|
||||
amplitude eases to zero under an animation that is merely frozen. Paused
|
||||
also means an untouched button isn't animating a thing. */
|
||||
animation:
|
||||
btn-hover-tilt 2.6s ease-in-out infinite,
|
||||
btn-hover-float 3.4s ease-in-out infinite;
|
||||
animation-play-state: paused;
|
||||
/* The lift is carried by `translate` rather than `transform` so the rocking,
|
||||
which needs `transform` and `rotate` to itself, composes with it instead
|
||||
of replacing it. It eases in and out rather than out alone — a fast start
|
||||
is what made the button feel like it snapped up to meet the cursor. */
|
||||
transition:
|
||||
background var(--dur-base),
|
||||
color var(--dur-base),
|
||||
border-color var(--dur-base),
|
||||
box-shadow var(--dur-base),
|
||||
translate var(--dur-lift) var(--ease-in-out),
|
||||
--btn-bob var(--dur-bob-fade) var(--ease-in-out);
|
||||
}
|
||||
|
||||
.app-header-nav .btn-primary:hover:not(:disabled) {
|
||||
translate: 0 -2px;
|
||||
/* The rocking starts the moment the lift does, but from nothing, and takes
|
||||
longer than the lift to reach full throw — so the button is already
|
||||
drifting as it settles, and never visibly changes gear. */
|
||||
--btn-bob: 1;
|
||||
animation-play-state: running;
|
||||
}
|
||||
|
||||
/* Pressing puts it back down flat — the base :active transform can only take
|
||||
effect once the animation stops claiming `transform`. */
|
||||
.app-header-nav .btn-primary:active:not(:disabled) {
|
||||
translate: none;
|
||||
animation: none;
|
||||
--btn-bob: 0;
|
||||
/* The press has to land at once — at the lift's own easing it would float
|
||||
back down under the finger. */
|
||||
transition-duration: var(--dur-fast);
|
||||
}
|
||||
|
||||
/* The rock is the motion; the float underneath it is small and on a period
|
||||
that doesn't divide the rock's, so the two never line up the same way twice
|
||||
and it never settles into a metronome. */
|
||||
@keyframes btn-hover-tilt {
|
||||
0%,
|
||||
100% {
|
||||
rotate: 0deg;
|
||||
}
|
||||
25% {
|
||||
rotate: calc(var(--btn-bob, 0) * 2.5deg);
|
||||
}
|
||||
75% {
|
||||
rotate: calc(var(--btn-bob, 0) * -2.5deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes btn-hover-float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(calc(var(--btn-bob, 0) * -2px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-header-nav .btn-primary {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Icon-only controls are square, so the row reads as a tidy set. */
|
||||
.app-header-nav .nav-search-btn,
|
||||
.app-header-nav .notification-bell,
|
||||
|
||||
@@ -364,6 +364,13 @@
|
||||
transform: translate(2px, 2px);
|
||||
}
|
||||
|
||||
/* The header's dump button floats in the base theme; here it presses into the
|
||||
page instead, so neither the lift nor the idle drift applies. */
|
||||
[data-style="brutalist"] .app-header-nav .btn-primary:hover:not(:disabled) {
|
||||
translate: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* ── btn-border ──────────────────────────────────────────────────── */
|
||||
[data-style="brutalist"] .btn-border {
|
||||
box-shadow: 2px 2px 0 var(--color-border);
|
||||
|
||||
@@ -356,6 +356,12 @@
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Flat ink on paper: the header's dump button doesn't lift or drift either. */
|
||||
[data-style="nyt"] .app-header-nav .btn-primary:hover:not(:disabled) {
|
||||
translate: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Secondary / outline buttons — plain hairline box */
|
||||
|
||||
/* ── Inputs — clean field, ink focus ─────────────────────────────── */
|
||||
|
||||
@@ -86,6 +86,10 @@
|
||||
--dur-base: 0.15s;
|
||||
--dur-slow: 0.2s;
|
||||
--dur-reveal: 0.25s;
|
||||
/* A hover lift long enough not to snap, and the slower fade that brings the
|
||||
idle rocking in over the top of it. */
|
||||
--dur-lift: 0.4s;
|
||||
--dur-bob-fade: 0.9s;
|
||||
|
||||
/* ── Layers ── */
|
||||
--z-raised: 1;
|
||||
|
||||
Reference in New Issue
Block a user