diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index 665bdd5bc..970cd0b0f 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -497,6 +497,7 @@ struct AccountPanelView: View { accountRows } } + .animation(PanelMotion.state, value: accountRunLayoutKey) } private var accountRows: some View { @@ -594,6 +595,14 @@ struct AccountPanelView: View { accountListContentHeight > accountListAvailableHeight + 1 } + private var accountRunLayoutKey: String { + store.accounts.map { account in + let runIDs = operatorRuns(for: account).map(\.id).joined(separator: ",") + return "\(account.id):\(runIDs)" + } + .joined(separator: "|") + } + private var accountListAvailableHeight: CGFloat { let visibleHeight = AccountPanelLayout.activeScreenVisibleHeight() let availableHeight = visibleHeight - accountPanelChromeHeight @@ -836,6 +845,7 @@ struct AccountRowView: View { if runs.isEmpty == false { AccountRunSummaryView(runs: runs) + .transition(runSummaryTransition) } if account.hasUsageSummary { @@ -858,6 +868,28 @@ struct AccountRowView: View { .animation(PanelMotion.state, value: account.selected) .animation(PanelMotion.state, value: account.codexActive) .animation(PanelMotion.state, value: isLogoutArmed) + .animation(reduceMotion ? .easeOut(duration: 0.12) : PanelMotion.state, value: runIdentityKey) + } + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var runIdentityKey: String { + runs.map(\.id).joined(separator: "|") + } + + private var runSummaryTransition: AnyTransition { + if reduceMotion { + return .opacity + } + + return .asymmetric( + insertion: .opacity + .combined(with: .move(edge: .top)) + .combined(with: .scale(scale: 0.98, anchor: .topLeading)), + removal: .opacity + .combined(with: .move(edge: .top)) + .combined(with: .scale(scale: 0.98, anchor: .topLeading)) + ) } private var routeHelp: String { @@ -885,12 +917,14 @@ struct AccountRowView: View { struct AccountRunSummaryView: View { let runs: [OperatorRunStatus] + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 5) { ForEach(runs) { run in AccountRunChipView(run: run) + .transition(runChipTransition) } } .padding(.trailing, 1) @@ -899,6 +933,25 @@ struct AccountRunSummaryView: View { .frame(height: AccountRunChipLayout.height) .frame(maxWidth: .infinity, alignment: .leading) .accessibilityLabel("\(runs.count) running lane\(runs.count == 1 ? "" : "s")") + .animation(reduceMotion ? .easeOut(duration: 0.12) : PanelMotion.state, value: runIdentityKey) + } + + private var runIdentityKey: String { + runs.map(\.id).joined(separator: "|") + } + + private var runChipTransition: AnyTransition { + if reduceMotion { + return .opacity + } + + return .asymmetric( + insertion: .opacity + .combined(with: .scale(scale: 0.88, anchor: .leading)) + .combined(with: .move(edge: .leading)), + removal: .opacity + .combined(with: .scale(scale: 0.94, anchor: .leading)) + ) } } diff --git a/apps/decodex/src/orchestrator/operator_dashboard.html b/apps/decodex/src/orchestrator/operator_dashboard.html index 6114ebc4c..e068945ab 100644 --- a/apps/decodex/src/orchestrator/operator_dashboard.html +++ b/apps/decodex/src/orchestrator/operator_dashboard.html @@ -3519,6 +3519,26 @@ } } + @keyframes stable-list-item-enter { + from { + opacity: 0; + transform: translateY(-5px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + .is-list-entering { + animation: stable-list-item-enter var(--medium) var(--ease) both; + transform-origin: top left; + } + + .is-size-animating { + overflow: hidden; + } + @media (prefers-reduced-motion: reduce) { *, *::before, @@ -5423,6 +5443,7 @@

Run History

} else { const clone = nextChild.cloneNode(true); current.insertBefore(clone, cursor); + markStableListEnter(clone); used.add(clone); } } @@ -5434,11 +5455,72 @@

Run History

} } + function markStableListEnter(node) { + if (reducedMotionQuery.matches || !(node instanceof HTMLElement)) { + return; + } + + node.classList.add("is-list-entering"); + const clear = () => { + node.classList.remove("is-list-entering"); + }; + node.addEventListener("animationend", clear, { once: true }); + window.setTimeout(clear, 360); + } + + function animateStableListSize(container, startHeight) { + if (reducedMotionQuery.matches) { + return; + } + + const endHeight = container.getBoundingClientRect().height; + if (Math.abs(endHeight - startHeight) < 1) { + return; + } + + const previousHeight = container.style.height; + const previousOverflow = container.style.overflow; + const previousTransition = container.style.transition; + let cleaned = false; + + const cleanup = (event) => { + if (event && event.propertyName !== "height") { + return; + } + if (cleaned) { + return; + } + cleaned = true; + container.classList.remove("is-size-animating"); + container.style.height = previousHeight; + container.style.overflow = previousOverflow; + container.style.transition = previousTransition; + }; + + container.classList.add("is-size-animating"); + container.style.height = `${startHeight}px`; + container.style.overflow = "hidden"; + void container.offsetHeight; + + window.requestAnimationFrame(() => { + container.style.transition = [previousTransition, "height var(--medium) var(--ease)"] + .filter(Boolean) + .join(", "); + container.style.height = `${endHeight}px`; + container.addEventListener("transitionend", cleanup, { once: true }); + window.setTimeout(cleanup, 360); + }); + } + function renderStableList(container, html) { const template = document.createElement("template"); template.innerHTML = html.trim(); + const startHeight = reducedMotionQuery.matches + ? 0 + : container.getBoundingClientRect().height; patchChildNodes(container, template.content); + animateStableListSize(container, startHeight); } function pluralLabel(count, singular, plural = `${singular}s`) { diff --git a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs index 24d512323..e1cc4d181 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs @@ -256,10 +256,16 @@ fn operator_dashboard_patches_active_run_cards_without_replacing_the_list() { let response = dashboard_response(); assert!(response.contains("function renderStableList(container, html)")); + assert!(response.contains("function animateStableListSize(container, startHeight)")); + assert!(response.contains("function markStableListEnter(node)")); assert!(response.contains("function patchChildNodes(current, next)")); assert!(response.contains("function activeRunRenderKey(run)")); assert!(response.contains("data-render-key=\"${escapeHtml(renderKey)}\"")); assert!(response.contains("renderStableList(\n\t\t\t\t\tnodes.activeRuns,")); + assert!(response.contains("markStableListEnter(clone);")); + assert!(response.contains("container.style.height = `${startHeight}px`;")); + assert!(response.contains(".is-list-entering")); + assert!(response.contains("@keyframes stable-list-item-enter")); assert!(!response.contains("nodes.activeRuns.innerHTML = runs")); assert!(response.contains("return node.dataset.renderKey || node.dataset.detailKey || \"\";")); assert!(response.contains("current.closest(\"details.is-animating\")"));