From 7b12dcbe40500f6ac832234493e88c24a5424449 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sun, 20 Sep 2026 08:47:22 +0000 Subject: [PATCH] =?UTF-8?q?v3:=20the=20dump=20button=20keeps=20hovering=20?= =?UTF-8?q?once=20you=20point=20at=20it=20=E2=80=94=20a=20flat=201px=20jum?= =?UTF-8?q?p=20was=20the=20whole=20of=20the=20header=20button's=20hover,?= =?UTF-8?q?=20so=20it=20now=20lifts=20and=20then=20rocks,=20tilting=20a=20?= =?UTF-8?q?couple=20of=20degrees=20either=20way=20over=20a=20shallow=20flo?= =?UTF-8?q?at.=20The=20three=20motions=20ride=20on=20three=20different=20p?= =?UTF-8?q?roperties=20=E2=80=94=20`translate`=20for=20the=20lift,=20`rota?= =?UTF-8?q?te`=20for=20the=20tilt,=20`transform`=20for=20the=20float=20?= =?UTF-8?q?=E2=80=94=20since=20that's=20what=20lets=20them=20compose=20rat?= =?UTF-8?q?her=20than=20the=20last=20one=20declared=20winning=20outright;?= =?UTF-8?q?=20the=20tilt=20and=20the=20float=20run=20on=20periods=20that?= =?UTF-8?q?=20don't=20divide=20each=20other=20(2.6s=20against=203.4s)=20so?= =?UTF-8?q?=20the=20pair=20never=20lines=20up=20the=20same=20way=20twice?= =?UTF-8?q?=20and=20never=20settles=20into=20a=20metronome,=20and=20the=20?= =?UTF-8?q?pivot=20sits=20on=20the=20bottom=20edge=20so=20it=20reads=20as?= =?UTF-8?q?=20a=20head=20on=20a=20neck=20rather=20than=20as=20a=20button?= =?UTF-8?q?=20spinning=20about=20its=20middle.=20Arriving=20and=20leaving?= =?UTF-8?q?=20is=20what=20the=20machinery=20is=20for:=20the=20amplitude=20?= =?UTF-8?q?is=20a=20registered=20custom=20property=20the=20keyframes=20mul?= =?UTF-8?q?tiply=20through,=20so=20pointing=20at=20the=20button=20eases=20?= =?UTF-8?q?the=20rocking=20up=20from=20nothing=20while=20the=20lift=20is?= =?UTF-8?q?=20still=20settling,=20and=20leaving=20eases=20it=20back=20down?= =?UTF-8?q?=20under=20an=20animation=20that=20is=20merely=20paused=20?= =?UTF-8?q?=E2=80=94=20no=20switch=20from=20still=20to=20full=20throw,=20a?= =?UTF-8?q?nd=20no=20snap=20square=20when=20the=20cursor=20goes.=20Geociti?= =?UTF-8?q?es=20and=20brutalist=20re-declare=20the=20whole=20transition=20?= =?UTF-8?q?shorthand=20on=20every=20button=20and=20neither=20list=20mentio?= =?UTF-8?q?ns=20`translate`,=20which=20outweighed=20the=20header's=20own?= =?UTF-8?q?=20rule=20and=20dropped=20the=20lift=20onto=20the=20frame=20it?= =?UTF-8?q?=20started=20on,=20so=20that=20rule=20takes=20a=20`:not(:disabl?= =?UTF-8?q?ed)`=20to=20win=20the=20weight=20back.=20nyt=20stays=20flat=20i?= =?UTF-8?q?nk=20and=20brutalist=20goes=20on=20pressing=20into=20the=20page?= =?UTF-8?q?,=20both=20opting=20out=20by=20name;=20reduced=20motion=20keeps?= =?UTF-8?q?=20the=20lift=20and=20drops=20the=20rocking.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- src/App.css | 92 ++++++++++++++++++++++++++++++++++++++++ src/themes/brutalist.css | 7 +++ src/themes/nyt.css | 6 +++ src/tokens.css | 4 ++ 4 files changed, 109 insertions(+) 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;