Skip to content

Commit b03e096

Browse files
mizu-junclaudehappy-otter
authored
feat(client): hover cross-fade for the settings rows and the context menu (UI/UX v3 P3b2a) (#80)
* docs(spec): P3b2 hover cross-fade design, correcting the P3b hover survey The P3b design document claimed hover existed in exactly two places. It missed the tab bar and the custom title bar's window buttons, both of which are chrome that responds to the pointer. Scope is now all four models. Adds a forward-pointing correction note to the parent document rather than rewriting its reasoning. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering> * docs(plan): P3b2a implementation plan, with a two-timer HoverTransition Self-review of the plan found a real defect in the design's single-Timed HoverTransition: the sum-to-1 invariant only holds when the outgoing item was already at weight 1, so interrupting a fade mid-flight makes the incoming item jump. Sweeping a list crosses row boundaries faster than the 100 ms fade, making that the common case. Splits it into two timers and adds a test that pins the defect. Also records the remaining single-slot limitation rather than hiding it. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering> * feat(client): add HoverTransition, the shared hover cross-fade * feat(client): add lerp_rgba for hover colour cross-fades * feat(client): cross-fade the settings panel's hover fill * feat(client): cross-fade the context menu's hover fill, accent and label * refactor(client): remove the WidgetSpec.hovered boolean The hover cross-fade (UI/UX v3 P3b2a) replaced its only reader with a weight from HoverTransition, leaving the field written by nine builders and read by nothing. SettingsPanel.hover_widget is already the truth for whether the pointer is over a control, so the boolean was redundant rather than merely unused. * test(client): restore direct coverage for WidgetDesc focused/tooltip Deleting the hovered field also deleted the only unit test exercising the focused() and tooltip() builder methods directly, rather than through an unrelated field. Re-add a trimmed version scoped to what still exists. * fix(client): retarget hover cross-fade on panel close, clarify two comments Fix wave from the P3b2 whole-branch review (zero Critical/Important, three of four minors picked up in this pass): 1. Panel-closed pointer-motion branch (mouse.rs) cleared hover_widget and theme_hover_preview on Esc dismiss but left hover_transition pointed at the last hovered row. The fade-out that should start immediately instead waited for the next pointer move (up to 100ms later), and has_active_animation stayed true for a closed panel. Retarget to None alongside the existing clears, mirroring the open-panel branch's AnimationsConfig::clone() borrow workaround. Added a state-level test pinning that the retarget settles rather than running forever. 2. hover.rs: document why Default's throwaway Instant::now() is safe (zero duration short-circuits Timed's start-dependent paths). 3. color_util.rs: note that lerp_rgba_clamps_t's endpoints are exactly representable, so unlike lerp_rgba_hits_both_endpoints_exactly it can't distinguish the early-return implementation from a clamp-then-lerp one. HoverTransition::target()'s #[allow(dead_code)] is left as-is per the review's own recommendation to revisit at P3b2b. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Happy <yesreply@happy.engineering>
1 parent 5d6e167 commit b03e096

34 files changed

Lines changed: 1910 additions & 87 deletions

docs/superpowers/plans/2026-08-29-p3b2a-hover-crossfade.md

Lines changed: 869 additions & 0 deletions
Large diffs are not rendered by default.

docs/superpowers/specs/2026-08-28-p3b-motion-application-design.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ leave rather than appearing and vanishing.
1818

1919
### Hover exists in exactly two places
2020

21+
> **Correction (2026-08-29): this heading is wrong.** There are four
22+
> pointer-hover models, not two — this survey missed the tab bar
23+
> (`ClientState.hovered_tab_id`, painted at `ui_verts.rs:363`) and the custom
24+
> title bar's window buttons (`ClientState.hovered_window_button`,
25+
> `ui_verts.rs:778-805`). The Scope section below is correspondingly too
26+
> narrow. The reasoning in this section about the two models it *did* find
27+
> still holds, and so does its argument for not extending `HoverDwell`. See
28+
> `2026-08-29-p3b2-hover-crossfade-design.md`, which corrects the survey and
29+
> carries P3b2's real scope. The rest of this document — including everything
30+
> P3b1 shipped — is unaffected.
31+
2132
- **The widget layer.** Every migrated settings tab builds its specs with
2233
`desc.place(rect, control).hovered(hovered == Some(index))` — a single
2334
`Option<u16>` per frame. `draw/mod.rs:152` paints a hover row fill when

docs/superpowers/specs/2026-08-29-p3b2-hover-crossfade-design.md

Lines changed: 327 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
//! Hover cross-fade between two items of one model (UI/UX v3 P3b2).
2+
//!
3+
//! A hover weight is a scalar, not a layer: three of the four hover models
4+
//! in this client interpolate more than one property (a fill, an accent
5+
//! line, a text colour), and two of them compute the hovered colour by
6+
//! brightening the resting one. So this type answers "how hovered is this
7+
//! id, right now" and each draw site lerps its own appearance — unlike
8+
//! `SurfaceMotion`, whose consumers fade a whole surface's vertices.
9+
//!
10+
//! One pointer means one transition **per model**, not globally: moving from
11+
//! a settings row to a tab starts a tab-bar transition while the widget
12+
//! layer's is still fading out. Each model therefore owns its own
13+
//! `HoverTransition`.
14+
//!
15+
//! The logical hover state (`SettingsPanel.hover_widget`,
16+
//! `ContextMenu.hovered`) stays the truth for tooltips, hit-testing and
17+
//! accessibility. It cannot also carry the transition: it goes `None` the
18+
//! moment the pointer leaves, which is exactly when the fade-out must still
19+
//! be running.
20+
21+
use std::time::Instant;
22+
23+
use nexterm_config::AnimationsConfig;
24+
25+
use super::{Curve, Timed, duration};
26+
27+
/// A cross-fade between the previously hovered item and the current one.
28+
///
29+
/// **Two timers, not one.** The obvious form is a single `Timed` with the
30+
/// outgoing item at `1 - progress` and the incoming one at `progress`, so
31+
/// the pair always sums to 1. That is wrong: the invariant only holds when
32+
/// the outgoing item was already at weight 1. Enter row A and, 50 ms later
33+
/// while A is still at 0.5, move to row B — a single timer makes B *jump* to
34+
/// 0.5 on the frame the pointer crosses the boundary. Sweeping down a list
35+
/// crosses boundaries faster than 100 ms routinely, so that form pops on
36+
/// exactly the gesture hover exists to support.
37+
///
38+
/// With two timers the outgoing item decays from the weight it actually held
39+
/// and the incoming one rises from the weight *it* actually held — 0
40+
/// normally, or its partly-decayed value when the pointer comes back to it.
41+
/// The pair does not sum to 1 mid-handoff, which is correct: at that instant
42+
/// neither row is fully hovered.
43+
///
44+
/// One slot is a real limitation: only one item fades out at a time, so
45+
/// sweeping across five rows drops the three intermediate ones to 0 as each
46+
/// is replaced, leaving a trail that cuts off rather than one that fades. A
47+
/// fixed-capacity `id → Timed` map would fix it behind an unchanged
48+
/// `weight()`; a single slot is bounded and matches what the design chose.
49+
// Consumed by the two overlay models in Tasks 3 and 4.
50+
#[derive(Debug, Clone, Copy)]
51+
pub struct HoverTransition<Id> {
52+
/// The item fading out, and the weight it held when it started to.
53+
from: Option<(Id, f32)>,
54+
from_anim: Timed,
55+
/// The item fading in.
56+
to: Option<Id>,
57+
to_anim: Timed,
58+
}
59+
60+
impl<Id> Default for HoverTransition<Id> {
61+
fn default() -> Self {
62+
// Both animations are born finished; with `from` and `to` both
63+
// `None`, every id weighs 0 regardless. `Instant::now()` here is an
64+
// arbitrary start: at a zero duration, `Timed::raw_progress` and
65+
// `Timed::resuming_at` short-circuit without reading `start`, so any
66+
// instant is equally harmless.
67+
let zero = Timed::new(Instant::now(), 0, Curve::EasyEase);
68+
Self {
69+
from: None,
70+
from_anim: zero,
71+
to: None,
72+
to_anim: zero,
73+
}
74+
}
75+
}
76+
77+
// Consumed by the two overlay models in Tasks 3 and 4.
78+
impl<Id: Copy + PartialEq> HoverTransition<Id> {
79+
/// Point the transition at `to`, resuming from whatever is on screen.
80+
///
81+
/// Idempotent while `to` is unchanged, because the caller is a
82+
/// pointer-motion handler that fires far more often than the hovered id
83+
/// changes; restarting the fade on every frame of a slow drag across one
84+
/// row would freeze it near 0.
85+
pub fn retarget(&mut self, to: Option<Id>, now: Instant, anim: &AnimationsConfig) {
86+
if self.to == to {
87+
return;
88+
}
89+
let ms = anim.scaled_duration_ms(duration::FASTER);
90+
// Read both weights off the screen *before* overwriting any field.
91+
let outgoing = self.to.map(|id| (id, self.weight(id, now)));
92+
let incoming_from = to.map_or(0.0, |id| self.weight(id, now));
93+
94+
self.from = outgoing;
95+
self.from_anim = Timed::new(now, ms, Curve::EasyEase);
96+
self.to = to;
97+
self.to_anim = Timed::resuming_at(now, incoming_from, ms, Curve::EasyEase);
98+
}
99+
100+
/// Hover weight for `id` in `[0, 1]`.
101+
pub fn weight(&self, id: Id, now: Instant) -> f32 {
102+
if self.to == Some(id) {
103+
return self.to_anim.progress(now);
104+
}
105+
if let Some((from_id, held)) = self.from
106+
&& from_id == id
107+
{
108+
return held * (1.0 - self.from_anim.progress(now));
109+
}
110+
0.0
111+
}
112+
113+
/// Whether another frame is needed.
114+
pub fn is_active(&self, now: Instant) -> bool {
115+
(self.to.is_some() && !self.to_anim.is_done(now))
116+
|| (self.from.is_some() && !self.from_anim.is_done(now))
117+
}
118+
119+
/// The item currently being hovered, as far as the transition knows.
120+
///
121+
/// No production caller yet — Task 3 (settings rows) reads `weight`
122+
/// directly and Task 4 (context menu) may or may not need this. Exercised
123+
/// by this module's own tests, which does not count for a non-test build.
124+
#[allow(dead_code)]
125+
pub fn target(&self) -> Option<Id> {
126+
self.to
127+
}
128+
}
129+
130+
#[cfg(test)]
131+
mod tests {
132+
use super::*;
133+
use nexterm_config::AnimationsConfig;
134+
use std::time::Duration;
135+
136+
fn on() -> AnimationsConfig {
137+
AnimationsConfig::default()
138+
}
139+
140+
fn off() -> AnimationsConfig {
141+
AnimationsConfig {
142+
enabled: false,
143+
..AnimationsConfig::default()
144+
}
145+
}
146+
147+
/// 100 ms is `duration::FASTER`, the constant both P3b2 models use.
148+
const MS: u64 = 100;
149+
150+
#[test]
151+
fn a_fresh_transition_weighs_nothing() {
152+
let h: HoverTransition<u32> = HoverTransition::default();
153+
let now = Instant::now();
154+
assert!(h.weight(1, now).abs() < 1e-4);
155+
assert!(!h.is_active(now));
156+
assert_eq!(h.target(), None);
157+
}
158+
159+
#[test]
160+
fn entering_an_item_fades_it_in() {
161+
let mut h: HoverTransition<u32> = HoverTransition::default();
162+
let t0 = Instant::now();
163+
h.retarget(Some(7), t0, &on());
164+
assert_eq!(h.target(), Some(7));
165+
assert!(h.weight(7, t0).abs() < 1e-3);
166+
assert!(h.is_active(t0));
167+
let done = t0 + Duration::from_millis(MS);
168+
assert!((h.weight(7, done) - 1.0).abs() < 1e-3);
169+
assert!(!h.is_active(done));
170+
}
171+
172+
/// The cross-fade: the item being left and the item being entered are
173+
/// complementary at every instant, and nothing else weighs anything.
174+
#[test]
175+
fn moving_between_items_cross_fades_them() {
176+
let mut h: HoverTransition<u32> = HoverTransition::default();
177+
let t0 = Instant::now();
178+
h.retarget(Some(1), t0, &on());
179+
let settled = t0 + Duration::from_millis(MS);
180+
h.retarget(Some(2), settled, &on());
181+
182+
let mid = settled + Duration::from_millis(50);
183+
let (w1, w2) = (h.weight(1, mid), h.weight(2, mid));
184+
assert!(
185+
w1 > 0.1 && w1 < 0.9,
186+
"outgoing item should be mid-fade: {w1}"
187+
);
188+
assert!(
189+
w2 > 0.1 && w2 < 0.9,
190+
"incoming item should be mid-fade: {w2}"
191+
);
192+
assert!((w1 + w2 - 1.0).abs() < 1e-3, "must be complementary");
193+
assert!(h.weight(3, mid).abs() < 1e-4, "untouched item weighs 0");
194+
195+
let done = settled + Duration::from_millis(MS);
196+
assert!(h.weight(1, done).abs() < 1e-3);
197+
assert!((h.weight(2, done) - 1.0).abs() < 1e-3);
198+
}
199+
200+
/// Leaving the model entirely still fades the last item out — this is why
201+
/// the transition cannot live on the logical hover state, which goes
202+
/// `None` the moment the pointer leaves.
203+
#[test]
204+
fn leaving_fades_the_last_item_out() {
205+
let mut h: HoverTransition<u32> = HoverTransition::default();
206+
let t0 = Instant::now();
207+
h.retarget(Some(1), t0, &on());
208+
let settled = t0 + Duration::from_millis(MS);
209+
h.retarget(None, settled, &on());
210+
assert_eq!(h.target(), None);
211+
212+
let mid = settled + Duration::from_millis(50);
213+
let w = h.weight(1, mid);
214+
assert!(w > 0.1 && w < 0.9, "must still be drawn while fading: {w}");
215+
assert!(h.is_active(mid));
216+
217+
let done = settled + Duration::from_millis(MS);
218+
assert!(h.weight(1, done).abs() < 1e-3);
219+
assert!(!h.is_active(done));
220+
}
221+
222+
/// Retargeting to the same id must not restart the fade — `retarget` is
223+
/// called from a per-motion handler that fires far more often than the
224+
/// hovered id changes.
225+
#[test]
226+
fn retargeting_the_same_item_is_a_no_op() {
227+
let mut h: HoverTransition<u32> = HoverTransition::default();
228+
let t0 = Instant::now();
229+
h.retarget(Some(7), t0, &on());
230+
let mid = t0 + Duration::from_millis(50);
231+
let before = h.weight(7, mid);
232+
h.retarget(Some(7), mid, &on());
233+
let after = h.weight(7, mid);
234+
assert!(
235+
(after - before).abs() < 1e-4,
236+
"fade restarted: {before} -> {after}"
237+
);
238+
// And it still finishes on the original schedule.
239+
assert!((h.weight(7, t0 + Duration::from_millis(MS)) - 1.0).abs() < 1e-3);
240+
}
241+
242+
/// The defect the two-timer design exists to prevent. A single `Timed`
243+
/// with the pair summing to 1 makes the *incoming* item jump to whatever
244+
/// the outgoing one held — here 0.5 — the instant the pointer crosses
245+
/// the boundary. Sweeping a list crosses boundaries faster than the
246+
/// 100 ms fade, so that jump is the common case, not the corner.
247+
#[test]
248+
fn interrupting_mid_fade_jumps_neither_item() {
249+
let mut h: HoverTransition<u32> = HoverTransition::default();
250+
let t0 = Instant::now();
251+
h.retarget(Some(1), t0, &on());
252+
let mid = t0 + Duration::from_millis(50);
253+
let out_before = h.weight(1, mid);
254+
assert!(
255+
out_before > 0.1 && out_before < 0.9,
256+
"the test needs item 1 genuinely mid-fade: {out_before}"
257+
);
258+
259+
h.retarget(Some(2), mid, &on());
260+
261+
let out_after = h.weight(1, mid);
262+
assert!(
263+
(out_after - out_before).abs() < 1e-3,
264+
"the outgoing item jumped: {out_before} -> {out_after}"
265+
);
266+
let in_after = h.weight(2, mid);
267+
assert!(
268+
in_after.abs() < 1e-3,
269+
"the incoming item must start from nothing, not from the \
270+
outgoing item's weight: {in_after}"
271+
);
272+
}
273+
274+
/// Coming back to the item that is still fading out must resume it, not
275+
/// restart it from 0 — the pointer wobbling on a row boundary is the
276+
/// gesture this covers.
277+
#[test]
278+
fn returning_to_a_fading_item_resumes_it() {
279+
let mut h: HoverTransition<u32> = HoverTransition::default();
280+
let t0 = Instant::now();
281+
h.retarget(Some(1), t0, &on());
282+
let settled = t0 + Duration::from_millis(100);
283+
h.retarget(Some(2), settled, &on());
284+
285+
let mid = settled + Duration::from_millis(50);
286+
let held = h.weight(1, mid);
287+
assert!(
288+
held > 0.1 && held < 0.9,
289+
"item 1 should be mid-decay: {held}"
290+
);
291+
292+
h.retarget(Some(1), mid, &on());
293+
let resumed = h.weight(1, mid);
294+
assert!(
295+
(resumed - held).abs() < 5e-2,
296+
"returning restarted the fade: {held} -> {resumed}"
297+
);
298+
// And it climbs back to 1 rather than stalling.
299+
assert!((h.weight(1, mid + Duration::from_millis(100)) - 1.0).abs() < 1e-2);
300+
}
301+
302+
/// The reduced-motion path.
303+
#[test]
304+
fn disabled_animations_snap() {
305+
let mut h: HoverTransition<u32> = HoverTransition::default();
306+
let t0 = Instant::now();
307+
h.retarget(Some(1), t0, &off());
308+
assert!((h.weight(1, t0) - 1.0).abs() < 1e-4);
309+
assert!(!h.is_active(t0));
310+
h.retarget(Some(2), t0, &off());
311+
assert!(h.weight(1, t0).abs() < 1e-4);
312+
assert!((h.weight(2, t0) - 1.0).abs() < 1e-4);
313+
assert!(!h.is_active(t0));
314+
}
315+
}

nexterm-client-gpu/src/animations/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,16 @@
2323
2424
mod curve;
2525
mod easing;
26+
mod hover;
2627
mod surface;
2728
mod timed;
2829

2930
// `Curve` and `duration` are re-exported for `Timed`'s callers; the
3031
// settings panel (Task 6 of UI/UX v3 P3a) is the first consumer.
3132
pub use curve::{Curve, duration};
3233
pub use easing::{compute_progress, ease_out_cubic};
34+
// Consumed by the two overlay models in Tasks 3 and 4.
35+
pub use hover::HoverTransition;
3336
pub use surface::SurfaceMotion;
3437
pub use timed::Timed;
3538

0 commit comments

Comments
 (0)