Skip to content

feat(client): time-based motion foundation and settings-panel transitions (UI/UX v3 P3a) - #77

Merged
mizu-jun merged 12 commits into
masterfrom
p3a-motion-foundation
Aug 28, 2026
Merged

feat(client): time-based motion foundation and settings-panel transitions (UI/UX v3 P3a)#77
mizu-jun merged 12 commits into
masterfrom
p3a-motion-foundation

Conversation

@mizu-jun

Copy link
Copy Markdown
Owner

UI/UX v3 P3a — the first of three PRs for the motion-language phase.

PR Scope
P3a (this) Timed animations, the Fluent curve/duration tables, animation-driven redraw, the settings panel as first consumer
P3b Widget hover/press, dialogs, flyouts, tooltips
P3c OS reduced-motion detection (Windows SPI_GETCLIENTAREAANIMATION, macOS accessibilityDisplayShouldReduceMotion)

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_animation
had 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.15 and a comment reading "assumes 60 fps".
Its real duration drifted with frame rate, it ignored animations.intensity
entirely, and there was no close animation at all — close() reset the progress
to 0 and the panel vanished on the next frame.

What changed

  • animations/ moduleanimations.rs (660 lines) split into mod.rs
    (springs, AnimationManager), easing.rs, curve.rs and timed.rs.
  • Curve — the nine Fluent 2 cubic-bezier tokens with a CSS-equivalent
    solver: Newton-Raphson on X, falling back to bisection for the two curves
    whose X-derivative vanishes at an endpoint. Values are transcribed from
    microsoft/fluentui packages/tokens/src/global/curves.ts; the Fluent 2
    design 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_at
    expresses an interruption as "continue from the value already on screen".
  • Animation-driven redrawClientState::has_active_animation is queried
    once per event-loop tick, and a redraw is requested only while it is true.
  • Settings panel — a 200 ms Fluent Direct Entrance in, a 150 ms Gentle Exit
    out, both scaled by the configured intensity. is_open remains 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.
  • Pane-vertex-cache-miss counter under NEXTERM_LOG=trace.

No new config key, no new user-facing string, no dependency change.

Reduced motion

AnimationsConfig::scaled_duration_ms already returns 0 when
animations.enabled = false or intensity = "off". A Timed built with a
zero 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_vertices call count does not regress". There is no
build_pane_vertices in the workspace. The equivalent is a miss on the C4
per-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 Timed section described a mechanism that does not work. It had
progress delegate to compute_progress, which truncates elapsed time via
Duration::as_millis(). That quantises the recovered curve parameter in steps
of 1/duration_ms; at 200 ms on Curve::AccelerateMax the best achievable
error 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. Timed keeps full Duration precision internally instead, and the spec
now records that.

Test plan

  • cargo test — 1781 passed, 0 failed
  • cargo clippy --all-targets -- -D warnings — clean
  • cargo fmt --check — clean
  • Cargo.lock unchanged, so no flatpak sources regeneration
  • doc_matches_schema green — no config key added
  • The acceptance criterion as a unit test: a state with nothing animating
    returns false from has_active_animation, so an idle terminal requests
    exactly the frames it requested before this PR
  • Not run — on-device. Whether the 200 ms entrance and 150 ms exit feel
    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) and
docs/plans/ui-ux-modernization-v3.md (phase plan) are all updated.

🤖 Generated with Claude Code

mizu-jun and others added 12 commits August 28, 2026 21:15
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.
@github-actions

Copy link
Copy Markdown

Coverage report


Generated by cargo llvm-cov (workspace minus nexterm-client-gpu and nexterm-i18n).

@mizu-jun
mizu-jun merged commit b96e6e0 into master Aug 28, 2026
12 checks passed
@mizu-jun
mizu-jun deleted the p3a-motion-foundation branch August 28, 2026 13:57
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant