diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml
index 278b8fc6..b38660bd 100644
--- a/crates/web/Cargo.toml
+++ b/crates/web/Cargo.toml
@@ -42,6 +42,7 @@ web-sys = { version = "0.3", features = [
"CustomEvent", "CustomEventInit",
"History", "DomRect",
"Notification", "NotificationPermission",
+ "TransitionEvent", "TransitionEventInit",
] }
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
diff --git a/crates/web/src/components/bottom_sheet.rs b/crates/web/src/components/bottom_sheet.rs
index bf2686bf..564e59fe 100644
--- a/crates/web/src/components/bottom_sheet.rs
+++ b/crates/web/src/components/bottom_sheet.rs
@@ -12,9 +12,13 @@
//! - 100 vw wide, top corners `--radius-l`, 4 × 36 px centred handle.
//! - translateY(100% → 0) over `--motion-slow`, backdrop fade.
+use leptos::ev::TransitionEvent;
+use leptos::html::Div;
use leptos::prelude::*;
use wasm_bindgen::JsCast;
+use crate::components::lifecycle::{advance, is_zero_duration, LifecycleState};
+
/// Reusable bottom-sheet primitive.
#[component]
pub fn BottomSheet(
@@ -66,6 +70,66 @@ pub fn BottomSheet(
}
};
+ // Four-phase data-state lifecycle on the inner .bottom-sheet div.
+ // Driving property: transform (per components.css `.bottom-sheet`
+ // declares `transition: transform var(--motion-slow) var(--motion-ease)`
+ // — translateY 100% → 0 on .open). The reduced-motion media query
+ // swaps the transition to opacity, but the JS shortcut handles that
+ // path by snapping to terminal when computed transition-duration is
+ // zero (which it is in the reduced-motion CSS once the transition
+ // finishes).
+ //
+ // The existing data-open attribute on .bottom-sheet-root remains
+ // (additive) — it gates pointer-events + the backdrop's opacity,
+ // and removing it would break those CSS selectors. The new
+ // data-state lives on the inner .bottom-sheet (the element that
+ // actually carries the transform transition).
+ //
+ // See docs/specs/2026-04-27-event-based-waits-design.md
+ // §`data-state` attribute pattern.
+ let sheet_ref: NodeRef
= NodeRef::new();
+ let lifecycle = RwSignal::new(if open.get_untracked() {
+ LifecycleState::Open
+ } else {
+ LifecycleState::Closed
+ });
+
+ Effect::new(move |prev: Option| {
+ let now_open = open.get();
+ if prev.is_none() || prev == Some(now_open) {
+ lifecycle.set(if now_open {
+ LifecycleState::Open
+ } else {
+ LifecycleState::Closed
+ });
+ return now_open;
+ }
+ lifecycle.set(if now_open {
+ LifecycleState::Opening
+ } else {
+ LifecycleState::Closing
+ });
+ if let Some(el) = sheet_ref.get_untracked() {
+ if is_zero_duration(el.as_ref()) {
+ lifecycle.set(advance(lifecycle.get_untracked()));
+ }
+ }
+ now_open
+ });
+
+ let on_transition_end = move |ev: TransitionEvent| {
+ // Accept either driving property: `.bottom-sheet` slides via
+ // `transform` by default, but the prefers-reduced-motion media
+ // query in components.css swaps the transition to
+ // `opacity var(--motion-slow) linear`. Both paths must advance
+ // the lifecycle; `is_zero_duration` cannot short-circuit because
+ // the reduced-motion duration is non-zero.
+ let prop = ev.property_name();
+ if prop == "transform" || prop == "opacity" {
+ lifecycle.update(|s| *s = advance(*s));
+ }
+ };
+
view! {
::new();
let cancel_button_ref = NodeRef::::new();
+ // Four-phase data-state lifecycle on the .confirm-overlay root.
+ // Driving property: opacity (the dialog fades in/out). The element
+ // is conditionally rendered, so the Closing phase is observable only
+ // for the brief moment between visible.set(false) and the subtree
+ // being unmounted; tests should gate on Open / Closed (or absence).
+ //
+ // See docs/specs/2026-04-27-event-based-waits-design.md
+ // §`data-state` attribute pattern.
+ let overlay_ref: NodeRef
= NodeRef::new();
+ let lifecycle = RwSignal::new(if visible.get_untracked() {
+ LifecycleState::Open
+ } else {
+ LifecycleState::Closed
+ });
+
+ Effect::new(move |prev: Option| {
+ let now_visible = visible.get();
+ if prev.is_none() || prev == Some(now_visible) {
+ lifecycle.set(if now_visible {
+ LifecycleState::Open
+ } else {
+ LifecycleState::Closed
+ });
+ return now_visible;
+ }
+ lifecycle.set(if now_visible {
+ LifecycleState::Opening
+ } else {
+ LifecycleState::Closing
+ });
+ if let Some(el) = overlay_ref.get_untracked() {
+ if is_zero_duration(el.as_ref()) {
+ lifecycle.set(advance(lifecycle.get_untracked()));
+ }
+ }
+ now_visible
+ });
+
+ let on_transition_end = move |ev: TransitionEvent| {
+ if ev.property_name() == "opacity" {
+ lifecycle.update(|s| *s = advance(*s));
+ }
+ };
+
// Auto-focus the confirm button when the dialog becomes visible so
// keyboard users are pulled into the modal and Escape/Tab work as
// expected per WAI-ARIA APG.
@@ -67,6 +115,9 @@ pub fn ConfirmDialog(
Some(view! {
60 px,
//! Escape. Swipe gesture wiring lands in task 8.
+use leptos::ev::TransitionEvent;
+use leptos::html::Aside;
use leptos::prelude::*;
use wasm_bindgen::JsCast;
+use crate::components::lifecycle::{advance, is_zero_duration, LifecycleState};
use crate::components::{PeerStatusLabel, StatusDot, StatusDotBorder, StatusDotSize};
use crate::icons;
use crate::state::AppState;
@@ -65,6 +68,69 @@ pub fn GroveDrawer(
closure.forget();
}
+ // Four-phase data-state lifecycle on the inner
0.0 {
diff --git a/crates/web/src/components/mobile_shell.rs b/crates/web/src/components/mobile_shell.rs
index 1741479c..dbd16dff 100644
--- a/crates/web/src/components/mobile_shell.rs
+++ b/crates/web/src/components/mobile_shell.rs
@@ -17,8 +17,11 @@
//!
//! Spec: docs/specs/2026-04-19-ui-design/layout-primitives.md §Mobile layout
+use leptos::ev::TransitionEvent;
+use leptos::html::Div;
use leptos::prelude::*;
+use crate::components::lifecycle::{advance, is_zero_duration, LifecycleState};
use crate::components::{
BottomSheet, ChannelSidebar, ChatInput, FileShareButton, GroveDrawer, MainPaneHeader,
MessageList, RightRailWhich, TabBar,
@@ -233,6 +236,55 @@ where
let on_pin = on_pin.clone();
let on_voice_join_sidebar = on_voice_join.clone();
+ // Four-phase data-state lifecycle on the .mobile-shell root, mirroring
+ // the drawer-open signal owned by this component (`drawer_open` above).
+ // The actual transform transition lives on the inner GroveDrawer
+ //