diff --git a/src/App.css b/src/App.css index b9b949d5..056038df 100644 --- a/src/App.css +++ b/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: ""; + 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, diff --git a/src/themes/brutalist.css b/src/themes/brutalist.css index 59067524..89bb8b14 100644 --- a/src/themes/brutalist.css +++ b/src/themes/brutalist.css @@ -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); diff --git a/src/themes/nyt.css b/src/themes/nyt.css index 926703c4..8b7e2743 100644 --- a/src/themes/nyt.css +++ b/src/themes/nyt.css @@ -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 ─────────────────────────────── */ diff --git a/src/tokens.css b/src/tokens.css index be0ffce9..00f2c8f0 100644 --- a/src/tokens.css +++ b/src/tokens.css @@ -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;