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! {