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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-20 08:47:22 +00:00
parent e780a3c96f
commit 7b12dcbe40
4 changed files with 109 additions and 0 deletions

View File

@@ -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,

View File

@@ -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);

View File

@@ -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 ─────────────────────────────── */

View File

@@ -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;