Replace flat row flash with accent shimmer sweep and fix combo key press animation

- Row highlight on shortcut trigger now uses a horizontal gradient sweep
  (background-position animation) instead of a static background flash,
  providing a distinct visual language from the focus ring
- Shimmer uses theme-aware accent color via color-mix on keymap page,
  with a neutral fallback in shui.css base styles
- Guard against animation spam: clearTimeout+reset pattern prevents
  stale timeout accumulation during rapid key repeat; reflow only
  forced on first trigger, class stays applied until last trigger ends
- Fix combo key press animation: animate the container (the keycap)
  instead of individual kbd elements, so translateY and box-shadow
  follow the container's border-radius correctly
- Scope row shimmer to .shui-shortcut-row/.shortcut-row elements
  to prevent accidental shimmer on standalone badge containers
- Respect prefers-reduced-motion for all new animations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
scheinriese
2026-03-11 17:11:55 +01:00
committed by Tienson Qin
parent e45fb29061
commit 4dcdbcc44c
3 changed files with 104 additions and 35 deletions

View File

@@ -157,30 +157,58 @@
%)))))))
(def ^:private press-animation-ms 160)
(def ^:private row-shimmer-ms 750)
(defn- highlight-row!
"Add or remove the row highlight class on the closest shortcut row ancestor."
"Add or remove the row highlight class on the closest shortcut row ancestor.
On first trigger, forces a reflow to start the CSS animation.
On repeated triggers, the class stays applied (no reflow) and the
removal timer is reset so it fires after the last trigger."
[^js container add?]
(when-let [^js row (or (.closest container ".shui-shortcut-row, .shortcut-row")
(.-parentElement container))]
(if add?
(.add (.-classList row) "shui-shortcut-row--pressed")
(.remove (.-classList row) "shui-shortcut-row--pressed"))))
(let [already? (.contains (.-classList row) "shui-shortcut-row--pressed")]
;; Cancel any pending removal
(when-let [t (.-__sweepTimer row)]
(js/clearTimeout t)
(set! (.-__sweepTimer row) nil))
;; Only force reflow on first trigger to start the animation
(when-not already?
(.add (.-classList row) "shui-shortcut-row--pressed"))
;; Schedule removal after the last trigger
(set! (.-__sweepTimer row)
(js/setTimeout
(fn []
(.remove (.-classList row) "shui-shortcut-row--pressed")
(set! (.-__sweepTimer row) nil))
row-shimmer-ms)))
(do
(when-let [t (.-__sweepTimer row)]
(js/clearTimeout t)
(set! (.-__sweepTimer row) nil))
(.remove (.-classList row) "shui-shortcut-row--pressed")))))
(defn- animate-element!
"Add pressed class, optionally highlight row, then auto-reset after animation."
"Add pressed class, optionally highlight row, then auto-reset after animation.
Key badge uses a simple clearTimeout+reset pattern to avoid stale removals."
[^js el ^js container highlight-row?]
(.add (.-classList el) "shui-shortcut-key-pressed")
(when highlight-row? (highlight-row! container true))
(js/setTimeout
(fn []
(.remove (.-classList el) "shui-shortcut-key-pressed")
(when highlight-row? (highlight-row! container false)))
press-animation-ms))
;; Clear any pending badge removal, then schedule a new one
(when-let [t (.-__badgeTimer el)]
(js/clearTimeout t))
(set! (.-__badgeTimer el)
(js/setTimeout
(fn []
(.remove (.-classList el) "shui-shortcut-key-pressed")
(set! (.-__badgeTimer el) nil))
press-animation-ms)))
(defn shortcut-press!
"Central helper to trigger key press animation.
Finds all nodes with matching data-shortcut-binding and animates individual keys.
For combo keys, animates the container (the whole keycap depresses).
For separate keys, animates individual kbd elements.
Optionally highlights parent row.
Args:
@@ -192,11 +220,15 @@
selector (str "[data-shortcut-binding=\"" normalized "\"]")
containers (.querySelectorAll js/document selector)]
(doseq [^js container (array-seq containers)]
(let [keys (.querySelectorAll container "kbd.shui-shortcut-key")]
(if (> (.-length keys) 0)
(doseq [^js key-el (array-seq keys)]
(animate-element! key-el container highlight-row?))
(animate-element! container container highlight-row?)))))))
(if (.contains (.-classList container) "shui-shortcut-combo")
;; Combo keys: animate the container as a unit (one keycap)
(animate-element! container container highlight-row?)
;; Separate keys: animate each kbd individually
(let [keys (.querySelectorAll container "kbd.shui-shortcut-key")]
(if (> (.-length keys) 0)
(doseq [^js key-el (array-seq keys)]
(animate-element! key-el container highlight-row?))
(animate-element! container container highlight-row?))))))))
(rum/defc combo-keys
"Renders combo keys (simultaneous key combinations) with separator."

View File

@@ -279,6 +279,7 @@ div[data-radix-popper-content-wrapper] {
box-sizing: border-box;
white-space: nowrap;
min-width: 0;
transition: transform 140ms ease-out, box-shadow 140ms ease-out;
}
/* Glow effect for combo and separate keys */
@@ -377,33 +378,52 @@ kbd.shui-shortcut-key,
color: hsl(var(--primary-foreground));
}
/* Key press animation */
/* Key press animation — separate keys: animate individual kbd elements */
kbd.shui-shortcut-key-pressed,
.shui-shortcut-key-pressed {
transform: translateY(1px);
}
/* Key press animation with glow - preserve glow effect */
/* Combo keys: animate the container */
.shui-shortcut-combo.shui-shortcut-glow.shui-shortcut-key-pressed {
box-shadow: rgba(255, 255, 255, 0.15) 0px 2px 0px 0px inset, rgba(0, 0, 0, 0.15) 0px 0px 0px 0px inset, 0 1px 2px rgba(0, 0, 0, 0.2);
}
/* Separate keys: animate individual keys */
.shui-shortcut-separate.shui-shortcut-glow kbd.shui-shortcut-key-pressed {
box-shadow: rgba(255, 255, 255, 0.15) 0px 2px 0px 0px inset, rgba(0, 0, 0, 0.15) 0px 0px 0px 0px inset, 0 1px 2px rgba(0, 0, 0, 0.2);
}
/* Key press animation without glow */
.shui-shortcut-combo:not(.shui-shortcut-glow) kbd.shui-shortcut-key-pressed,
.shui-shortcut-separate:not(.shui-shortcut-glow) kbd.shui-shortcut-key-pressed {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
/* Row highlight animation */
.shui-shortcut-row--pressed {
background-color: rgba(223, 239, 254, 0.1);
transition: background-color 160ms ease-out;
/* Key press animation — combo keys: animate the container (the whole keycap) */
.shui-shortcut-combo.shui-shortcut-key-pressed {
transform: translateY(1px);
}
.shui-shortcut-combo.shui-shortcut-glow.shui-shortcut-key-pressed {
box-shadow: rgba(255, 255, 255, 0.15) 0px 2px 0px 0px inset, rgba(0, 0, 0, 0.15) 0px 0px 0px 0px inset, 0 1px 2px rgba(0, 0, 0, 0.2);
}
.shui-shortcut-combo:not(.shui-shortcut-glow).shui-shortcut-key-pressed {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
/* Row highlight animation — accent band sweep (only on actual row elements) */
.shui-shortcut-row.shui-shortcut-row--pressed,
.shortcut-row.shui-shortcut-row--pressed {
background-image: linear-gradient(
90deg,
transparent 0%,
transparent 35%,
rgba(223, 239, 254, 0.08) 50%,
transparent 65%,
transparent 100%
);
background-size: 300% 100%;
background-repeat: no-repeat;
animation: shortcut-row-sweep 700ms ease-out forwards;
}
@keyframes shortcut-row-sweep {
from { background-position: -50% 0; }
to { background-position: 150% 0; }
}
/* Ensure consistent height for shortcut containers */
@@ -417,14 +437,16 @@ kbd.shui-shortcut-key-pressed,
@media (prefers-reduced-motion: reduce) {
kbd.shui-shortcut-key,
.shui-shortcut-key,
.shui-shortcut-row--pressed {
.shui-shortcut-combo {
transition: none;
transform: none;
box-shadow: 0 0 0 transparent;
}
.shui-shortcut-row--pressed {
background-color: transparent;
.shui-shortcut-row.shui-shortcut-row--pressed,
.shortcut-row.shui-shortcut-row--pressed {
animation: none !important;
background-image: none !important;
}
}

View File

@@ -215,9 +215,19 @@ button.shortcut-feedback-action {
background-color: var(--lx-gray-05-alpha, var(--rx-gray-05-alpha));
}
/* Shortcut keypress highlight — briefly flashes the row when the user presses a shortcut */
/* Shortcut keypress shimmer — accent band sweeps left-to-right across the row */
&.shui-shortcut-row--pressed {
background-color: var(--lx-gray-05-alpha, var(--rx-gray-05-alpha));
background-image: linear-gradient(
90deg,
transparent 0%,
transparent 35%,
color-mix(in srgb, var(--lx-accent-09, hsl(var(--primary))) 10%, transparent) 50%,
transparent 65%,
transparent 100%
);
background-size: 300% 100%;
background-repeat: no-repeat;
animation: shortcut-row-sweep 700ms ease-out forwards;
}
/* Keyboard navigation focus ring — two levels above hover for clear distinction */
@@ -289,6 +299,11 @@ button.shortcut-feedback-action {
}
@keyframes shortcut-row-sweep {
from { background-position: -50% 0; }
to { background-position: 150% 0; }
}
/* === V3 SHORTCUT POPOVER === */
.shortcut-popover {
@apply flex flex-col;