From 9a2e3f922f59ddff7cc2e26045b3abdb34a52671 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Tue, 28 Jul 2026 14:27:54 +0300 Subject: [PATCH] ui(motion): reserve springsEnabled for real SpringSimulation (#481) Stop using Full-motion springsEnabled to pick emphasized cubics on shell morphs; FadeSlide, SwitchingBody, and dialogs always use standard/enter/exit tokens. Document the rule in motion docs and CONTRIBUTING. --- CONTRIBUTING.md | 7 ++++--- docs/motion-and-high-refresh.md | 15 +++++++++++++++ lib/core/motion/querya_fade_slide.dart | 15 +++++---------- lib/core/motion/querya_spring.dart | 8 ++++++-- lib/core/motion/querya_switching_body.dart | 13 +++++-------- lib/shared/widgets/app_dialog.dart | 6 +----- test/core/motion/querya_fade_slide_test.dart | 8 ++++---- test/core/motion/querya_switching_body_test.dart | 5 ++--- 8 files changed, 42 insertions(+), 35 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 627d55d3..a64b09b3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,10 +81,11 @@ version locally to avoid "works on my machine" drift. When bumping the pin, run For animated UI, **do not invent magic `Duration(...)` / raw curves** in widgets. -- Use `QueryaMotion` tokens (`fast` / `standard` / `slow`) via +- Use `QueryaMotion` tokens (`fast` / `standard` / `slow` / `treeExpand`) via `context.motionDuration` / `context.motionCurve` (or `QueryaMotion.effective*`). -- Interactive Fluid motion: `QueryaSpring` / `QueryaSpringController` when - `QueryaSpring.springsEnabled` (Full motion only). +- **Real springs only:** `QueryaSpring` / `QueryaSpringController` when + `QueryaSpring.springsEnabled` (Full motion) — tab indicator, drag settle, etc. + Do not use `springsEnabled` just to pick an emphasized cubic for fades/dialogs. - Honor Preferences Motion Full / Reduced / Off and OS `disableAnimations`. - Mid-drag layout (split panes) stays 1:1; spring settle only on drag-end. - Do not animate virtualized grid rows on scroll. diff --git a/docs/motion-and-high-refresh.md b/docs/motion-and-high-refresh.md index cb77c4d9..0204610a 100644 --- a/docs/motion-and-high-refresh.md +++ b/docs/motion-and-high-refresh.md @@ -112,6 +112,16 @@ Introduce `lib/core/motion/` with a single source of truth for durations and cur - **Theme switch**: enable a tasteful `emphasized` cross-fade and consider making it on-by-default. - **List/grid item insertion** (results, history): subtle staggered fade-in for first paint only (no per-scroll cost). +### 4.5 Springs vs duration-token cubics (#481) + +`QueryaSpring.springsEnabled` (Full motion only) means **real** `SpringSimulation` / +`QueryaSpringController` — tab strip indicator, split drag settle, and similar +interruptible physics. + +Shell morphs (`QueryaFadeSlide`, `QueryaSwitchingBody`, `showAppDialog`) always use +duration tokens (`standard` + `enter`/`exit`). Do **not** treat `emphasized` cubic +as a stand-in for “Fluid spring.” + --- ## 5. Implementation plan (proposed issues) @@ -156,6 +166,11 @@ When reviewing PRs that touch animation: 5. Tree expand: chevron `AnimatedRotation` and `QueryaAnimatedExpand` **must** use `QueryaMotion.treeExpand` + `treeExpandCurve` (not `fast`/`standardCurve` mixed with `standard`/`enter`). +6. **Springs vs cubics (#481):** `QueryaSpring.springsEnabled` gates **real** + `SpringSimulation` / `QueryaSpringController` only (tab strip indicator, split + drag settle). Shell morphs (`QueryaFadeSlide`, `QueryaSwitchingBody`, + `showAppDialog`) use duration-token cubics (`standard`/`enter`/`exit`) — do not + brand emphasized ease as “spring”. **Allowed named non-token durations** (named + documented — not magic literals at call sites): diff --git a/lib/core/motion/querya_fade_slide.dart b/lib/core/motion/querya_fade_slide.dart index c7d19086..d954b6d5 100644 --- a/lib/core/motion/querya_fade_slide.dart +++ b/lib/core/motion/querya_fade_slide.dart @@ -2,12 +2,12 @@ import 'package:flutter/material.dart'; import 'querya_motion.dart'; import 'querya_motion_context.dart'; -import 'querya_spring.dart'; /// Fades and optionally slides [child] when the keyed child changes. /// -/// Uses a short spring-like curve when [QueryaSpring.springsEnabled], otherwise -/// duration tokens. Prefer wrapping content with a stable [Key] on [child]. +/// Uses duration-token cubic curves ([QueryaMotion.standard] / [QueryaMotion.enter]), +/// not [QueryaSpring] — reserve springs for interruptible physics (tab indicator, +/// drag settle). Prefer wrapping content with a stable [Key] on [child]. class QueryaFadeSlide extends StatelessWidget { const QueryaFadeSlide({ super.key, @@ -24,13 +24,8 @@ class QueryaFadeSlide extends StatelessWidget { @override Widget build(BuildContext context) { - final useSpring = QueryaSpring.springsEnabled(context); - final duration = context.motionDuration( - useSpring ? QueryaMotion.standard : QueryaMotion.fast, - ); - final curve = context.motionCurve( - useSpring ? QueryaMotion.emphasized : QueryaMotion.enter, - ); + final duration = context.motionDuration(QueryaMotion.standard); + final curve = context.motionCurve(QueryaMotion.enter); return AnimatedSwitcher( duration: duration, diff --git a/lib/core/motion/querya_spring.dart b/lib/core/motion/querya_spring.dart index 278d35fb..f53b85ee 100644 --- a/lib/core/motion/querya_spring.dart +++ b/lib/core/motion/querya_spring.dart @@ -6,8 +6,12 @@ import 'querya_motion_scope.dart'; /// Spring presets for Fluid UI (interruptible / redirectable motion). /// /// Tuned toward critically damped motion (~Apple Response 0.3–0.5s feel). -/// Use with [SpringSimulation] / [AnimationController.animateWith], not fixed -/// [Duration] curves, when [springsEnabled] is true. +/// Use **only** with [SpringSimulation] / [AnimationController.animateWith] +/// when [springsEnabled] is true (tab indicator, drag settle, etc.). +/// +/// Do **not** branch on [springsEnabled] merely to pick an emphasized cubic +/// curve for [AnimatedOpacity] / [AnimatedSwitcher] / dialogs — those use +/// [QueryaMotion] duration tokens instead (#481). abstract final class QueryaSpring { /// Snappy panels / dialogs / tab indicator (~0.3s Response feel). static const SpringDescription snappy = SpringDescription( diff --git a/lib/core/motion/querya_switching_body.dart b/lib/core/motion/querya_switching_body.dart index 77a54f91..a16a72bb 100644 --- a/lib/core/motion/querya_switching_body.dart +++ b/lib/core/motion/querya_switching_body.dart @@ -2,12 +2,14 @@ import 'package:flutter/material.dart'; import 'querya_motion.dart'; import 'querya_motion_context.dart'; -import 'querya_spring.dart'; /// Keep-alive indexed stack with opacity (+ optional slide) transitions. /// /// Off-screen children stay mounted (SQL editor state, etc.). Prefer this over /// hard `if` swaps for empty↔workspace and similar shell morphs. +/// +/// Uses duration-token cubics ([QueryaMotion.standard] / enter / exit), not +/// [QueryaSpring] — springs stay for interruptible physics only. class QueryaSwitchingBody extends StatelessWidget { const QueryaSwitchingBody({ super.key, @@ -26,13 +28,8 @@ class QueryaSwitchingBody extends StatelessWidget { Widget build(BuildContext context) { assert(children.isNotEmpty, 'QueryaSwitchingBody requires children'); final safeIndex = index.clamp(0, children.length - 1); - final useSpring = QueryaSpring.springsEnabled(context); - final duration = context.motionDuration( - useSpring ? QueryaMotion.standard : QueryaMotion.fast, - ); - final inCurve = context.motionCurve( - useSpring ? QueryaMotion.emphasized : QueryaMotion.enter, - ); + final duration = context.motionDuration(QueryaMotion.standard); + final inCurve = context.motionCurve(QueryaMotion.enter); final outCurve = context.motionCurve(QueryaMotion.exit); return Stack( diff --git a/lib/shared/widgets/app_dialog.dart b/lib/shared/widgets/app_dialog.dart index 1a92ceaa..ec8a2a37 100644 --- a/lib/shared/widgets/app_dialog.dart +++ b/lib/shared/widgets/app_dialog.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; -import 'package:querya_desktop/core/motion/querya_spring.dart'; /// Shows a modal dialog with a frosted, dimmed backdrop over the app. /// @@ -76,12 +75,9 @@ class _BlurredDialogScaffoldState extends State<_BlurredDialogScaffold> { void _rebuildCurved() { _curved?.dispose(); - final useSpring = QueryaSpring.springsEnabled(context); _curved = CurvedAnimation( parent: widget.animation, - curve: context.motionCurve( - useSpring ? QueryaMotion.emphasized : QueryaMotion.enter, - ), + curve: context.motionCurve(QueryaMotion.enter), reverseCurve: context.motionCurve(QueryaMotion.exit), ); } diff --git a/test/core/motion/querya_fade_slide_test.dart b/test/core/motion/querya_fade_slide_test.dart index 112d03f1..0c67cc93 100644 --- a/test/core/motion/querya_fade_slide_test.dart +++ b/test/core/motion/querya_fade_slide_test.dart @@ -92,7 +92,7 @@ void main() { expect(find.text('a'), findsNothing); }); - testWidgets('uses standard duration when springs enabled (full)', + testWidgets('uses standard/enter duration tokens (full motion)', (tester) async { await tester.pumpWidget( wrap( @@ -104,10 +104,10 @@ void main() { final switcher = tester.widget(find.byType(AnimatedSwitcher)); expect(switcher.duration, QueryaMotion.standard); - expect(switcher.switchInCurve, QueryaMotion.emphasized); + expect(switcher.switchInCurve, QueryaMotion.enter); }); - testWidgets('uses fast duration when reduced (no springs)', (tester) async { + testWidgets('halves standard duration when reduced', (tester) async { await tester.pumpWidget( wrap( const QueryaFadeSlide( @@ -122,7 +122,7 @@ void main() { switcher.duration, QueryaMotion.effectiveDuration( tester.element(find.byType(QueryaFadeSlide)), - QueryaMotion.fast, + QueryaMotion.standard, ), ); expect(switcher.switchInCurve, QueryaMotion.enter); diff --git a/test/core/motion/querya_switching_body_test.dart b/test/core/motion/querya_switching_body_test.dart index ecab84c6..d39b686d 100644 --- a/test/core/motion/querya_switching_body_test.dart +++ b/test/core/motion/querya_switching_body_test.dart @@ -228,8 +228,7 @@ void main() { expect(opacity.duration, QueryaMotion.instant); }); - testWidgets('reduced motion disables springs path (fast halved)', - (tester) async { + testWidgets('halves standard duration when reduced', (tester) async { await tester.pumpWidget( wrap( const QueryaSwitchingBody( @@ -246,7 +245,7 @@ void main() { opacity.duration, QueryaMotion.effectiveDuration( tester.element(find.byType(QueryaSwitchingBody)), - QueryaMotion.fast, + QueryaMotion.standard, ), ); });