Skip to content

feat(client): hover cross-fade for the tab bar and window buttons (UI/UX v3 P3b2b) - #82

Merged
mizu-jun merged 6 commits into
masterfrom
p3b2b-titlebar-hover-v2
Aug 29, 2026
Merged

feat(client): hover cross-fade for the tab bar and window buttons (UI/UX v3 P3b2b)#82
mizu-jun merged 6 commits into
masterfrom
p3b2b-titlebar-hover-v2

Conversation

@mizu-jun

Copy link
Copy Markdown
Owner

Summary

Completes P3b2 by giving the last two of this client's four pointer-hover models a cross-fade. Stacked on #80 (P3b2a), which did the settings widget rows and the context menu.

  • A tab in the tab bar. Its background used to snap between inactive_bg and a brightened version of it; it now lerps over 100 ms, still gated on the existing tab_bar.hover_highlight key.
  • A window button in the custom title bar. The fill and the glyph colour used to snap. The fill's alpha now carries the fade; the glyph lerps between text_secondary and text_primary.

No new primitive: HoverTransition<Id> and color_util::lerp_rgba come from #80 unchanged.

Two traps, both worth a reviewer's eye

Neither is visible in the diff's shape, and both were found by measuring rather than by reading the design.

1. The tab bar's hover flag has a non-colour reader. let is_hovered = state.hovered_tab_id == Some(pane_id); is read twice — once to pick the background colour, and once to gate whether the tear-out [↗] and close [×] buttons are drawn at all. The second is behavioural: those are click targets hit-tested in mouse.rs, so a button drawn at weight 0.05 is one the user can hit while it is vanishing, and one drawn during a fade-out lingers after the pointer has left. That reader keeps the boolean; only the colour consults the weight.

2. hovered_window_button has two write sites. The pointer-motion handler, and UserEvent::SnapMaximizeHover — the Windows snap-layout path, where the OS reports hover on the maximize button. Retargeting only the first leaves that path snapping, and nothing fails to compile. This is the same class of defect #79's whole-branch review caught in an AccessKit path, so both sites are retargeted and a grep confirmed there is no third.

Both interpolation kinds, in one builder

P3b2a established that hover needs a scalar consumed at colour-choice time rather than a post-pass, and that the kind of interpolation follows the shape of the hovered appearance. This branch needs both kinds in the same function:

Site Shape Mechanism
Tab background the quad is always drawn, in one of four colours lerp_rgba — the colour moves
Window-button fill additive — no quad at all when not hovered alpha scaled by the weight, and no geometry emitted at 0
Window-button glyph opaque swap between two tokens lerp_rgba

Alpha-scaling the tab background would fade the tab out of the bar instead of into its tint. Lerping the additive fill would assume what sits behind the button and keep painting at weight 0. The Close button's hovered fill is semantic_error — an unrelated hue rather than a brightening — so its alpha is carried through untouched by the fade.

Also in here

  • HoverTransition::target() is now #[cfg(test)] rather than carrying a permanent #[allow(dead_code)]. It has no production caller but three real test assertions that pin something the weight checks do not — that retarget actually moved the target. AnimationManager::tick_by_dt in the same module is the precedent.
  • nexterm-client-gpu/CLAUDE.md gains an "Adding a hover model" rule: own transition, retarget from every handler that writes the logical id, add the aggregate clause, pick the interpolation by shape rather than habit, and check whether the hover flag has a behavioural reader as well as a colour one.

A spec requirement this does NOT meet, stated plainly

The design's Testing section asks that hover_highlight = false starting no transition be tested. A test was written for it and then deleted, because it could not fail — it asserted HoverTransition::default() behaviour on a fresh state and never exercised the gate's branch. A test that cannot fail but carries a requirement's name is worse than an acknowledged gap: the next person to touch the gate would trust it.

So the gate is verified by code inspection only. Covering it properly needs an EventHandler test harness this crate does not have, which is out of scope here and deserves its own decision. The reason is recorded in the commit body of the deletion and in the plan document's verification backlog, not just in a PR description.

Test plan

  • cargo test --workspace — 0 failures (nexterm-client-gpu: 1013 passed)
  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo fmt --check — clean
  • Per model: hovering asks for frames until the fade settles and stops afterwards; leaving fades the last item out rather than snapping; moving between two window buttons shows both genuinely mid-fade at the same instant.
  • a_fully_idle_state_wants_no_animation_frames still green — the phase has now added four clauses to has_active_animation, four more ways for it to regress.
  • Reduced motion inherited unchanged: scaled_duration_ms returns 0 when animations are off, so both new models snap exactly as they did before.
  • No Cargo.lock, nexterm-i18n/locales/, docs/CONFIGURATION.md or pkg/flatpak/ change, so no flatpak sources regeneration and no key-parity risk.

Not verified here: no motion in P3b1 or P3b2 has been seen on hardware. Two questions only a display can settle, both now in the plan's backlog — whether the tab bar's +0.06/+0.06/+0.08 brightening is perceptible as a fade at all, and whether the Close button fading to semantic_error rather than snapping to it weakens the "this is destructive" signal. That second one is the only place in P3b2 where the hovered appearance is a warning rather than an affordance.

Design: docs/superpowers/specs/2026-08-29-p3b2-hover-crossfade-design.md
Plan: docs/superpowers/plans/2026-08-29-p3b2b-titlebar-hover.md

🤖 Generated with Claude Code

mizu-jun and others added 6 commits August 29, 2026 09:33
Records two traps found by measuring, neither of which is in the design:
the tab bar's is_hovered also gates whether the tear-out and close buttons
are drawn (a behavioural use that must keep the boolean, since a button
drawn at weight 0.05 is still clickable), and hovered_window_button has a
second write site in the Windows snap-layout event handler.

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>
a_tab_transition_that_is_never_retargeted_stays_quiet could not fail:
it only exercised HoverTransition::default(), already covered by
a_fully_idle_state_wants_no_animation_frames, and never called
retarget, so it did not exercise the config gate in mouse.rs even by
proxy. A test that cannot fail but carries the name of a requirement
is worse than no test - it creates the appearance of coverage.

The tab_bar.hover_highlight gate is verified by code inspection only.
Covering it properly needs an EventHandler test harness this crate
does not have, which is out of scope for P3b2b.
…n::target

target() has no production caller across all four hover models, so gate it
behind #[cfg(test)] instead of #[allow(dead_code)] -- following
AnimationManager::tick_by_dt's precedent -- rather than deleting it and
losing the retarget-moved-the-target assertion the weight checks alone
cannot express. Also closes out P3b2 in the plan doc and adds the
hover-model rule to nexterm-client-gpu/CLAUDE.md.
Three accuracy fixes to the phase-tracking steering document:

- Add the missing P3b1 entry (shipped via #79, squash 5d6e167,
  merged to master): the shared SurfaceMotion timer pair and
  open/close motion for eleven overlay surfaces, including the
  redacted password-modal ghost.
- Fix P3b2, which claimed "shipped via #80" while #80 is still
  open. A checklist that overstates what is on master is worse
  than one that lags behind -- it's exactly the failure mode the
  CONFIGURATION.md reconciliation (#73) exists to prevent. P3b2a
  is pending review in #80, P3b2b in #81; the item is unchecked
  since neither half has merged.
- Replace the dangling "this branch" reference with #81, matching
  every other entry's PR-number provenance convention.

No code changes.
@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 61f4d1a into master Aug 29, 2026
12 checks passed
@mizu-jun
mizu-jun deleted the p3b2b-titlebar-hover-v2 branch August 29, 2026 03:12
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