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 @@