feat(client): time-based motion foundation and settings-panel transitions (UI/UX v3 P3a) - #77
Merged
Conversation
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>
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>
- 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.
Pure code movement ahead of UI/UX v3 P3a, which adds Curve and Timed. No behaviour change; the same tests run and pass.
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.
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.
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.
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.
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.
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.
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.
…r 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.
Coverage reportGenerated by |
mizu-jun
added a commit
that referenced
this pull request
Aug 28, 2026
…nverified (#78) P3a is checked off with what it changed and the two corrections it carried: the acceptance criterion named a build_pane_vertices that does not exist, and the spec had Timed delegate to compute_progress, whose millisecond truncation moves AccelerateMax by ~0.1 near its tail. The on-device backlog gains a P3a entry. Motion is the first item on that list a still screenshot cannot capture, and the criterion's own number — the idle pane-vertex-cache miss rate — still needs a real session to read.
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
UI/UX v3 P3a — the first of three PRs for the motion-language phase.
Timedanimations, the Fluent curve/duration tables, animation-driven redraw, the settings panel as first consumerSPI_GETCLIENTAREAANIMATION, macOSaccessibilityDisplayShouldReduceMotion)Why
Before this PR the client could not animate anything that wasn't already
being driven by some other source of frames.
AnimationManager::has_active_animationhad been dead code since it was written, so a spring mid-flight advanced only
when an unrelated redraw happened — on an idle terminal it simply stopped at
whatever value it had reached.
The one UI animation that did run, the settings-panel entrance, worked by pumping
its own redraws with
open_progress += 0.15and a comment reading "assumes 60 fps".Its real duration drifted with frame rate, it ignored
animations.intensityentirely, and there was no close animation at all —
close()reset the progressto 0 and the panel vanished on the next frame.
What changed
animations/module —animations.rs(660 lines) split intomod.rs(springs,
AnimationManager),easing.rs,curve.rsandtimed.rs.Curve— the nine Fluent 2 cubic-bezier tokens with a CSS-equivalentsolver: Newton-Raphson on X, falling back to bisection for the two curves
whose X-derivative vanishes at an endpoint. Values are transcribed from
microsoft/fluentuipackages/tokens/src/global/curves.ts; the Fluent 2design site documents motion qualitatively and publishes no token values.
duration— the eight Fluent 2 duration steps (50 – 500 ms).Timed { start, duration_ms, curve }— a value, not a running object.Nothing ticks it; it answers about any instant you hand it.
resuming_atexpresses an interruption as "continue from the value already on screen".
ClientState::has_active_animationis queriedonce per event-loop tick, and a redraw is requested only while it is true.
out, both scaled by the configured intensity.
is_openremains the singletruth for input routing and the AccessKit tree and still goes false the instant
the user dismisses the panel; the new
closingfield is render-only.NEXTERM_LOG=trace.No new config key, no new user-facing string, no dependency change.
Reduced motion
AnimationsConfig::scaled_duration_msalready returns 0 whenanimations.enabled = falseorintensity = "off". ATimedbuilt with azero duration is finished the moment it is created, so every animation applies
instantly and nothing requests a frame on its behalf. That is the whole path —
no special-casing at any call site. OS-level detection lands in P3c.
Two things this PR corrects
The P3 acceptance criterion named a function that does not exist. The plan
said "idle
build_pane_verticescall count does not regress". There is nobuild_pane_verticesin the workspace. The equivalent is a miss on the C4per-pane vertex cache, which is the only path that rebuilds a pane's cell
vertices. The plan now says so, and the counter added here makes it measurable
for the first time — including the cursor-blink invalidation debt tracked as P3
in
plans/audit-round3-2026h2.md, which has been marked "needs measurement"ever since it was written.
The spec's
Timedsection described a mechanism that does not work. It hadprogressdelegate tocompute_progress, which truncates elapsed time viaDuration::as_millis(). That quantises the recovered curve parameter in stepsof
1/duration_ms; at 200 ms onCurve::AccelerateMaxthe best achievableerror at value 0.9 is 0.10 — verified by exhaustive search over all 201
reachable millisecond values. This was not academic: the settings-panel exit
uses exactly that curve over 150 ms, so reopening mid-fade would have visibly
jumped.
Timedkeeps fullDurationprecision internally instead, and the specnow records that.
Test plan
cargo test— 1781 passed, 0 failedcargo clippy --all-targets -- -D warnings— cleancargo fmt --check— cleanCargo.lockunchanged, so no flatpak sources regenerationdoc_matches_schemagreen — no config key addedreturns
falsefromhas_active_animation, so an idle terminal requestsexactly the frames it requested before this PR
right, and whether the Fluent curves read as intended at Nexterm's panel
size, cannot be judged from CI or from a container. Neither can the idle
cache-miss reading, which needs a real session. This joins the on-device
verification backlog that P2a–P2c already contribute to.
Docs
docs/superpowers/specs/2026-08-28-p3a-motion-foundation-design.md(design),docs/superpowers/plans/2026-08-28-p3a-motion-foundation.md(plan),nexterm-client-gpu/CLAUDE.md(crate guide) anddocs/plans/ui-ux-modernization-v3.md(phase plan) are all updated.🤖 Generated with Claude Code