From 73f7e46a266414e89ef1aa34d48ffd6b294f723b Mon Sep 17 00:00:00 2001 From: Stefanie Jane Date: Tue, 27 Oct 2026 09:01:22 -0700 Subject: [PATCH] fix(studio): smooth tab, panel, and drag interactions Keep drag previews and drop feedback mounted so pointer movement and changing targets do not reset their geometry or visual state. Clamp the preview continuously at viewport edges and reserve space for its hint. Coordinate tab selection, disclosures, and slide-overs with restrained motion that respects reduced-motion preferences. Preserve canvas identity and prevent focus from scrolling the stage during panel transitions. Cover rapid selection, drag cancellation and spring loading, focus return, and motion preferences in browser regressions. Isolate fixture rig data and scope canvas assertions to outputs rather than retained device rows. Co-Authored-By: Nova (Codex) --- crates/hypercolor-ui/input.css | 48 +++++++ .../src/pages/studio/composition_panel.rs | 65 +++++++-- .../src/pages/studio/device_card.rs | 15 +- .../src/pages/studio/device_pane.rs | 13 +- crates/hypercolor-ui/src/pages/studio/drag.rs | 121 +++++++--------- .../src/pages/studio/drag_model.rs | 16 +++ crates/hypercolor-ui/src/pages/studio/mod.rs | 20 ++- .../hypercolor-ui/src/pages/studio/stage.rs | 18 ++- .../src/pages/studio/zone_strip.rs | 52 ++++++- .../tests/studio_drag_model_tests.rs | 54 ++++++- e2e/tests/studio-history-fixture.mjs | 6 + e2e/tests/studio-motion.spec.mjs | 134 ++++++++++++++++++ e2e/tests/studio-performance.spec.mjs | 2 +- 13 files changed, 456 insertions(+), 108 deletions(-) create mode 100644 e2e/tests/studio-motion.spec.mjs diff --git a/crates/hypercolor-ui/input.css b/crates/hypercolor-ui/input.css index d9056976e..2cea587d6 100644 --- a/crates/hypercolor-ui/input.css +++ b/crates/hypercolor-ui/input.css @@ -2,6 +2,54 @@ @import "./tokens/primitives.css"; @import "./tokens/semantic.css"; +/* Studio keeps live surfaces mounted. Motion follows selection and + disclosure state without interpolating pointer-driven geometry. */ +.studio-tab-indicator { + transition: transform 200ms var(--ease-silk), width 200ms var(--ease-silk); +} + +.studio-workspace[data-switch="1"] .studio-zone-content { + animation: studio-settle-a 160ms var(--ease-silk); +} +.studio-workspace[data-switch="2"] .studio-zone-content { + animation: studio-settle-b 160ms var(--ease-silk); +} +@keyframes studio-settle-a { from { opacity: 0.65; } to { opacity: 1; } } +@keyframes studio-settle-b { from { opacity: 0.65; } to { opacity: 1; } } + +.studio-workspace[data-dragging="true"] .studio-zone-content { + animation: none; +} + +.studio-disclosure { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + transition: grid-template-rows 200ms var(--ease-silk), opacity 160ms var(--ease-silk); +} +.studio-disclosure[data-open="true"] { + grid-template-rows: 1fr; + opacity: 1; +} + +.studio-composition-panel, +.studio-device-drawer { + transition: translate 240ms var(--ease-silk); +} +.studio-composition-scrim { + transition: opacity 200ms var(--ease-silk); +} + +@media (prefers-reduced-motion: reduce) { + .studio-workspace[data-switch] .studio-zone-content { animation: none; } + .studio-tab-indicator, + .studio-disclosure, + .studio-composition-panel, + .studio-composition-scrim, + .studio-device-drawer, + .studio-workspace [aria-expanded] svg { transition: none; } +} + /* ── Toasts ────────────────────────────────────────────────────────────── Toasts float at the bottom right of the content area, clear of the diff --git a/crates/hypercolor-ui/src/pages/studio/composition_panel.rs b/crates/hypercolor-ui/src/pages/studio/composition_panel.rs index e92ea6f74..7d94a1423 100644 --- a/crates/hypercolor-ui/src/pages/studio/composition_panel.rs +++ b/crates/hypercolor-ui/src/pages/studio/composition_panel.rs @@ -9,6 +9,7 @@ use hypercolor_leptos_ext::events::target_is_text_entry; use leptos::ev; use leptos::prelude::*; use leptos_icons::Icon; +use wasm_bindgen::JsCast; use crate::api; use crate::components::layer_panel::LayerPanel; @@ -18,6 +19,9 @@ use super::StudioContext; use super::face_composition::{DefaultFaceCard, ScreenCompositionSection}; use super::surface::selected_screen_device_id; +pub(super) const COMPOSITION_PANEL_ID: &str = "studio-composition-panel"; +pub(super) const COMPOSITION_TRIGGER_ID: &str = "studio-composition-trigger"; + /// The right-edge composition slide-over. Stays mounted and animates via /// a transform; `inert` while closed keeps its controls out of the tab /// order. Positioned `absolute` inside the Stage pane so it overlays the @@ -33,6 +37,43 @@ pub fn CompositionPanel( ) -> impl IntoView { let studio = expect_context::(); let open = studio.composition_open; + let panel_ref = NodeRef::::new(); + let close_ref = NodeRef::::new(); + + // Focus follows the disclosure without stealing it back from a control + // elsewhere in Studio when the panel closes. + Effect::new(move |previous: Option| { + let is_open = open.get(); + if is_open && previous == Some(false) { + request_animation_frame(move || { + if open.try_get_untracked() == Some(true) + && let Some(button) = close_ref.try_get_untracked().flatten() + { + let options = web_sys::FocusOptions::new(); + options.set_prevent_scroll(true); + let _ = button.focus_with_options(&options); + } + }); + } + is_open + }); + let close = move || { + let focused_in_panel = panel_ref.get_untracked().is_some_and(|panel| { + document() + .active_element() + .is_some_and(|active| panel.contains(Some(&active))) + }); + if focused_in_panel + && let Some(trigger) = document() + .get_element_by_id(COMPOSITION_TRIGGER_ID) + .and_then(|element| element.dyn_into::().ok()) + { + let options = web_sys::FocusOptions::new(); + options.set_prevent_scroll(true); + let _ = trigger.focus_with_options(&options); + } + open.set(false); + }; // A Screen surface's backing display device — present only while the // selection is a display-role zone with a bound target. Drives the @@ -49,32 +90,36 @@ pub fn CompositionPanel( let keydown = window_event_listener(ev::keydown, move |event| { if open.get_untracked() && event.key() == "Escape" && !target_is_text_entry(event.target()) { - open.set(false); + close(); } }); on_cleanup(move || keydown.remove()); view! {