Skip to content

Commit b96e6e0

Browse files
mizu-junclaudehappy-otter
authored
feat(client): time-based motion foundation and settings-panel transitions (UI/UX v3 P3a) (#77)
* docs(spec): P3a motion foundation design Timed animations with the Fluent curve/duration tables, animation-driven redraw, and the settings-panel open/close migration as the first consumer. Records that the P3 acceptance criterion names build_pane_vertices, which does not exist; the measurable equivalent is pane-cache misses per second. 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): P3a motion foundation implementation plan Seven tasks: split animations.rs into a module, add the Fluent Curve and duration tables, add Timed, count pane-vertex-cache misses, let animations request their own frames, migrate the settings panel open/close, document. 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): fix three P3a plan defects found in the pre-flight scan - The transcribed Curve and duration tables need #[allow(dead_code)]: this is a binary crate, so unconstructed variants and unread constants fail the build under -D warnings. - Task 4's prose and its code block disagreed on where the counter test module goes. - Tasks 5 and 6 named a mod tests in state/mod.rs that does not exist; the only test block there is pane_border_hit_tests. * refactor(client): split animations.rs into an animations/ module Pure code movement ahead of UI/UX v3 P3a, which adds Curve and Timed. No behaviour change; the same tests run and pass. * feat(client): add the Fluent 2 motion curve and duration tables Nine cubic-bezier curves and eight duration steps, transcribed from microsoft/fluentui packages/tokens. The solver mirrors CSS cubic-bezier: Newton-Raphson on X with a bisection fallback for the two curves whose X-derivative vanishes at an endpoint. Since this crate is a binary (no lib.rs), items that Task 3 and Tasks 5/6 will later consume are dead code from clippy's point of view right now. Beyond the two allow(dead_code) already anticipated for the Curve enum and the duration module, -D warnings also flagged the impl Curve methods, the three private solver helpers, and the mod.rs re-export itself; each now carries its own allow with a comment pointing at the task that starts consuming it. * feat(client): add Timed, a curve-eased time-based animation value A Timed stores start, duration and curve and answers about any instant, so consumers stay testable without a clock. A zero duration -- what AnimationsConfig yields when animations are off -- is born finished, which is the whole reduced-motion path. resuming_at expresses an interruption as "continue from the value already on screen". Deviates from the plan's literal resuming_at/raw_progress bodies: they kept elapsed time in full Duration precision instead of rounding to whole milliseconds and round-tripping through compute_progress's Duration::as_millis() truncation. Verified by exhaustive search that no whole-millisecond reconstruction can satisfy resuming_at_starts_from_the_requested_value's tolerance for Curve::AccelerateMax at duration_ms = 200 -- the quantization error is amplified past tolerance by that curve's steep tail near t = 1. * docs(plan): add the allow-attribute cleanup and the spec correction Task 6 retires the placeholder #[allow] attributes its own consumption makes unnecessary. Task 7 corrects the spec's Timed section, which stated a delegation to compute_progress that Task 3 proved unusable. * feat(client): count pane-vertex-cache misses under NEXTERM_LOG=trace The UI/UX v3 P3 acceptance criterion names build_pane_vertices, which does not exist; the measurable equivalent is a miss on the C4 pane cache. This adds the counter and a once-per-second trace line, which is also the first instrument for the cursor-blink invalidation debt in audit-round3 P3. * feat(client): let running animations request their own frames AnimationManager::has_active_animation had been dead code since it was written, so a spring mid-flight only advanced when an unrelated redraw happened. ClientState::has_active_animation aggregates it, and the event loop requests a redraw only while it is true — an idle terminal asks for exactly the frames it asked for before. * feat(client): animate the settings panel open and close with Timed The panel's entrance was a frame-count hack (open_progress += 0.15, 'assumes 60 fps'), so its real duration drifted with frame rate and it ignored animations.intensity entirely; there was no exit animation at all. It now runs on Timed: a 200 ms Fluent Direct Entrance in, a 150 ms Gentle Exit out, both scaled by the configured intensity. is_open stays the single truth for input routing and the AccessKit tree and still goes false the instant the user dismisses the panel. The new closing field is render-only — the renderer's permission to keep drawing the panel while it fades. * docs: record the P3 split and the animations/ module layout Also corrects the P3 acceptance criterion, which named a build_pane_vertices function that does not exist. The measurable equivalent is the pane-vertex cache miss rate, which P3a now counts. * refactor(client): split settings open/close animation tests into their own file nexterm-client-gpu/src/settings/mod.rs grew past the project's 800-line ceiling on this branch, mostly from the open_close_animation_tests module Task 6 appended. Move that module's body into settings/open_close_animation_tests.rs (same pattern already used for the animations/ split earlier on this branch) and leave a `#[cfg(test)] mod open_close_animation_tests;` declaration in mod.rs. No test logic changed. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Happy <yesreply@happy.engineering>
1 parent 7e20ba2 commit b96e6e0

24 files changed

Lines changed: 2920 additions & 144 deletions

docs/plans/ui-ux-modernization-v3.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -200,10 +200,17 @@ Addresses G6 (principles: Effortless, Calm).
200200
`accessibilityDisplayShouldReduceMotion` (shares the objc2 dependency
201201
decision with P2); Linux: manual `animations.enabled` config remains the
202202
fallback. Detection only ever *disables* motion.
203-
- Acceptance: idle `build_pane_vertices` call count does not regress
204-
(measured with the tracing counter recommended by
205-
`plans/audit-round3-2026h2.md` P3); reduced-motion ON renders every
206-
animation instantly.
203+
- P3 ships in three PRs: **P3a** motion foundation (`Timed`, the Fluent
204+
curve/duration tables, animation-driven redraw, settings-panel open/close),
205+
**P3b** widget hover/press and overlay open-close, **P3c** OS
206+
reduced-motion detection.
207+
- Acceptance: the idle pane-vertex-cache miss rate does not regress
208+
(measured with the counter added in P3a — `NEXTERM_LOG=trace`); with
209+
reduced motion on, every animation renders instantly. The criterion
210+
previously named `build_pane_vertices`, which does not exist in the
211+
codebase; the C4 pane cache miss is the equivalent, and the cursor-blink
212+
invalidation debt behind it stays tracked in
213+
`plans/audit-round3-2026h2.md` P3.
207214

208215
### P4 — Iconography & chrome typography (M)
209216

@@ -516,6 +523,9 @@ gated behind a spike.
516523
the Linux runners — a wrong constant fails there instead of reaching a
517524
Windows release
518525
- [ ] P3 motion language + reduced-motion detection
526+
- [ ] P3a motion foundation (`Timed`, Fluent curves, animation-driven redraw, settings panel)
527+
- [ ] P3b widget and overlay motion
528+
- [ ] P3c OS reduced-motion detection
519529
- [ ] P4 icon font + chrome type ramp
520530
- [ ] P5 contrast everywhere + high-contrast scheme
521531
- [ ] P6 InfoBar + consent reclassification

docs/superpowers/plans/2026-08-28-p3a-motion-foundation.md

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

docs/superpowers/specs/2026-08-28-p3a-motion-foundation-design.md

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

nexterm-client-gpu/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,4 @@ Guidance for working inside the GPU client crate. The repo-wide rules — langua
2222
- `update_checker.rs` — Polls the GitHub Releases API five seconds after startup. Disabled by `auto_check_update = false`. Results land in `ClientState.update_banner`; `Esc` dismisses, `Enter` opens the release page.
2323
- `platform.rs` — Platform-specific utilities. `apply_backdrop` applies the configured `window.backdrop` material: Windows via `DwmSetWindowAttribute(DWMWA_SYSTEMBACKDROP_TYPE)` (Windows 11 build 22621+; a no-op below that), macOS via `window-vibrancy`, Linux not at all. The value mapping, `dwm_backdrop_value`, is a plain `const fn` compiled on every platform so it is testable without Windows. `open_releases_url` opens the release page in the default browser.
2424
- `renderer/background_pass.rs` — Background image rendering (Sprint 5-7 / Phase 3-1). When `WindowConfig.background_image` is set, the image is loaded at startup and each frame draws clear → background image → cell backgrounds → text. NDC + UV computation for each fit mode (cover/contain/stretch/center/tile) lives in the pure function `compute_background_quad`, with 11 unit tests. Images larger than 4096×4096 are downscaled with Lanczos3. Tile mode falls back to stretch when the tile count exceeds 256 (defensive). Reuses the existing `image_pipeline` (used for Sixel/Kitty) instead of introducing a separate one. Supported formats: PNG / JPEG (whichever features are enabled in the workspace `image` crate).
25-
- `animations.rs` — UI animation foundation (Sprint 5-7 / Phase 3-2). Easing helpers (`ease_out_cubic`, `linear`, …) and `AnimationManager` (timestamps for tab switches and pane insertions). The renderer queries progress in [0,1] via `tab_switch_progress(now, duration)` / `pane_fade_in_progress(id, now, duration)`. When `Config.animations.enabled = false` or `intensity = "off"`, `scaled_duration_ms` returns 0 and all animations apply instantly (reduced-motion support). `intensity` has four levels: `off`, `subtle` (×0.5), `normal` (×1.0), `energetic` (×1.5). Tab switching is a 200 ms ease-out (accent line expands from the center, with fade-in); new pane insertion is a 250 ms white overlay fading from alpha 0.35 to 0.
25+
- `animations/` — UI animation foundation. `mod.rs` holds `AnimationManager` and the spring physics (`SpringState`) for the tab accent and per-pane dim; `easing.rs` the time-based helpers (`ease_out_cubic`, `compute_progress`); `curve.rs` the nine Fluent 2 cubic-bezier curves and eight duration steps, transcribed from `microsoft/fluentui` `packages/tokens` (do not re-derive them by eye); `timed.rs` the `Timed { start, duration_ms, curve }` value type. Springs are for motion interrupted by a new target; `Timed` is for transitions with a known start, end and duration. A zero duration — what `AnimationsConfig::scaled_duration_ms` returns when `enabled = false` or `intensity = "off"` — makes a `Timed` finished on creation, which is the whole reduced-motion path. `ClientState::has_active_animation` is the one place that decides whether the event loop requests another frame; a surface that gains a `Timed` adds a clause there or it will simply never animate.
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
//! Fluent 2 motion curves and durations (UI/UX v3 P3a).
2+
//!
3+
//! Values are transcribed from the Fluent UI implementation repository —
4+
//! `microsoft/fluentui`, `packages/tokens/src/global/curves.ts` and
5+
//! `durations.ts`. The Fluent 2 design site documents motion qualitatively
6+
//! and publishes no token values, so the implementation repo is the source
7+
//! of truth here. Do not re-derive these by eye.
8+
//!
9+
//! All nine curves are defined even though P3a uses two: a partial copy of
10+
//! an external table invites a later change to guess at a missing constant.
11+
//! They are `const fn` data with no runtime cost.
12+
13+
/// Animation durations from the Fluent 2 token set, in milliseconds.
14+
///
15+
/// `dead_code` is allowed for the module as a whole: this is a verbatim
16+
/// transcription of an external table, and the steps P3a does not consume
17+
/// yet are consumed by P3b. Silencing them individually as they are picked
18+
/// up would churn this file for no gain.
19+
#[allow(dead_code)]
20+
pub mod duration {
21+
/// Checkbox tick, toggle snap.
22+
pub const ULTRA_FAST: u32 = 50;
23+
/// Button press feedback.
24+
pub const FASTER: u32 = 100;
25+
/// Small control state changes.
26+
pub const FAST: u32 = 150;
27+
/// Panel slide, card expand.
28+
pub const NORMAL: u32 = 200;
29+
/// Slightly softer than `NORMAL`.
30+
pub const GENTLE: u32 = 250;
31+
/// Dialog entrance, page transition.
32+
pub const SLOW: u32 = 300;
33+
/// Large-surface movement.
34+
pub const SLOWER: u32 = 400;
35+
/// Full-screen morph.
36+
pub const ULTRA_SLOW: u32 = 500;
37+
}
38+
39+
/// A Fluent 2 easing curve, expressed as a CSS-style cubic bezier with
40+
/// `P0 = (0, 0)` and `P3 = (1, 1)`.
41+
///
42+
/// Accelerate curves start slow and leave quickly (use them for exits);
43+
/// decelerate curves arrive quickly and settle (use them for entrances).
44+
///
45+
/// `dead_code` is allowed for the same reason as `duration` above: the
46+
/// table is transcribed whole, and the variants P3a does not construct are
47+
/// P3b's to construct.
48+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49+
#[allow(dead_code)]
50+
pub enum Curve {
51+
/// No easing. Progress bars and spinners.
52+
Linear,
53+
AccelerateMax,
54+
AccelerateMid,
55+
AccelerateMin,
56+
DecelerateMax,
57+
DecelerateMid,
58+
DecelerateMin,
59+
EasyEaseMax,
60+
EasyEase,
61+
}
62+
63+
impl Curve {
64+
/// The `(x1, y1, x2, y2)` control points, matching the CSS
65+
/// `cubic-bezier()` argument order.
66+
pub const fn control_points(self) -> (f32, f32, f32, f32) {
67+
match self {
68+
Curve::Linear => (0.0, 0.0, 1.0, 1.0),
69+
Curve::AccelerateMax => (0.9, 0.1, 1.0, 0.2),
70+
Curve::AccelerateMid => (1.0, 0.0, 1.0, 1.0),
71+
Curve::AccelerateMin => (0.8, 0.0, 0.78, 1.0),
72+
Curve::DecelerateMax => (0.1, 0.9, 0.2, 1.0),
73+
Curve::DecelerateMid => (0.0, 0.0, 0.0, 1.0),
74+
Curve::DecelerateMin => (0.33, 0.0, 0.1, 1.0),
75+
Curve::EasyEaseMax => (0.8, 0.0, 0.2, 1.0),
76+
Curve::EasyEase => (0.33, 0.0, 0.67, 1.0),
77+
}
78+
}
79+
80+
/// Map elapsed-time fraction `t` to eased progress, both in `[0, 1]`.
81+
pub fn eval(self, t: f32) -> f32 {
82+
let t = t.clamp(0.0, 1.0);
83+
if self == Curve::Linear {
84+
return t;
85+
}
86+
let (x1, y1, x2, y2) = self.control_points();
87+
let s = solve_for_x(x1, x2, t);
88+
axis(y1, y2, s).clamp(0.0, 1.0)
89+
}
90+
91+
/// The `t` whose eased value is `value` — the inverse of [`Curve::eval`].
92+
///
93+
/// Used to resume an interrupted animation from the value already on
94+
/// screen. `eval` is monotone for every curve in this table, so a plain
95+
/// bisection is exact enough and cannot diverge.
96+
pub fn invert(self, value: f32) -> f32 {
97+
let value = value.clamp(0.0, 1.0);
98+
if self == Curve::Linear {
99+
return value;
100+
}
101+
let (mut lo, mut hi) = (0.0f32, 1.0f32);
102+
for _ in 0..24 {
103+
let mid = 0.5 * (lo + hi);
104+
if self.eval(mid) < value {
105+
lo = mid;
106+
} else {
107+
hi = mid;
108+
}
109+
}
110+
0.5 * (lo + hi)
111+
}
112+
}
113+
114+
/// One axis of a cubic bezier with the endpoints pinned to 0 and 1.
115+
fn axis(p1: f32, p2: f32, s: f32) -> f32 {
116+
let u = 1.0 - s;
117+
3.0 * u * u * s * p1 + 3.0 * u * s * s * p2 + s * s * s
118+
}
119+
120+
/// Derivative of [`axis`] with respect to `s`.
121+
fn axis_derivative(p1: f32, p2: f32, s: f32) -> f32 {
122+
let u = 1.0 - s;
123+
3.0 * u * u * p1 + 6.0 * u * s * (p2 - p1) + 3.0 * s * s * (1.0 - p2)
124+
}
125+
126+
/// Find the curve parameter `s` with `X(s) = t`.
127+
///
128+
/// Newton-Raphson seeded at `s = t` converges in a couple of iterations for
129+
/// the well-conditioned curves. `AccelerateMid` and `DecelerateMid` have a
130+
/// zero X-derivative at an endpoint, where Newton stalls or steps outside
131+
/// `[0, 1]`; bisection then finishes the job. X is monotone in `s` for every
132+
/// curve in the table (all control points lie in `[0, 1]`), so bisection
133+
/// always converges.
134+
fn solve_for_x(x1: f32, x2: f32, t: f32) -> f32 {
135+
const EPSILON: f32 = 1e-6;
136+
137+
let mut s = t;
138+
for _ in 0..8 {
139+
let err = axis(x1, x2, s) - t;
140+
if err.abs() < EPSILON {
141+
return s;
142+
}
143+
let d = axis_derivative(x1, x2, s);
144+
if d.abs() < EPSILON {
145+
break;
146+
}
147+
s -= err / d;
148+
if !(0.0..=1.0).contains(&s) {
149+
break;
150+
}
151+
}
152+
153+
let (mut lo, mut hi) = (0.0f32, 1.0f32);
154+
for _ in 0..30 {
155+
let mid = 0.5 * (lo + hi);
156+
if axis(x1, x2, mid) < t {
157+
lo = mid;
158+
} else {
159+
hi = mid;
160+
}
161+
}
162+
0.5 * (lo + hi)
163+
}
164+
165+
#[cfg(test)]
166+
mod tests {
167+
use super::*;
168+
169+
/// Every curve this project uses.
170+
const ALL: [Curve; 9] = [
171+
Curve::Linear,
172+
Curve::AccelerateMax,
173+
Curve::AccelerateMid,
174+
Curve::AccelerateMin,
175+
Curve::DecelerateMax,
176+
Curve::DecelerateMid,
177+
Curve::DecelerateMin,
178+
Curve::EasyEaseMax,
179+
Curve::EasyEase,
180+
];
181+
182+
#[test]
183+
fn every_curve_starts_at_0_and_ends_at_1() {
184+
for c in ALL {
185+
assert!(c.eval(0.0).abs() < 1e-3, "{c:?} at 0");
186+
assert!((c.eval(1.0) - 1.0).abs() < 1e-3, "{c:?} at 1");
187+
}
188+
}
189+
190+
#[test]
191+
fn every_curve_is_monotonically_increasing() {
192+
for c in ALL {
193+
let mut prev = -1.0;
194+
for i in 0..=100 {
195+
let v = c.eval(i as f32 / 100.0);
196+
assert!(v >= prev - 1e-4, "{c:?} dipped at t={}", i as f32 / 100.0);
197+
prev = v;
198+
}
199+
}
200+
}
201+
202+
#[test]
203+
fn linear_is_the_identity() {
204+
for i in 0..=10 {
205+
let t = i as f32 / 10.0;
206+
assert!((Curve::Linear.eval(t) - t).abs() < 1e-6);
207+
}
208+
}
209+
210+
#[test]
211+
fn out_of_range_inputs_clamp() {
212+
for c in ALL {
213+
assert!(c.eval(-1.0).abs() < 1e-3, "{c:?} below 0");
214+
assert!((c.eval(2.0) - 1.0).abs() < 1e-3, "{c:?} above 1");
215+
}
216+
}
217+
218+
/// `EasyEaseMax` (0.8, 0, 0.2, 1) and `EasyEase` (0.33, 0, 0.67, 1) are
219+
/// both point-symmetric about (0.5, 0.5) — x2 = 1-x1 and y2 = 1-y1 — so
220+
/// their midpoint is exactly 0.5. This is the one closed-form value the
221+
/// solver can be checked against without a reference implementation.
222+
#[test]
223+
fn symmetric_curves_pass_through_their_midpoint() {
224+
assert!((Curve::EasyEaseMax.eval(0.5) - 0.5).abs() < 1e-3);
225+
assert!((Curve::EasyEase.eval(0.5) - 0.5).abs() < 1e-3);
226+
}
227+
228+
#[test]
229+
fn accelerate_lags_linear_and_decelerate_leads_it() {
230+
assert!(Curve::AccelerateMax.eval(0.5) < 0.5);
231+
assert!(Curve::DecelerateMax.eval(0.5) > 0.5);
232+
}
233+
234+
/// `AccelerateMid` (1, 0, 1, 1) has a zero X-derivative at t=1 and
235+
/// `DecelerateMid` (0, 0, 0, 1) has one at t=0. Newton-Raphson stalls
236+
/// there; these two exist to exercise the bisection fallback directly.
237+
#[test]
238+
fn degenerate_curves_still_solve() {
239+
for c in [Curve::AccelerateMid, Curve::DecelerateMid] {
240+
for i in 0..=20 {
241+
let t = i as f32 / 20.0;
242+
let v = c.eval(t);
243+
assert!(v.is_finite(), "{c:?} not finite at {t}");
244+
assert!((0.0..=1.0).contains(&v), "{c:?} out of range at {t}: {v}");
245+
}
246+
}
247+
}
248+
249+
#[test]
250+
fn invert_round_trips_through_eval() {
251+
for c in ALL {
252+
for i in 0..=10 {
253+
let v = i as f32 / 10.0;
254+
let t = c.invert(v);
255+
assert!(
256+
(c.eval(t) - v).abs() < 1e-2,
257+
"{c:?}: invert({v}) = {t}, eval back = {}",
258+
c.eval(t)
259+
);
260+
}
261+
}
262+
}
263+
264+
#[test]
265+
fn durations_match_the_fluent_table() {
266+
assert_eq!(duration::ULTRA_FAST, 50);
267+
assert_eq!(duration::FASTER, 100);
268+
assert_eq!(duration::FAST, 150);
269+
assert_eq!(duration::NORMAL, 200);
270+
assert_eq!(duration::GENTLE, 250);
271+
assert_eq!(duration::SLOW, 300);
272+
assert_eq!(duration::SLOWER, 400);
273+
assert_eq!(duration::ULTRA_SLOW, 500);
274+
}
275+
}

0 commit comments

Comments
 (0)