[release] Snap to 56f0418878
Change-Id: I7992f8f072f57c8fbc40ded5850d598c0b271caa
diff --git a/examples/localized_flutter/localized_flutter_app/meta/localized_flutter_app.cml b/examples/localized_flutter/localized_flutter_app/meta/localized_flutter_app.cml
index 98ed8e3..7f61e83 100644
--- a/examples/localized_flutter/localized_flutter_app/meta/localized_flutter_app.cml
+++ b/examples/localized_flutter/localized_flutter_app/meta/localized_flutter_app.cml
@@ -20,7 +20,6 @@
     use: [
         {
             protocol: [
-                "fuchsia.cobalt.LoggerFactory",
                 "fuchsia.fonts.Provider",
                 "fuchsia.intl.PropertyProvider",
                 "fuchsia.sysmem.Allocator",
diff --git a/session_shells/BUILD.gn b/session_shells/BUILD.gn
index ed3b982..c4ac1f4 100644
--- a/session_shells/BUILD.gn
+++ b/session_shells/BUILD.gn
@@ -25,5 +25,5 @@
 group("rust_unittests") {
   testonly = true
 
-  deps = [ "gazelle/appkit:appkit-tests" ]
+  deps = [ "gazelle:tests" ]
 }
diff --git a/session_shells/development_flags.gni b/session_shells/development_flags.gni
new file mode 100644
index 0000000..6cd397f
--- /dev/null
+++ b/session_shells/development_flags.gni
@@ -0,0 +1,14 @@
+# Copyright 2022 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+# These arguments are here for local development ONLY. They MUST NOT be
+# referenced in a product.gni file or modified by anyone other than a developer
+# configuring their `fx set`.
+declare_args() {
+  # Whether to use "ermine" or "gazelle" as the application shell in
+  # workstation.
+  #
+  # TODO(fxbug.dev/110571): Remove this.
+  application_shell = "ermine"
+}
diff --git a/session_shells/ermine/fidl/BUILD.gn b/session_shells/ermine/fidl/BUILD.gn
index 4b07a13..92c1c93 100644
--- a/session_shells/ermine/fidl/BUILD.gn
+++ b/session_shells/ermine/fidl/BUILD.gn
@@ -4,7 +4,7 @@
 import("//build/fidl/fidl.gni")
 
 group("fidl") {
-  public_deps = [ ":ermine.tools" ]
+  public_deps = [ ":ermine.tools_dart($dart_toolchain)" ]
 }
 
 fidl("ermine.tools") {
diff --git a/session_shells/ermine/keyboard_shortcuts/BUILD.gn b/session_shells/ermine/keyboard_shortcuts/BUILD.gn
index 9fc739f..85c40a4 100644
--- a/session_shells/ermine/keyboard_shortcuts/BUILD.gn
+++ b/session_shells/ermine/keyboard_shortcuts/BUILD.gn
@@ -17,9 +17,9 @@
   deps = [
     "//sdk/dart/fuchsia_services",
     "//sdk/dart/zircon",
-    "//sdk/fidl/fuchsia.input",
-    "//sdk/fidl/fuchsia.ui.shortcut",
-    "//sdk/fidl/fuchsia.ui.views",
+    "//sdk/fidl/fuchsia.input:fuchsia.input_dart",
+    "//sdk/fidl/fuchsia.ui.shortcut:fuchsia.ui.shortcut_dart",
+    "//sdk/fidl/fuchsia.ui.views:fuchsia.ui.views_dart",
     "//third_party/dart-pkg/git/flutter/packages/flutter",
     "//third_party/dart-pkg/pub/meta",
   ]
@@ -34,9 +34,9 @@
     ":keyboard_shortcuts",
     "//sdk/dart/fidl",
     "//sdk/dart/zircon",
-    "//sdk/fidl/fuchsia.input",
-    "//sdk/fidl/fuchsia.ui.shortcut",
-    "//sdk/fidl/fuchsia.ui.views",
+    "//sdk/fidl/fuchsia.input:fuchsia.input_dart",
+    "//sdk/fidl/fuchsia.ui.shortcut:fuchsia.ui.shortcut_dart",
+    "//sdk/fidl/fuchsia.ui.views:fuchsia.ui.views_dart",
     "//third_party/dart-pkg/git/flutter/packages/flutter_test",
     "//third_party/dart-pkg/pub/mockito",
     "//third_party/dart-pkg/pub/test",
diff --git a/session_shells/ermine/login/BUILD.gn b/session_shells/ermine/login/BUILD.gn
index f02031c..3a658a8 100644
--- a/session_shells/ermine/login/BUILD.gn
+++ b/session_shells/ermine/login/BUILD.gn
@@ -7,6 +7,7 @@
 import("//build/fidl/fidl.gni")
 import("//build/flutter/flutter_component.gni")
 import("//build/testing/flutter_driver.gni")
+import("//src/experiences/session_shells/development_flags.gni")
 
 declare_args() {
   # Whether or not to provide the data sharing consent step in OOBE
@@ -62,24 +63,24 @@
     "//sdk/dart/fuchsia_services",
     "//sdk/dart/fuchsia_vfs",
     "//sdk/dart/zircon",
-    "//sdk/fidl/fuchsia.component",
-    "//sdk/fidl/fuchsia.component.decl",
-    "//sdk/fidl/fuchsia.element",
-    "//sdk/fidl/fuchsia.feedback",
-    "//sdk/fidl/fuchsia.hardware.power.statecontrol",
-    "//sdk/fidl/fuchsia.identity.account",
-    "//sdk/fidl/fuchsia.intl",
-    "//sdk/fidl/fuchsia.io",
-    "//sdk/fidl/fuchsia.mem",
-    "//sdk/fidl/fuchsia.recovery",
-    "//sdk/fidl/fuchsia.settings",
-    "//sdk/fidl/fuchsia.ssh",
-    "//sdk/fidl/fuchsia.sys",
-    "//sdk/fidl/fuchsia.ui.app",
-    "//sdk/fidl/fuchsia.ui.focus",
-    "//sdk/fidl/fuchsia.ui.scenic",
-    "//sdk/fidl/fuchsia.ui.views",
-    "//sdk/fidl/fuchsia.update.channelcontrol",
+    "//sdk/fidl/fuchsia.component:fuchsia.component_dart",
+    "//sdk/fidl/fuchsia.component.decl:fuchsia.component.decl_dart",
+    "//sdk/fidl/fuchsia.element:fuchsia.element_dart",
+    "//sdk/fidl/fuchsia.feedback:fuchsia.feedback_dart",
+    "//sdk/fidl/fuchsia.hardware.power.statecontrol:fuchsia.hardware.power.statecontrol_dart",
+    "//sdk/fidl/fuchsia.identity.account:fuchsia.identity.account_dart",
+    "//sdk/fidl/fuchsia.intl:fuchsia.intl_dart",
+    "//sdk/fidl/fuchsia.io:fuchsia.io_dart",
+    "//sdk/fidl/fuchsia.mem:fuchsia.mem_dart",
+    "//sdk/fidl/fuchsia.recovery:fuchsia.recovery_dart",
+    "//sdk/fidl/fuchsia.settings:fuchsia.settings_dart",
+    "//sdk/fidl/fuchsia.ssh:fuchsia.ssh_dart",
+    "//sdk/fidl/fuchsia.sys:fuchsia.sys_dart",
+    "//sdk/fidl/fuchsia.ui.app:fuchsia.ui.app_dart",
+    "//sdk/fidl/fuchsia.ui.focus:fuchsia.ui.focus_dart",
+    "//sdk/fidl/fuchsia.ui.scenic:fuchsia.ui.scenic_dart",
+    "//sdk/fidl/fuchsia.ui.views:fuchsia.ui.views_dart",
+    "//sdk/fidl/fuchsia.update.channelcontrol:fuchsia.update.channelcontrol_dart",
     "//src/experiences/session_shells/ermine/fidl",
     "//src/experiences/session_shells/ermine/internationalization",
     "//src/experiences/session_shells/ermine/utils:ermine_utils",
@@ -103,7 +104,13 @@
 
   component_name = "login"
 
-  manifest = "meta/login.cml"
+  # TODO(fxbug.dev/100284): Make Ermine/Gazelle a dynamic child, rather than
+  # requiring these separate variants.
+  if (application_shell == "ermine") {
+    manifest = "meta/login_ermine.cml"
+  } else if (application_shell == "gazelle") {
+    manifest = "meta/login_gazelle.cml"
+  }
 
   deps = [
     ":default_config",
diff --git a/session_shells/ermine/login/meta/login.cml b/session_shells/ermine/login/meta/login_common.shard.cml
similarity index 96%
rename from session_shells/ermine/login/meta/login.cml
rename to session_shells/ermine/login/meta/login_common.shard.cml
index a9142ba..17dbc69 100644
--- a/session_shells/ermine/login/meta/login.cml
+++ b/session_shells/ermine/login/meta/login_common.shard.cml
@@ -12,13 +12,6 @@
         args: [ "--expose_dirs=hosted_directories" ],
         data: "data/login",
     },
-    children: [
-        {
-            name: "ermine_shell",
-            url: "fuchsia-pkg://fuchsia.com/ermine#meta/ermine.cm",
-            startup: "lazy",
-        },
-    ],
     capabilities: [
         {
             protocol: [
@@ -68,12 +61,12 @@
         {
             protocol: [
                 "fuchsia.accessibility.semantics.SemanticsManager",
-                "fuchsia.cobalt.LoggerFactory",
                 "fuchsia.feedback.CrashReporter",
                 "fuchsia.fonts.Provider",
                 "fuchsia.hardware.power.statecontrol.Admin",
                 "fuchsia.identity.account.AccountManager",
                 "fuchsia.intl.PropertyProvider",
+                "fuchsia.metrics.MetricEventLoggerFactory",
                 "fuchsia.recovery.FactoryReset",
                 "fuchsia.settings.Intl",
                 "fuchsia.settings.Privacy",
@@ -99,7 +92,6 @@
                 "fuchsia.accessibility.semantics.SemanticsManager",
                 "fuchsia.buildinfo.Provider",
                 "fuchsia.camera3.DeviceWatcher",
-                "fuchsia.cobalt.LoggerFactory",
                 "fuchsia.element.Manager",
                 "fuchsia.feedback.CrashReporter",
                 "fuchsia.fonts.Provider",
@@ -114,6 +106,7 @@
                 "fuchsia.mediacodec.CodecFactory",
                 "fuchsia.memory.Monitor",
                 "fuchsia.memorypressure.Provider",
+                "fuchsia.metrics.MetricEventLoggerFactory",
                 "fuchsia.net.interfaces.State",
                 "fuchsia.net.name.Lookup",
                 "fuchsia.posix.socket.Provider",
diff --git a/session_shells/ermine/login/meta/login_ermine.cml b/session_shells/ermine/login/meta/login_ermine.cml
new file mode 100644
index 0000000..3eadba9
--- /dev/null
+++ b/session_shells/ermine/login/meta/login_ermine.cml
@@ -0,0 +1,13 @@
+// Copyright 2022 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+{
+    include: [ "//src/experiences/session_shells/ermine/login/meta/login_common.shard.cml" ],
+    children: [
+        {
+            name: "ermine_shell",
+            url: "fuchsia-pkg://fuchsia.com/ermine#meta/ermine.cm",
+            startup: "lazy",
+        },
+    ],
+}
diff --git a/session_shells/ermine/login/meta/login_gazelle.cml b/session_shells/ermine/login/meta/login_gazelle.cml
new file mode 100644
index 0000000..f1e5db5
--- /dev/null
+++ b/session_shells/ermine/login/meta/login_gazelle.cml
@@ -0,0 +1,18 @@
+// Copyright 2022 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+{
+    include: [ "//src/experiences/session_shells/ermine/login/meta/login_common.shard.cml" ],
+    children: [
+        // Even though this is gazelle, we call it `ermine_shell` because it
+        // acts as a drop-in replacement.
+        //
+        // TODO(fxbug.dev/100284): Resolve this confusion by calling them both
+        // something like `application_shell`.
+        {
+            name: "ermine_shell",
+            url: "fuchsia-pkg://fuchsia.com/gazelle_shell#meta/gazelle_shell.cm",
+            startup: "lazy",
+        },
+    ],
+}
diff --git a/session_shells/ermine/session/BUILD.gn b/session_shells/ermine/session/BUILD.gn
index 8583e34..928735a 100644
--- a/session_shells/ermine/session/BUILD.gn
+++ b/session_shells/ermine/session/BUILD.gn
@@ -18,13 +18,13 @@
     "//sdk/dart/fuchsia_services",
     "//sdk/dart/fuchsia_vfs",
     "//sdk/dart/zircon",
-    "//sdk/fidl/fuchsia.component",
-    "//sdk/fidl/fuchsia.component.decl",
-    "//sdk/fidl/fuchsia.io",
-    "//sdk/fidl/fuchsia.session.scene",
-    "//sdk/fidl/fuchsia.ui.app",
-    "//sdk/fidl/fuchsia.ui.input",
-    "//sdk/fidl/fuchsia.ui.views",
+    "//sdk/fidl/fuchsia.component:fuchsia.component_dart",
+    "//sdk/fidl/fuchsia.component.decl:fuchsia.component.decl_dart",
+    "//sdk/fidl/fuchsia.io:fuchsia.io_dart",
+    "//sdk/fidl/fuchsia.session.scene:fuchsia.session.scene_dart",
+    "//sdk/fidl/fuchsia.ui.app:fuchsia.ui.app_dart",
+    "//sdk/fidl/fuchsia.ui.input:fuchsia.ui.input_dart",
+    "//sdk/fidl/fuchsia.ui.views:fuchsia.ui.views_dart",
   ]
 }
 
diff --git a/session_shells/ermine/shell/BUILD.gn b/session_shells/ermine/shell/BUILD.gn
index 74cee4f..160cf2f 100644
--- a/session_shells/ermine/shell/BUILD.gn
+++ b/session_shells/ermine/shell/BUILD.gn
@@ -100,31 +100,31 @@
     "//sdk/dart/fuchsia_scenic_flutter",
     "//sdk/dart/fuchsia_services",
     "//sdk/dart/zircon",
-    "//sdk/fidl/fuchsia.buildinfo",
-    "//sdk/fidl/fuchsia.element",
-    "//sdk/fidl/fuchsia.feedback",
-    "//sdk/fidl/fuchsia.hardware.power.statecontrol",
-    "//sdk/fidl/fuchsia.input",
-    "//sdk/fidl/fuchsia.intl",
-    "//sdk/fidl/fuchsia.media",
-    "//sdk/fidl/fuchsia.media.audio",
-    "//sdk/fidl/fuchsia.memory",
-    "//sdk/fidl/fuchsia.net.interfaces",
-    "//sdk/fidl/fuchsia.power.battery",
-    "//sdk/fidl/fuchsia.power.button",
-    "//sdk/fidl/fuchsia.settings",
-    "//sdk/fidl/fuchsia.ssh",
-    "//sdk/fidl/fuchsia.ui.activity",
-    "//sdk/fidl/fuchsia.ui.app",
-    "//sdk/fidl/fuchsia.ui.brightness",
-    "//sdk/fidl/fuchsia.ui.focus",
-    "//sdk/fidl/fuchsia.ui.input",
-    "//sdk/fidl/fuchsia.ui.shortcut",
-    "//sdk/fidl/fuchsia.ui.views",
-    "//sdk/fidl/fuchsia.update",
-    "//sdk/fidl/fuchsia.update.channelcontrol",
-    "//sdk/fidl/fuchsia.wlan.common",
-    "//sdk/fidl/fuchsia.wlan.policy",
+    "//sdk/fidl/fuchsia.buildinfo:fuchsia.buildinfo_dart",
+    "//sdk/fidl/fuchsia.element:fuchsia.element_dart",
+    "//sdk/fidl/fuchsia.feedback:fuchsia.feedback_dart",
+    "//sdk/fidl/fuchsia.hardware.power.statecontrol:fuchsia.hardware.power.statecontrol_dart",
+    "//sdk/fidl/fuchsia.input:fuchsia.input_dart",
+    "//sdk/fidl/fuchsia.intl:fuchsia.intl_dart",
+    "//sdk/fidl/fuchsia.media:fuchsia.media_dart",
+    "//sdk/fidl/fuchsia.media.audio:fuchsia.media.audio_dart",
+    "//sdk/fidl/fuchsia.memory:fuchsia.memory_dart",
+    "//sdk/fidl/fuchsia.net.interfaces:fuchsia.net.interfaces_dart",
+    "//sdk/fidl/fuchsia.power.battery:fuchsia.power.battery_dart",
+    "//sdk/fidl/fuchsia.power.button:fuchsia.power.button_dart",
+    "//sdk/fidl/fuchsia.settings:fuchsia.settings_dart",
+    "//sdk/fidl/fuchsia.ssh:fuchsia.ssh_dart",
+    "//sdk/fidl/fuchsia.ui.activity:fuchsia.ui.activity_dart",
+    "//sdk/fidl/fuchsia.ui.app:fuchsia.ui.app_dart",
+    "//sdk/fidl/fuchsia.ui.brightness:fuchsia.ui.brightness_dart",
+    "//sdk/fidl/fuchsia.ui.focus:fuchsia.ui.focus_dart",
+    "//sdk/fidl/fuchsia.ui.input:fuchsia.ui.input_dart",
+    "//sdk/fidl/fuchsia.ui.shortcut:fuchsia.ui.shortcut_dart",
+    "//sdk/fidl/fuchsia.ui.views:fuchsia.ui.views_dart",
+    "//sdk/fidl/fuchsia.update:fuchsia.update_dart",
+    "//sdk/fidl/fuchsia.update.channelcontrol:fuchsia.update.channelcontrol_dart",
+    "//sdk/fidl/fuchsia.wlan.common:fuchsia.wlan.common_dart",
+    "//sdk/fidl/fuchsia.wlan.policy:fuchsia.wlan.policy_dart",
     "//src/experiences/session_shells/ermine/fidl",
     "//src/experiences/session_shells/ermine/internationalization",
     "//src/experiences/session_shells/ermine/keyboard_shortcuts",
diff --git a/session_shells/ermine/shell/lib/src/states/app_state_impl.dart b/session_shells/ermine/shell/lib/src/states/app_state_impl.dart
index 2f98553..8701fc3 100644
--- a/session_shells/ermine/shell/lib/src/states/app_state_impl.dart
+++ b/session_shells/ermine/shell/lib/src/states/app_state_impl.dart
@@ -43,6 +43,7 @@
 
   static const kFeedbackUrl =
       'https://fuchsia.dev/fuchsia-src/contribute/report-issue';
+  static const kChromeElementManager = 'fuchsia.element.Manager-chrome';
   static const kLicenseUrl =
       'fuchsia-pkg://fuchsia.com/license_settings#meta/license_settings.cm';
   static const kScreenSaverUrl =
@@ -408,6 +409,11 @@
       (String title, String url, String? alternateServiceName) async {
     try {
       _clearError(url, 'ProposeElementError');
+
+      // For web urls use Chrome's element manager service.
+      if (url.startsWith('http')) {
+        alternateServiceName ??= kChromeElementManager;
+      }
       await launchService.launch(title, url,
           alternateServiceName: alternateServiceName);
       // Hide app launcher unless we had an error presenting the view.
diff --git a/session_shells/ermine/shell/meta/ermine.cml b/session_shells/ermine/shell/meta/ermine.cml
index bbe1766..427ce8b 100644
--- a/session_shells/ermine/shell/meta/ermine.cml
+++ b/session_shells/ermine/shell/meta/ermine.cml
@@ -42,7 +42,6 @@
             protocol: [
                 "fuchsia.accessibility.semantics.SemanticsManager",
                 "fuchsia.buildinfo.Provider",
-                "fuchsia.cobalt.LoggerFactory",
                 "fuchsia.feedback.CrashReporter",
                 "fuchsia.fonts.Provider",
                 "fuchsia.hardware.power.statecontrol.Admin",
@@ -51,6 +50,7 @@
                 "fuchsia.media.Audio",
                 "fuchsia.media.AudioCore",
                 "fuchsia.memory.Monitor",
+                "fuchsia.metrics.MetricEventLoggerFactory",
                 "fuchsia.net.interfaces.State",
                 "fuchsia.power.battery.BatteryManager",
                 "fuchsia.power.button.Monitor",
diff --git a/session_shells/ermine/utils/BUILD.gn b/session_shells/ermine/utils/BUILD.gn
index 6134b1e..b32cf83 100644
--- a/session_shells/ermine/utils/BUILD.gn
+++ b/session_shells/ermine/utils/BUILD.gn
@@ -29,9 +29,9 @@
     "//sdk/dart/fuchsia_scenic_flutter",
     "//sdk/dart/fuchsia_services",
     "//sdk/dart/zircon",
-    "//sdk/fidl/fuchsia.feedback",
-    "//sdk/fidl/fuchsia.mem",
-    "//sdk/fidl/fuchsia.ui.views",
+    "//sdk/fidl/fuchsia.feedback:fuchsia.feedback_dart",
+    "//sdk/fidl/fuchsia.mem:fuchsia.mem_dart",
+    "//sdk/fidl/fuchsia.ui.views:fuchsia.ui.views_dart",
     "//src/experiences/session_shells/ermine/internationalization",
     "//third_party/dart-pkg/git/flutter/packages/flutter",
     "//third_party/dart-pkg/pub/flutter_mobx",
diff --git a/session_shells/gazelle/BUILD.gn b/session_shells/gazelle/BUILD.gn
index c46275d..f4e5186 100644
--- a/session_shells/gazelle/BUILD.gn
+++ b/session_shells/gazelle/BUILD.gn
@@ -6,12 +6,16 @@
 
 group("gazelle") {
   public_deps = [
-    "session",
+    "shell",
     "wm",
   ]
 }
 
 group("tests") {
   testonly = true
-  deps = [ "wm:tests" ]
+  deps = [
+    "appkit:tests",
+    "pointer_fusion:tests",
+    "wm:tests",
+  ]
 }
diff --git a/session_shells/gazelle/README.md b/session_shells/gazelle/README.md
index ae43fd8..610c0cf 100644
--- a/session_shells/gazelle/README.md
+++ b/session_shells/gazelle/README.md
@@ -7,8 +7,10 @@
 
 To run it:
 
-    fx set workstation_eng_paused.BOARD --with //src/experiences/session_shells/gazelle
+    fx set workstation_eng_paused.BOARD \
+        --with //src/experiences/session_shells/{ermine,gazelle} \
+        '--args=application_shell="gazelle"'
     fx build
-    ffx session launch fuchsia-pkg://fuchsia.com/gazelle_session#meta/gazelle_session.cm
+    ffx session launch fuchsia-pkg://fuchsia.com/workstation_routing#meta/workstation_routing.cm
 
 [wiki-gazelle]: https://en.wikipedia.org/wiki/Gazelle
diff --git a/session_shells/gazelle/appkit/BUILD.gn b/session_shells/gazelle/appkit/BUILD.gn
index 064e7dd..d8dda0d 100644
--- a/session_shells/gazelle/appkit/BUILD.gn
+++ b/session_shells/gazelle/appkit/BUILD.gn
@@ -3,6 +3,7 @@
 # found in the LICENSE file.
 import("//build/components.gni")
 import("//build/rust/rustc_library.gni")
+import("//src/lib/vulkan/vulkan.gni")
 
 rustc_library("appkit") {
   name = "appkit"
@@ -22,6 +23,7 @@
     "//sdk/fidl/fuchsia.math:fuchsia.math_rust",
     "//sdk/fidl/fuchsia.ui.composition:fuchsia.ui.composition_rust",
     "//sdk/fidl/fuchsia.ui.input3:fuchsia.ui.input3_rust",
+    "//sdk/fidl/fuchsia.ui.pointer:fuchsia.ui.pointer_rust",
     "//sdk/fidl/fuchsia.ui.views:fuchsia.ui.views_rust",
     "//src/lib/async-utils",
     "//src/lib/fidl/rust/fidl",
@@ -33,7 +35,9 @@
     "//third_party/rust_crates:tracing",
   ]
   test_deps = [
+    "//sdk/fidl/fuchsia.input:fuchsia.input_rust",
     "//sdk/fidl/fuchsia.ui.app:fuchsia.ui.app_rust",
+    "//sdk/fidl/fuchsia.ui.test.input:fuchsia.ui.test.input_rust",
     "//sdk/fidl/fuchsia.ui.test.scene:fuchsia.ui.test.scene_rust",
     "//src/lib/fuchsia",
   ]
@@ -52,13 +56,13 @@
     log_settings = {
       max_severity = "ERROR"
     }
-    environments = [
-      {
-        dimensions = {
-          # Ensure the device has Vulkan.
-          device_type = "AEMU"
-        }
-      },
-    ]
+
+    # Ensure the device has Vulkan.
+    environments = vulkan_envs
   }
 }
+
+group("tests") {
+  testonly = true
+  deps = [ ":appkit-tests" ]
+}
diff --git a/session_shells/gazelle/appkit/meta/appkit_lib_test.cml b/session_shells/gazelle/appkit/meta/appkit_lib_test.cml
index 14c8a1b..040c366 100644
--- a/session_shells/gazelle/appkit/meta/appkit_lib_test.cml
+++ b/session_shells/gazelle/appkit/meta/appkit_lib_test.cml
@@ -25,6 +25,7 @@
                 "fuchsia.session.scene.Manager",
                 "fuchsia.ui.composition.Flatland",
                 "fuchsia.ui.input3.Keyboard",
+                "fuchsia.ui.test.input.Registry",
                 "fuchsia.ui.test.scene.Controller",
             ],
             from: "#test-ui-stack",
diff --git a/session_shells/gazelle/appkit/src/child_view.rs b/session_shells/gazelle/appkit/src/child_view.rs
index a55d7d4..ce4b782 100644
--- a/session_shells/gazelle/appkit/src/child_view.rs
+++ b/session_shells/gazelle/appkit/src/child_view.rs
@@ -5,13 +5,13 @@
 use {
     anyhow::{format_err, Error},
     fidl::endpoints::{create_proxy, Proxy},
-    fidl_fuchsia_math as fmath, fidl_fuchsia_ui_composition as ui_comp,
-    futures::future::AbortHandle,
+    fidl_fuchsia_math as fmath, fidl_fuchsia_ui_composition as ui_comp, fuchsia_async as fasync,
+    tracing::*,
 };
 
 use crate::{
     event::{ChildViewEvent, Event, ViewSpecHolder},
-    utils::{spawn_abortable, EventSender},
+    utils::EventSender,
     window::WindowId,
 };
 
@@ -32,13 +32,7 @@
     viewport_content_id: ui_comp::ContentId,
     _window_id: WindowId,
     _event_sender: EventSender<T>,
-    services_abort: Option<AbortHandle>,
-}
-
-impl<T> Drop for ChildView<T> {
-    fn drop(&mut self) {
-        self.services_abort.take().map(|a| a.abort());
-    }
+    _running_task: fasync::Task<()>,
 }
 
 impl<T> ChildView<T> {
@@ -74,7 +68,9 @@
             child_view_watcher_request,
         )?;
 
-        view_spec_holder.responder.send(&mut Ok(()))?;
+        if let Some(responder) = view_spec_holder.responder {
+            responder.send(&mut Ok(())).expect("Failed to respond to GraphicalPresent.present")
+        }
 
         let child_view_id = ChildViewId::from_viewport_content_id(viewport_content_id);
         let child_view_watcher_fut = Self::start_child_view_watcher(
@@ -84,14 +80,13 @@
             event_sender.clone(),
         );
 
-        let abort_handle = spawn_abortable(child_view_watcher_fut);
-        let services_abort = Some(abort_handle);
+        let _running_task = fasync::Task::spawn(child_view_watcher_fut);
 
         Ok(ChildView {
             viewport_content_id,
             _window_id: window_id,
             _event_sender: event_sender,
-            services_abort,
+            _running_task,
         })
     }
 
@@ -109,19 +104,25 @@
         window_id: WindowId,
         event_sender: EventSender<T>,
     ) {
-        if let Ok(_) = child_view_watcher_proxy.get_status().await {
-            event_sender
-                .send(Event::ChildViewEvent(child_view_id, window_id, ChildViewEvent::Available))
-                .expect("Failed to send ChildView::Available event");
-        }
-        if let Ok(view_ref) = child_view_watcher_proxy.get_view_ref().await {
-            event_sender
-                .send(Event::ChildViewEvent(
+        match child_view_watcher_proxy.get_status().await {
+            Ok(_) => event_sender
+                .send(Event::ChildViewEvent {
                     child_view_id,
                     window_id,
-                    ChildViewEvent::Attached(view_ref),
-                ))
-                .expect("Failed to send ChildView::Attached event");
+                    event: ChildViewEvent::Available,
+                })
+                .expect("Failed to send ChildView::Available event"),
+            Err(err) => error!("ChildViewWatcher.get_status return error: {:?}", err),
+        }
+        match child_view_watcher_proxy.get_view_ref().await {
+            Ok(view_ref) => event_sender
+                .send(Event::ChildViewEvent {
+                    child_view_id,
+                    window_id,
+                    event: ChildViewEvent::Attached { view_ref },
+                })
+                .expect("Failed to send ChildView::Attached event"),
+            Err(err) => error!("ChildViewWatcher.get_view_ref return error: {:?}", err),
         }
 
         // After retrieving status and viewRef, we can only wait for the channel to close. This is a
@@ -129,7 +130,11 @@
         // [felement::ViewController]'s dismiss method.
         let _ = child_view_watcher_proxy.on_closed().await;
         event_sender
-            .send(Event::ChildViewEvent(child_view_id, window_id, ChildViewEvent::Detached))
+            .send(Event::ChildViewEvent {
+                child_view_id,
+                window_id,
+                event: ChildViewEvent::Detached,
+            })
             .expect("Failed to send ChildView::Detached event");
     }
 }
diff --git a/session_shells/gazelle/appkit/src/event.rs b/session_shells/gazelle/appkit/src/event.rs
index 0e6d593..8a96b46 100644
--- a/session_shells/gazelle/appkit/src/event.rs
+++ b/session_shells/gazelle/appkit/src/event.rs
@@ -5,7 +5,7 @@
 use {
     fidl::endpoints::{ClientEnd, ServerEnd},
     fidl_fuchsia_element as felement, fidl_fuchsia_ui_input3 as ui_input3,
-    fidl_fuchsia_ui_views as ui_views,
+    fidl_fuchsia_ui_pointer as fptr, fidl_fuchsia_ui_views as ui_views,
 };
 
 use crate::{child_view::ChildViewId, window::WindowId};
@@ -16,13 +16,13 @@
     /// Use Init to perform one-time app initialization.
     Init,
     /// Set of system level events that are window agnostic.
-    SystemEvent(SystemEvent),
+    SystemEvent { event: SystemEvent },
     /// Set of device level events that are window agnostic.
     DeviceEvent,
     /// Set of events that apply to a window instance.
-    WindowEvent(WindowId, WindowEvent),
+    WindowEvent { window_id: WindowId, event: WindowEvent },
     /// Set of events that apply to embedded child views.
-    ChildViewEvent(ChildViewId, WindowId, ChildViewEvent),
+    ChildViewEvent { child_view_id: ChildViewId, window_id: WindowId, event: ChildViewEvent },
     /// Used to route application specific events T.
     UserEvent(T),
     /// Use to notify the event processing loop to terminate.
@@ -34,9 +34,9 @@
 pub enum SystemEvent {
     /// Can be used to create a window from a [ui_views::ViewCreationToken]. This is useful only
     /// for ViewProvider based applications.
-    ViewCreationToken(ui_views::ViewCreationToken),
+    ViewCreationToken { token: ui_views::ViewCreationToken },
     /// Used for creating a child view given a ViewSpecHolder using [window.create_child_view].
-    PresentViewSpec(ViewSpecHolder),
+    PresentViewSpec { view_spec_holder: ViewSpecHolder },
 }
 
 /// The next future presentation time, expressed in nanoseconds in the `CLOCK_MONOTONIC` timebase.
@@ -46,13 +46,20 @@
 #[derive(Debug)]
 pub enum WindowEvent {
     /// Window is resized. This is also the first event sent upon window creation.
-    Resized(u32, u32),
+    Resized { width: u32, height: u32 },
     /// Window needs to be redrawn. Sent upon receiving the next frame request from Flatland.
-    NeedsRedraw(NextPresentTimeInNanos),
+    NeedsRedraw { next_present_time: NextPresentTimeInNanos },
     /// Window has received or lost focus.
-    Focused(bool),
+    Focused { focused: bool },
     /// A keyboard key event when the window is in focus.
-    Keyboard(ui_input3::KeyEvent, ui_input3::KeyboardListenerOnKeyEventResponder),
+    Keyboard {
+        event: ui_input3::KeyEvent,
+        responder: ui_input3::KeyboardListenerOnKeyEventResponder,
+    },
+    /// A mouse event received for this window.
+    Mouse { event: fptr::MouseEvent },
+    /// A touch event received for this window.
+    Touch { event: fptr::TouchEvent },
     /// Window was closed by the [GraphicalPresenter] presenting this window.
     Closed,
 }
@@ -63,7 +70,7 @@
     /// Child view is created but not attached to the view tree yet.
     Available,
     /// Child view is attached to the view tree.
-    Attached(ui_views::ViewRef),
+    Attached { view_ref: ui_views::ViewRef },
     /// Child view is detached from the view tree.
     Detached,
 }
@@ -75,5 +82,5 @@
     pub view_spec: felement::ViewSpec,
     pub annotation_controller: Option<ClientEnd<felement::AnnotationControllerMarker>>,
     pub view_controller_request: Option<ServerEnd<felement::ViewControllerMarker>>,
-    pub responder: felement::GraphicalPresenterPresentViewResponder,
+    pub responder: Option<felement::GraphicalPresenterPresentViewResponder>,
 }
diff --git a/session_shells/gazelle/appkit/src/tests.rs b/session_shells/gazelle/appkit/src/tests.rs
index 2b4bc67..ee861ee 100644
--- a/session_shells/gazelle/appkit/src/tests.rs
+++ b/session_shells/gazelle/appkit/src/tests.rs
@@ -4,9 +4,13 @@
 
 use {
     anyhow::Error,
-    fidl::endpoints::{create_proxy_and_stream, create_request_stream},
-    fidl_fuchsia_element as felement, fidl_fuchsia_ui_app as ui_app,
-    fidl_fuchsia_ui_test_scene as ui_test_scene, fuchsia_async as fasync,
+    fidl::endpoints::{create_proxy, create_proxy_and_stream, create_request_stream},
+    fidl_fuchsia_element as felement,
+    fidl_fuchsia_input::Key,
+    fidl_fuchsia_ui_app as ui_app,
+    fidl_fuchsia_ui_input3::{KeyEvent, KeyEventStatus},
+    fidl_fuchsia_ui_test_input as ui_test_input, fidl_fuchsia_ui_test_scene as ui_test_scene,
+    fuchsia_async as fasync,
     fuchsia_component::client::connect_to_protocol,
     fuchsia_scenic::flatland::ViewCreationTokenPair,
     futures::future::{AbortHandle, Abortable},
@@ -19,7 +23,7 @@
     child_view::{ChildView, ChildViewId},
     event::{ChildViewEvent, Event, SystemEvent, ViewSpecHolder, WindowEvent},
     utils::EventSender,
-    window::{Window, WindowBuilder, WindowId},
+    window::{Window, WindowId},
 };
 
 #[derive(Debug)]
@@ -64,40 +68,51 @@
         info!("------ParentView {:?}", event);
         match event {
             Event::Init => {}
-            Event::WindowEvent(id, window_event) => match window_event {
-                WindowEvent::Resized(width, height) => {
+            Event::WindowEvent { window_id: id, event: window_event } => match window_event {
+                WindowEvent::Resized { width, height } => {
                     app.width = width;
                     app.height = height;
                     if app.active_window.is_none() {
                         app.active_window = Some(id);
 
                         let cloned_graphical_presenter = graphical_presenter_proxy.clone();
+                        let cloned_event_sender = event_sender.clone();
                         fasync::Task::spawn(async move {
-                            create_child_view_spec(cloned_graphical_presenter)
+                            create_child_view_spec(cloned_graphical_presenter, cloned_event_sender)
                                 .await
                                 .expect("Failed to create_child_view");
                         })
                         .detach();
                     }
                 }
-                WindowEvent::NeedsRedraw(_) => {
+                WindowEvent::NeedsRedraw { .. } => {
                     assert!(
                         app.width > 0 && app.height > 0,
                         "Redraw event received before window was resized"
                     );
                 }
+                WindowEvent::Keyboard { event, responder } => {
+                    if let KeyEvent { key: Some(Key::Q), .. } = event {
+                        event_sender.send(Event::Exit).expect("Failed to send Event::Exit event");
+                        responder
+                            .send(KeyEventStatus::Handled)
+                            .expect("Failed to respond to keyboard event");
+                    } else {
+                        responder
+                            .send(KeyEventStatus::NotHandled)
+                            .expect("Failed to respond to keyboard event");
+                    }
+                }
                 _ => {}
             },
-            Event::SystemEvent(system_event) => match system_event {
-                SystemEvent::ViewCreationToken(view_creation_token) => {
-                    let mut window = WindowBuilder::new()
-                        .with_view_creation_token(view_creation_token)
-                        .build(event_sender.clone())
-                        .unwrap();
+            Event::SystemEvent { event: system_event } => match system_event {
+                SystemEvent::ViewCreationToken { token: view_creation_token } => {
+                    let mut window = Window::new(event_sender.clone())
+                        .with_view_creation_token(view_creation_token);
                     window.create_view().expect("Failed to create view for window");
                     app.windows.insert(window.id(), window);
                 }
-                SystemEvent::PresentViewSpec(view_spec_holder) => {
+                SystemEvent::PresentViewSpec { view_spec_holder } => {
                     let window = app.windows.get_mut(&app.active_window.unwrap()).unwrap();
                     let child_view = window
                         .create_child_view(
@@ -110,7 +125,7 @@
                     app.child_views.insert(child_view.id(), child_view);
                 }
             },
-            Event::ChildViewEvent(child_view_id, window_id, child_view_event) => {
+            Event::ChildViewEvent { child_view_id, window_id, event: child_view_event } => {
                 let window = app.windows.get_mut(&window_id).unwrap();
                 let child_view = app.child_views.get_mut(&child_view_id).unwrap();
 
@@ -122,9 +137,9 @@
                         );
                         window.redraw();
                     }
-                    ChildViewEvent::Attached(view_ref) => {
+                    ChildViewEvent::Attached { view_ref } => {
+                        // Set focus to child view.
                         window.request_focus(view_ref);
-                        event_sender.send(Event::Exit).expect("Failed to send Event::Exit event");
                     }
                     ChildViewEvent::Detached => {}
                 }
@@ -178,14 +193,21 @@
     };
 
     let view_provider_fut = async move {
-        match view_provider_request_stream.next().await.unwrap() {
+        match view_provider_request_stream
+            .next()
+            .await
+            .expect("Failed to read ViewProvider request stream")
+        {
             Ok(ui_app::ViewProviderRequest::CreateView2 { args, .. }) => {
                 event_sender
-                    .send(Event::SystemEvent(SystemEvent::ViewCreationToken(
-                        args.view_creation_token.unwrap(),
-                    )))
+                    .send(Event::SystemEvent {
+                        event: SystemEvent::ViewCreationToken {
+                            token: args.view_creation_token.unwrap(),
+                        },
+                    })
                     .expect("Failed to send SystemEvent::ViewCreationToken event");
             }
+            // Panic for all other CreateView requests and errors to fail the test.
             _ => panic!("ViewProvider impl only handles CreateView2()"),
         }
     };
@@ -210,12 +232,16 @@
                 responder,
             } => {
                 event_sender
-                    .send(Event::SystemEvent(SystemEvent::PresentViewSpec(ViewSpecHolder {
-                        view_spec,
-                        annotation_controller,
-                        view_controller_request,
-                        responder,
-                    })))
+                    .send(Event::SystemEvent {
+                        event: SystemEvent::PresentViewSpec {
+                            view_spec_holder: ViewSpecHolder {
+                                view_spec,
+                                annotation_controller,
+                                view_controller_request,
+                                responder: Some(responder),
+                            },
+                        },
+                    })
                     .expect("Failed to send SystemEvent::PresentViewSpec event");
             }
         }
@@ -224,6 +250,7 @@
 
 async fn create_child_view_spec(
     graphical_presenter: felement::GraphicalPresenterProxy,
+    parent_sender: EventSender<TestEvent>,
 ) -> Result<(), Error> {
     let ViewCreationTokenPair { view_creation_token, viewport_creation_token } =
         ViewCreationTokenPair::new()?;
@@ -233,6 +260,23 @@
     };
     let _ = graphical_presenter.present_view(view_spec, None, None).await;
 
+    let (keyboard, keyboard_server) = create_proxy::<ui_test_input::KeyboardMarker>()?;
+    let input_registry = connect_to_protocol::<ui_test_input::RegistryMarker>()?;
+    input_registry
+        .register_keyboard(ui_test_input::RegistryRegisterKeyboardRequest {
+            device: Some(keyboard_server),
+            ..ui_test_input::RegistryRegisterKeyboardRequest::EMPTY
+        })
+        .await?;
+
+    let (mouse, mouse_server) = create_proxy::<ui_test_input::MouseMarker>()?;
+    input_registry
+        .register_mouse(ui_test_input::RegistryRegisterMouseRequest {
+            device: Some(mouse_server),
+            ..ui_test_input::RegistryRegisterMouseRequest::EMPTY
+        })
+        .await?;
+
     fasync::Task::local(async move {
         let (sender, mut receiver) = futures::channel::mpsc::unbounded::<Event<TestEvent>>();
         let event_sender = EventSender::<TestEvent>(sender);
@@ -244,13 +288,37 @@
             info!("------ChildView  {:?}", event);
             match event {
                 Event::Init => {
-                    let mut window = WindowBuilder::new()
-                        .with_view_creation_token(view_creation_token.take().unwrap())
-                        .build(event_sender.clone())
-                        .unwrap();
+                    let mut window = Window::new(event_sender.clone())
+                        .with_view_creation_token(view_creation_token.take().unwrap());
                     window.create_view().expect("Failed to create window for child view");
                     _window_holder = Some(window);
                 }
+                Event::WindowEvent { event: window_event, .. } => match window_event {
+                    WindowEvent::Focused { focused } => {
+                        if focused {
+                            tap(mouse.clone());
+                        }
+                    }
+                    WindowEvent::Mouse { .. } => {
+                        // Inject 'q' to quit.
+                        inject_text("q".to_string(), keyboard.clone());
+                    }
+                    WindowEvent::Keyboard { event, responder } => {
+                        if let KeyEvent { key: Some(Key::Q), .. } = event {
+                            parent_sender
+                                .send(Event::Exit)
+                                .expect("Failed to send Event::Exit event");
+                            responder
+                                .send(KeyEventStatus::Handled)
+                                .expect("Failed to respond to keyboard event");
+                        } else {
+                            responder
+                                .send(KeyEventStatus::NotHandled)
+                                .expect("Failed to respond to keyboard event");
+                        }
+                    }
+                    _ => {}
+                },
                 _ => {}
             }
         }
@@ -259,3 +327,35 @@
 
     Ok(())
 }
+
+fn inject_text(text: String, keyboard: ui_test_input::KeyboardProxy) {
+    fasync::Task::local(async move {
+        keyboard
+            .simulate_us_ascii_text_entry(ui_test_input::KeyboardSimulateUsAsciiTextEntryRequest {
+                text: Some(text),
+                ..ui_test_input::KeyboardSimulateUsAsciiTextEntryRequest::EMPTY
+            })
+            .await
+            .expect("Failed to inject text using fuchsia.ui.test.input.Keyboard");
+    })
+    .detach();
+}
+
+fn tap(mouse: ui_test_input::MouseProxy) {
+    fasync::Task::local(async move {
+        mouse
+            .simulate_mouse_event(ui_test_input::MouseSimulateMouseEventRequest {
+                pressed_buttons: Some(vec![ui_test_input::MouseButton::First]),
+                ..ui_test_input::MouseSimulateMouseEventRequest::EMPTY
+            })
+            .await
+            .expect("Failed to tap using fuchsia.ui.test.input.Mouse");
+        mouse
+            .simulate_mouse_event(ui_test_input::MouseSimulateMouseEventRequest {
+                ..ui_test_input::MouseSimulateMouseEventRequest::EMPTY
+            })
+            .await
+            .expect("Failed to tap using fuchsia.ui.test.input.Mouse");
+    })
+    .detach();
+}
diff --git a/session_shells/gazelle/appkit/src/utils.rs b/session_shells/gazelle/appkit/src/utils.rs
index fd6e53f..26be474 100644
--- a/session_shells/gazelle/appkit/src/utils.rs
+++ b/session_shells/gazelle/appkit/src/utils.rs
@@ -4,9 +4,8 @@
 
 use {
     anyhow::{anyhow, Error},
-    fidl_fuchsia_ui_composition as ui_comp, fuchsia_async as fasync,
+    fidl_fuchsia_ui_composition as ui_comp,
     futures::channel::mpsc::{UnboundedReceiver, UnboundedSender},
-    futures::stream::{AbortHandle, Abortable},
 };
 
 use crate::event::Event;
@@ -77,18 +76,3 @@
         Ok(())
     }
 }
-
-pub fn spawn_abortable<T>(task: T) -> AbortHandle
-where
-    T: 'static + Send + futures::Future,
-{
-    let (abort_handle, abort_registration) = AbortHandle::new_pair();
-    let abortable_fut = Abortable::new(task, abort_registration);
-
-    fasync::Task::spawn(async move {
-        let _ = abortable_fut.await;
-    })
-    .detach();
-
-    abort_handle
-}
diff --git a/session_shells/gazelle/appkit/src/window.rs b/session_shells/gazelle/appkit/src/window.rs
index 9546c11..719a5e7 100644
--- a/session_shells/gazelle/appkit/src/window.rs
+++ b/session_shells/gazelle/appkit/src/window.rs
@@ -10,10 +10,10 @@
     },
     fidl::AsHandleRef,
     fidl_fuchsia_element as felement, fidl_fuchsia_ui_composition as ui_comp,
-    fidl_fuchsia_ui_input3 as ui_input3, fidl_fuchsia_ui_views as ui_views,
+    fidl_fuchsia_ui_input3 as ui_input3, fidl_fuchsia_ui_pointer as fptr,
+    fidl_fuchsia_ui_views as ui_views, fuchsia_async as fasync,
     fuchsia_component::client::connect_to_protocol,
     fuchsia_scenic::flatland::{IdGenerator, ViewCreationTokenPair},
-    futures::future::AbortHandle,
     futures::{FutureExt, StreamExt, TryFutureExt, TryStreamExt},
     std::sync::{Arc, Mutex},
     tracing::*,
@@ -22,7 +22,7 @@
 use crate::{
     child_view::ChildView,
     event::{Event, ViewSpecHolder, WindowEvent},
-    utils::{spawn_abortable, EventSender, Presenter},
+    utils::{EventSender, Presenter},
 };
 
 /// Defines a type to hold an id to the window. This implementation uses the value of
@@ -37,106 +37,71 @@
 }
 
 /// Defines a struct to hold window attributes used to create the window.
+#[derive(Default)]
 pub(crate) struct WindowAttributes {
     /// The title of the window. Only used when presented to the system's GraphicalPresenter.
-    pub title: String,
+    pub title: Option<String>,
     /// The [ViewCreationToken] passed to the application's [ViewProvider]. Unused for windows
     /// presented to the system's GraphicalPresenter.
     pub view_creation_token: Option<ui_views::ViewCreationToken>,
 }
 
-impl Default for WindowAttributes {
-    fn default() -> Self {
-        WindowAttributes { title: "appkit window".to_owned(), view_creation_token: None }
-    }
-}
-
-/// Defines a builder used to collect [WindowAttributes] before building the window.
-#[derive(Default)]
-pub struct WindowBuilder {
-    pub(crate) attributes: WindowAttributes,
-}
-
-impl WindowBuilder {
-    pub fn new() -> Self {
-        Default::default()
-    }
-
-    pub fn with_title(mut self, title: String) -> WindowBuilder {
-        self.attributes.title = title;
-        self
-    }
-
-    pub fn with_view_creation_token(mut self, token: ui_views::ViewCreationToken) -> WindowBuilder {
-        self.attributes.view_creation_token = Some(token);
-        self
-    }
-
-    pub fn build<T>(self, event_sender: EventSender<T>) -> Result<Window<T>, Error> {
-        Window::from_attributes(self.attributes, event_sender)
-    }
-}
-
 const ROOT_TRANSFORM_ID: ui_comp::TransformId = ui_comp::TransformId { value: 1 };
 
 /// Defines a struct to hold [Window] state.
 pub struct Window<T> {
+    attributes: WindowAttributes,
     id: WindowId,
     id_generator: IdGenerator,
     flatland: ui_comp::FlatlandProxy,
-    view_creation_token: Option<ui_views::ViewCreationToken>,
     annotations: Option<Vec<felement::Annotation>>,
     annotation_controller_request_stream: Option<felement::AnnotationControllerRequestStream>,
     view_controller_proxy: Option<felement::ViewControllerProxy>,
     focuser: Option<ui_views::FocuserProxy>,
     event_sender: EventSender<T>,
-    abortable_futures: Vec<AbortHandle>,
+    running_tasks: Vec<fasync::Task<()>>,
     presenter: Arc<Mutex<Presenter>>,
 }
 
-impl<T> Drop for Window<T> {
-    fn drop(&mut self) {
-        for abortable_fut in &self.abortable_futures {
-            abortable_fut.abort();
-        }
-        self.abortable_futures.clear();
-    }
-}
-
 impl<T> Window<T> {
-    pub fn new(event_sender: EventSender<T>) -> Result<Window<T>, Error> {
-        let builder = WindowBuilder::new();
-        builder.build(event_sender)
-    }
-
-    /// Creates a [Window] from [attributes].
-    pub(crate) fn from_attributes(
-        mut attributes: WindowAttributes,
-        event_sender: EventSender<T>,
-    ) -> Result<Window<T>, Error> {
+    pub fn new(event_sender: EventSender<T>) -> Window<T> {
         let id_generator = IdGenerator::new_with_first_id(ROOT_TRANSFORM_ID.value);
-        let flatland = connect_to_protocol::<ui_comp::FlatlandMarker>()?;
-        flatland.create_transform(&mut ROOT_TRANSFORM_ID.clone())?;
-        flatland.set_root_transform(&mut ROOT_TRANSFORM_ID.clone())?;
+        let flatland = connect_to_protocol::<ui_comp::FlatlandMarker>()
+            .expect("Failed to connect to fuchsia.ui.comp.Flatland");
+        flatland
+            .create_transform(&mut ROOT_TRANSFORM_ID.clone())
+            .expect("Failed to create transform");
+        flatland
+            .set_root_transform(&mut ROOT_TRANSFORM_ID.clone())
+            .expect("Failed to set root transform");
 
-        let view_creation_token = attributes.view_creation_token.take();
         let id = WindowId(0);
         let presenter = Arc::new(Mutex::new(Presenter::new(flatland.clone())));
-        let annotations = Self::annotations_from_window_attributes(&attributes);
+        let attributes = WindowAttributes::default();
 
-        Ok(Window {
+        Self {
+            attributes,
             id,
             id_generator,
             flatland,
-            view_creation_token,
-            annotations,
+            annotations: None,
             annotation_controller_request_stream: None,
             view_controller_proxy: None,
             focuser: None,
             event_sender,
-            abortable_futures: vec![],
+            running_tasks: vec![],
             presenter,
-        })
+        }
+    }
+
+    pub fn with_title(mut self, title: String) -> Window<T> {
+        self.attributes.title = Some(title);
+        self
+    }
+
+    pub fn with_view_creation_token(mut self, token: ui_views::ViewCreationToken) -> Window<T> {
+        self.attributes.view_creation_token = Some(token);
+        self
     }
 
     pub fn id(&self) -> WindowId {
@@ -180,12 +145,12 @@
         if let Some(focuser) = self.focuser.clone() {
             let mut dup_view_ref = fuchsia_scenic::duplicate_view_ref(&view_ref)
                 .expect("Failed to duplicate view_ref for request_focus");
-            let abort_handle = spawn_abortable(async move {
+            let task = fasync::Task::spawn(async move {
                 if let Err(error) = focuser.request_focus(&mut dup_view_ref).await {
                     error!("Failed to request focus on a view: {:?}", error);
                 }
             });
-            self.abortable_futures.push(abort_handle);
+            self.running_tasks.push(task);
         }
     }
 
@@ -211,7 +176,7 @@
 
         let (mut view_creation_token, viewport_creation_token) =
             // Check if view_creation_token was passed from ViewProvider.
-            match self.view_creation_token.take() {
+            match self.attributes.view_creation_token.take() {
                 Some(view_creation_token) => (view_creation_token, None),
                 None => {
                     // Create a pair of view creation token to present to GraphicalPresenter.
@@ -220,13 +185,17 @@
                     (view_creation_token, Some(viewport_creation_token))
                 }
             };
+        let (focused, focused_request) = create_proxy::<ui_views::ViewRefFocusedMarker>()?;
         let (view_focuser, view_focuser_request) = create_proxy::<ui_views::FocuserMarker>()?;
+        let (mouse, mouse_request) = create_proxy::<fptr::MouseSourceMarker>()?;
 
         self.id = WindowId::from_view_creation_token(&view_creation_token);
         self.focuser = Some(view_focuser);
 
         let view_bound_protocols = ui_comp::ViewBoundProtocols {
+            view_ref_focused: Some(focused_request),
             view_focuser: Some(view_focuser_request),
+            mouse_source: Some(mouse_request),
             ..ui_comp::ViewBoundProtocols::EMPTY
         };
 
@@ -246,6 +215,9 @@
             self.event_sender.clone(),
         );
 
+        let viewref_focused_fut =
+            Self::serve_view_ref_focused_watcher(self.id(), focused, self.event_sender.clone());
+
         // If we created a viewport_creation_token earlier, we intend to present to the system's
         // GraphicalPresenter. Connect to it to present the window.
         let graphical_presenter_fut = match viewport_creation_token {
@@ -257,6 +229,7 @@
                 self.annotation_controller_request_stream =
                     Some(annotation_controller_server_end.into_stream().unwrap());
                 self.view_controller_proxy = Some(view_controller_proxy.clone());
+                self.annotations = Self::annotations_from_window_attributes(&self.attributes);
                 Self::connect_to_graphical_presenter(
                     self.id(),
                     self.annotations.take(),
@@ -279,11 +252,20 @@
         )
         .boxed();
 
+        let mouse_fut =
+            Self::serve_mouse_source_watcher(self.id(), mouse, self.event_sender.clone());
+
         // Collect all futures into an abortable spawned task. The task is aborted in [Drop].
-        let abort_handle = spawn_abortable(async move {
-            futures::join!(flatland_and_layout_watcher_fut, graphical_presenter_fut, keyboard_fut)
+        let task = fasync::Task::spawn(async move {
+            futures::join!(
+                flatland_and_layout_watcher_fut,
+                viewref_focused_fut,
+                graphical_presenter_fut,
+                keyboard_fut,
+                mouse_fut,
+            );
         });
-        self.abortable_futures.push(abort_handle);
+        self.running_tasks.push(task);
 
         Ok(())
     }
@@ -312,9 +294,36 @@
         Ok(child_view)
     }
 
+    /// Creates an instance of [ChildView] given a [ViewportCreationToken].
+    pub fn create_child_view_from_viewport(
+        &mut self,
+        viewport_creation_token: ui_views::ViewportCreationToken,
+        width: u32,
+        height: u32,
+        event_sender: EventSender<T>,
+    ) -> Result<ChildView<T>, Error>
+    where
+        T: 'static + Sync + Send,
+    {
+        self.create_child_view(
+            ViewSpecHolder {
+                view_spec: felement::ViewSpec {
+                    viewport_creation_token: Some(viewport_creation_token),
+                    ..felement::ViewSpec::EMPTY
+                },
+                annotation_controller: None,
+                view_controller_request: None,
+                responder: None,
+            },
+            width,
+            height,
+            event_sender,
+        )
+    }
+
     // Waits for first layout event before monitoring flatland events and layout changes.
     async fn serve_flatland_events_and_layout_watcher(
-        id: WindowId,
+        window_id: WindowId,
         flatland: ui_comp::FlatlandProxy,
         presenter: Arc<Mutex<Presenter>>,
         parent_viewport_watcher: ui_comp::ParentViewportWatcherProxy,
@@ -326,20 +335,26 @@
             let width = logical_size.width;
             let height = logical_size.height;
             event_sender
-                .send(Event::WindowEvent(id, WindowEvent::Resized(width, height)))
+                .send(Event::WindowEvent {
+                    window_id,
+                    event: WindowEvent::Resized { width, height },
+                })
                 .expect("Failed to send WindowEvent::Resized event");
         }
 
         let flatland_events_fut =
-            Self::serve_flatland_events(id, flatland, presenter, event_sender.clone());
-        let layout_watcher_fut =
-            Self::serve_layout_info_watcher(id, parent_viewport_watcher, event_sender.clone());
+            Self::serve_flatland_events(window_id, flatland, presenter, event_sender.clone());
+        let layout_watcher_fut = Self::serve_layout_info_watcher(
+            window_id,
+            parent_viewport_watcher,
+            event_sender.clone(),
+        );
 
         futures::join!(flatland_events_fut, layout_watcher_fut);
     }
 
     async fn serve_flatland_events(
-        id: WindowId,
+        window_id: WindowId,
         flatland: ui_comp::FlatlandProxy,
         presenter: Arc<Mutex<Presenter>>,
         event_sender: EventSender<T>,
@@ -350,7 +365,7 @@
             .try_for_each(move |event| {
                 match event {
                     ui_comp::FlatlandEvent::OnNextFrameBegin { values } => {
-                        let next_presentation_time = values
+                        let next_present_time = values
                             .future_presentation_infos
                             .as_ref()
                             .and_then(|infos| infos.first())
@@ -361,10 +376,10 @@
                             .map(|mut presenter| presenter.on_next_frame(values))
                             .expect("Failed to call on_next_frame on presenter");
                         event_sender
-                            .send(Event::WindowEvent(
-                                id,
-                                WindowEvent::NeedsRedraw(next_presentation_time),
-                            ))
+                            .send(Event::WindowEvent {
+                                window_id,
+                                event: WindowEvent::NeedsRedraw { next_present_time },
+                            })
                             .expect("Failed to send WindowEvent::NeedsRedraw event");
                     }
                     ui_comp::FlatlandEvent::OnFramePresented { .. } => {}
@@ -379,7 +394,7 @@
     }
 
     async fn serve_layout_info_watcher(
-        id: WindowId,
+        window_id: WindowId,
         parent_viewport_watcher: ui_comp::ParentViewportWatcherProxy,
         event_sender: EventSender<T>,
     ) {
@@ -398,13 +413,16 @@
                         height = logical_size.height;
                     }
                     event_sender
-                        .send(Event::WindowEvent(id, WindowEvent::Resized(width, height)))
+                        .send(Event::WindowEvent {
+                            window_id,
+                            event: WindowEvent::Resized { width, height },
+                        })
                         .expect("Failed to send WindowEvent::Resized event");
                 }
                 Err(fidl::Error::ClientChannelClosed { .. }) => {
                     info!("ParentViewportWatcher connection closed.");
                     event_sender
-                        .send(Event::WindowEvent(id, WindowEvent::Closed))
+                        .send(Event::WindowEvent { window_id, event: WindowEvent::Closed })
                         .expect("Failed to send WindowEvent::Closed event");
                     break;
                 }
@@ -415,8 +433,72 @@
         }
     }
 
+    async fn serve_view_ref_focused_watcher(
+        window_id: WindowId,
+        focused: ui_views::ViewRefFocusedProxy,
+        event_sender: EventSender<T>,
+    ) {
+        let mut focused_stream =
+            HangingGetStream::new(focused, ui_views::ViewRefFocusedProxy::watch);
+        while let Some(result) = focused_stream.next().await {
+            match result {
+                Ok(ui_views::FocusState { focused: Some(focused), .. }) => {
+                    event_sender
+                        .send(Event::WindowEvent {
+                            window_id,
+                            event: WindowEvent::Focused { focused },
+                        })
+                        .expect("Failed to send WindowEvent::Focused event");
+                }
+                Ok(ui_views::FocusState { focused: None, .. }) => {
+                    error!("Missing required field FocusState.focused");
+                }
+                Err(fidl::Error::ClientChannelClosed { .. }) => {
+                    error!("ViewRefFocused connection closed.");
+                    break;
+                }
+                Err(fidl_error) => {
+                    error!("ViewRefFocused fidl error: {:?}", fidl_error);
+                    break;
+                }
+            }
+        }
+    }
+
+    async fn serve_mouse_source_watcher(
+        window_id: WindowId,
+        mouse_source: fptr::MouseSourceProxy,
+        event_sender: EventSender<T>,
+    ) {
+        let mut mouse_source_stream =
+            HangingGetStream::new(mouse_source, fptr::MouseSourceProxy::watch);
+
+        while let Some(result) = mouse_source_stream.next().await {
+            match result {
+                Ok(events) => {
+                    for event in events.iter() {
+                        event_sender
+                            .send(Event::WindowEvent {
+                                window_id,
+                                event: WindowEvent::Mouse { event: event.clone() },
+                            })
+                            .expect("failed to send Message.");
+                    }
+                }
+                Err(fidl::Error::ClientChannelClosed { .. }) => {
+                    error!("MouseSource connection closed.");
+                    break;
+                }
+                Err(fidl_error) => {
+                    warn!("MouseSource Watch() error: {:?}", fidl_error);
+                    break;
+                }
+            }
+        }
+    }
+
     async fn connect_to_graphical_presenter(
-        id: WindowId,
+        window_id: WindowId,
         annotations: Option<Vec<felement::Annotation>>,
         viewport_creation_token: ui_views::ViewportCreationToken,
         view_ref: ui_views::ViewRef,
@@ -449,12 +531,12 @@
         let stream = view_controller_proxy.take_event_stream();
         let _ = stream.collect::<Vec<_>>().await;
         event_sender
-            .send(Event::WindowEvent(id, WindowEvent::Closed))
+            .send(Event::WindowEvent { window_id, event: WindowEvent::Closed })
             .expect("Failed to send WindowEvent::Closed event");
     }
 
     async fn serve_keyboard_listener(
-        id: WindowId,
+        window_id: WindowId,
         mut view_ref: ui_views::ViewRef,
         event_sender: EventSender<T>,
     ) {
@@ -466,11 +548,14 @@
 
         match keyboard.add_listener(&mut view_ref, listener_client_end).await {
             Ok(()) => {
-                while let Ok(event) = listener_stream.next().await.unwrap() {
+                while let Some(Ok(event)) = listener_stream.next().await {
                     let ui_input3::KeyboardListenerRequest::OnKeyEvent { event, responder, .. } =
                         event;
                     event_sender
-                        .send(Event::WindowEvent(id, WindowEvent::Keyboard(event, responder)))
+                        .send(Event::WindowEvent {
+                            window_id,
+                            event: WindowEvent::Keyboard { event, responder },
+                        })
                         .expect("Failed to send WindowEvent::Keyboard event");
                 }
             }
@@ -484,12 +569,13 @@
         attributes: &WindowAttributes,
     ) -> Option<Vec<felement::Annotation>> {
         // TODO(https://fxbug.dev/108345): Stop hardcoding namespace for ermine shell.
+        let title = attributes.title.clone()?;
         let annotations = vec![felement::Annotation {
             key: felement::AnnotationKey {
                 namespace: "ermine".to_owned(),
                 value: "name".to_owned(),
             },
-            value: felement::AnnotationValue::Text(attributes.title.clone()),
+            value: felement::AnnotationValue::Text(title),
         }];
         Some(annotations)
     }
diff --git a/session_shells/gazelle/examples/bouncing_box/src/main.rs b/session_shells/gazelle/examples/bouncing_box/src/main.rs
index a7e5582..0812780 100644
--- a/session_shells/gazelle/examples/bouncing_box/src/main.rs
+++ b/session_shells/gazelle/examples/bouncing_box/src/main.rs
@@ -4,7 +4,7 @@
 
 use {
     anyhow::Error,
-    appkit::{Event, EventSender, Window, WindowBuilder, WindowEvent, WindowId},
+    appkit::{Event, EventSender, Window, WindowEvent, WindowId},
     fidl_fuchsia_input::Key,
     fidl_fuchsia_math as fmath, fidl_fuchsia_ui_composition as ui_comp,
     fidl_fuchsia_ui_input3::{KeyEvent, KeyEventStatus, KeyEventType},
@@ -83,10 +83,8 @@
         match event {
             Event::Init => {
                 // Create the application's window.
-                let mut window = WindowBuilder::new()
-                    .with_title("Bouncing Box".to_owned())
-                    .build(self.event_sender.clone())
-                    .unwrap();
+                let mut window =
+                    Window::new(self.event_sender.clone()).with_title("Bouncing Box".to_owned());
                 window.create_view()?;
 
                 // Create the bouncer.
@@ -96,20 +94,20 @@
 
                 self.windows.insert(window.id(), window);
             }
-            Event::WindowEvent(id, window_event) => {
+            Event::WindowEvent { window_id: id, event: window_event } => {
                 let window = self.windows.get_mut(&id).unwrap();
                 match window_event {
-                    WindowEvent::Resized(width, height) => {
+                    WindowEvent::Resized { width, height } => {
                         // Set the bouncer's size.
                         let size = fmath::SizeU { width, height };
                         self.bouncer.as_mut().map(|bouncer| bouncer.size = size);
                     }
-                    WindowEvent::NeedsRedraw(_presentation_time) => {
+                    WindowEvent::NeedsRedraw { .. } => {
                         // Update bouncer's position on every frame.
                         self.bouncer.as_mut().map(|bouncer| bouncer.update());
                         window.redraw();
                     }
-                    WindowEvent::Keyboard(event, responder) => {
+                    WindowEvent::Keyboard { event, responder } => {
                         // Quit app on 'q' is pressed.
                         if let KeyEvent {
                             type_: Some(KeyEventType::Pressed),
diff --git a/session_shells/gazelle/pointer_fusion/BUILD.gn b/session_shells/gazelle/pointer_fusion/BUILD.gn
new file mode 100644
index 0000000..09bcb62
--- /dev/null
+++ b/session_shells/gazelle/pointer_fusion/BUILD.gn
@@ -0,0 +1,35 @@
+# Copyright 2022 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+import("//build/components.gni")
+import("//build/rust/rustc_library.gni")
+
+rustc_library("pointer_fusion") {
+  name = "pointer_fusion"
+  with_unit_tests = true
+  version = "0.1.0"
+  edition = "2018"
+  sources = [
+    "src/lib.rs",
+    "src/pointer/mod.rs",
+    "src/pointer/mouse.rs",
+    "src/pointer/touch.rs",
+    "src/tests.rs",
+  ]
+  deps = [
+    "//sdk/fidl/fuchsia.ui.pointer:fuchsia.ui.pointer_rust",
+    "//src/lib/zircon/rust:fuchsia-zircon",
+    "//third_party/rust_crates:futures",
+    "//third_party/rust_crates:num",
+  ]
+  test_deps = [ "//src/lib/fuchsia" ]
+}
+
+fuchsia_unittest_package("pointer_fusion_tests") {
+  deps = [ ":pointer_fusion_test" ]
+}
+
+group("tests") {
+  testonly = true
+  deps = [ ":pointer_fusion_tests" ]
+}
diff --git a/session_shells/gazelle/pointer_fusion/src/lib.rs b/session_shells/gazelle/pointer_fusion/src/lib.rs
new file mode 100644
index 0000000..6689e40
--- /dev/null
+++ b/session_shells/gazelle/pointer_fusion/src/lib.rs
@@ -0,0 +1,113 @@
+// Copyright 2022 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+mod pointer;
+
+#[cfg(test)]
+mod tests;
+
+use {
+    crate::pointer::PointerFusionState,
+    fidl_fuchsia_ui_pointer as fptr, fuchsia_zircon as zx,
+    futures::{
+        channel::mpsc::{self, UnboundedSender},
+        stream, Stream, StreamExt,
+    },
+};
+
+#[derive(Clone, Default, Debug)]
+pub enum DeviceKind {
+    #[default]
+    Touch,
+    Mouse,
+    Stylus,
+    InvertedStylus,
+    Trackpad,
+}
+
+#[derive(Clone, Default, Debug)]
+pub enum Phase {
+    #[default]
+    Cancel,
+    Add,
+    Remove,
+    Hover,
+    Down,
+    Move,
+    Up,
+}
+
+#[derive(Clone, Default, Debug)]
+pub enum SignalKind {
+    #[default]
+    None,
+    Scroll,
+    ScrollInertiaCancel,
+}
+
+pub const POINTER_BUTTON_1: i64 = 1 << 0;
+pub const POINTER_BUTTON_2: i64 = 1 << 1;
+pub const POINTER_BUTTON_3: i64 = 1 << 2;
+pub const POINTER_BUTTON_4: i64 = 1 << 3;
+pub const POINTER_BUTTON_5: i64 = 1 << 4;
+
+/// Information about the state of a pointer.
+#[derive(Clone, Default, Debug)]
+pub struct PointerEvent {
+    /// The monotonically increasing identifier that is present only on 'Down' events and
+    /// is 0 otherwise.
+    pub id: i64,
+    /// The kind of input device.
+    pub kind: DeviceKind,
+    /// The timestamp when the event originated. This is monotonically increasing for the same
+    /// [DeviceKind]. Timestamp for synthesized events is same as event synthesized from.
+    pub timestamp: zx::Time,
+    /// The current [Phase] of pointer event.
+    pub phase: Phase,
+    /// The unique device identifier.
+    pub device_id: u32,
+    /// The x position of the device, in the viewport's coordinate system, as reported by the raw
+    /// device event.
+    pub physical_x: f32,
+    /// The y position of the device, in the viewport's coordinate system, as reported by the raw
+    /// device event.
+    pub physical_y: f32,
+    /// The relative change in x position of the device from previous event in sequence.
+    pub physical_delta_x: f32,
+    /// The relative change in y position of the device from previous event in sequence.
+    pub physical_delta_y: f32,
+    /// The buttons pressed on the device represented as bitflags.
+    pub buttons: i64,
+    /// The event [SignalKind] for scroll events.
+    pub signal_kind: SignalKind,
+    /// The amount of scroll in x direction, in physical pixels.
+    pub scroll_delta_y: f64,
+    /// The amount of scroll in y direction, in physical pixels.
+    pub scroll_delta_x: f64,
+    /// Set if this [PointerEvent] was synthesized for maintaining legal input sequence.
+    pub synthesized: bool,
+}
+
+#[derive(Debug)]
+pub enum InputEvent {
+    MouseEvent(fptr::MouseEvent),
+    TouchEvent(fptr::TouchEvent),
+}
+
+/// Provides a stream of [PointerEvent] fused from [InputEvent::MouseEvent] and
+/// [InputEvent::TouchEvent].
+///
+/// * `pixel_ratio` -  The device pixel ratio used to convert from logical to physical coordinates.
+///
+/// Returns a tuple of [UnboundedSender] to send [InputEvent]s to and a [Stream] to read fused
+/// [PointerEvent]s from.
+pub fn pointer_fusion(
+    pixel_ratio: f32,
+) -> (UnboundedSender<InputEvent>, impl Stream<Item = PointerEvent>) {
+    let mut state = PointerFusionState::new(pixel_ratio);
+    let (input_sender, receiver) = mpsc::unbounded::<InputEvent>();
+    let pointer_stream = receiver.map(move |input| stream::iter(state.fuse_input(input))).flatten();
+
+    (input_sender, pointer_stream)
+}
diff --git a/session_shells/gazelle/pointer_fusion/src/pointer/mod.rs b/session_shells/gazelle/pointer_fusion/src/pointer/mod.rs
new file mode 100644
index 0000000..5d90b91
--- /dev/null
+++ b/session_shells/gazelle/pointer_fusion/src/pointer/mod.rs
@@ -0,0 +1,76 @@
+// Copyright 2022 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+mod mouse;
+mod touch;
+
+use {
+    super::*,
+    fidl_fuchsia_ui_pointer as fptr, num,
+    std::collections::{HashMap, HashSet},
+    std::f32::EPSILON,
+};
+
+pub struct PointerFusionState {
+    pixel_ratio: f32,
+    mouse_device_info: HashMap<u32, fptr::MouseDeviceInfo>,
+    mouse_down: HashSet<u32>,
+    mouse_view_parameters: Option<fptr::ViewParameters>,
+    pointer_states: HashMap<u32, PointerState>,
+    next_pointer_id: i64,
+}
+
+impl PointerFusionState {
+    /// Constructs a [PointerFusionState] with a display [pixel_ratio] used to convert logical
+    /// coordinates into physical coordinates.
+    pub fn new(pixel_ratio: f32) -> Self {
+        PointerFusionState {
+            pixel_ratio,
+            mouse_device_info: HashMap::new(),
+            mouse_down: HashSet::new(),
+            mouse_view_parameters: None,
+            pointer_states: HashMap::new(),
+            next_pointer_id: 0,
+        }
+    }
+
+    pub fn fuse_input(&mut self, input: InputEvent) -> Vec<PointerEvent> {
+        match input {
+            InputEvent::MouseEvent(mouse_event) => self.fuse_mouse(mouse_event),
+            InputEvent::TouchEvent(touch_event) => self.fuse_touch(touch_event),
+        }
+    }
+}
+
+// The current information about a pointer derived from previous [PointerEvent]s. This is used to
+// sanitized the pointer stream and synthesize addition data like `physical_delta_x`.
+#[derive(Copy, Clone, Default)]
+struct PointerState {
+    id: i64,
+    is_down: bool,
+    physical_x: f32,
+    physical_y: f32,
+    buttons: i64,
+}
+
+impl PointerState {
+    pub(crate) fn from_event(event: &PointerEvent) -> Self {
+        let mut state = PointerState::default();
+        state.physical_x = event.physical_x;
+        state.physical_y = event.physical_y;
+        state
+    }
+
+    pub(crate) fn is_location_changed(&self, event: &PointerEvent) -> bool {
+        self.physical_x != event.physical_x || self.physical_y != event.physical_y
+    }
+
+    pub(crate) fn is_button_state_changed(&self, event: &PointerEvent) -> bool {
+        self.buttons != event.buttons
+    }
+
+    pub(crate) fn compute_delta(&self, event: &PointerEvent) -> (f32, f32) {
+        (event.physical_x - self.physical_x, event.physical_y - self.physical_y)
+    }
+}
diff --git a/session_shells/gazelle/pointer_fusion/src/pointer/mouse.rs b/session_shells/gazelle/pointer_fusion/src/pointer/mouse.rs
new file mode 100644
index 0000000..fed6308
--- /dev/null
+++ b/session_shells/gazelle/pointer_fusion/src/pointer/mouse.rs
@@ -0,0 +1,366 @@
+// Copyright 2022 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use {
+    super::*, fidl_fuchsia_ui_pointer as fptr, fuchsia_zircon as zx, num, std::collections::HashSet,
+};
+
+const SCROLL_OFFSET_MULTIPLIER: i64 = 20;
+
+impl PointerFusionState {
+    // Converts raw [fptr::MouseEvent]s to one or more [PointerEvent]s.
+    pub(super) fn fuse_mouse(&mut self, event: fptr::MouseEvent) -> Vec<PointerEvent> {
+        if let Some(ref device_info) = event.device_info {
+            self.mouse_device_info.insert(device_info.id.unwrap_or(0), device_info.clone());
+        }
+
+        if event.view_parameters.is_some() {
+            self.mouse_view_parameters = event.view_parameters;
+        }
+
+        if has_valid_mouse_sample(&event) && self.mouse_view_parameters.is_some() {
+            let sample = event.pointer_sample.as_ref().unwrap();
+            let id = sample.device_id.unwrap();
+            if self.mouse_device_info.contains_key(&id) {
+                let any_button_down = sample.pressed_buttons.is_some();
+                let phase = compute_mouse_phase(any_button_down, &mut self.mouse_down, id);
+
+                let pointer_event = create_mouse_draft(
+                    &event,
+                    phase,
+                    self.mouse_view_parameters.as_ref().unwrap(),
+                    self.mouse_device_info.get(&id).unwrap(),
+                    self.pixel_ratio,
+                );
+
+                let sanitized_events = self.sanitize_pointer(pointer_event);
+                return sanitized_events;
+            }
+        }
+
+        vec![]
+    }
+
+    // Sanitizes the [PointerEvent] draft such that the resulting event stream is contextually
+    // correct. It may drop events or synthesize new events to keep the event stream sane.
+    //
+    // Note: It is still possible to craft an event stream that will cause an assert check to fail
+    // on debug builds.
+    fn sanitize_pointer(&mut self, mut event: PointerEvent) -> Vec<PointerEvent> {
+        let mut converted_pointers = vec![];
+        match event.signal_kind {
+            SignalKind::None => match event.phase {
+                // Drops the Cancel if the pointer is not previously added.
+                Phase::Cancel => {
+                    if let Some(state) = self.pointer_states.get_mut(&event.device_id) {
+                        assert!(state.is_down);
+
+                        event.id = state.id;
+                        // Synthesize a move event if the location does not match.
+                        if state.is_location_changed(&event) {
+                            let (physical_delta_x, physical_delta_y) = state.compute_delta(&event);
+                            let move_event = PointerEvent {
+                                physical_delta_x,
+                                physical_delta_y,
+                                phase: Phase::Move,
+                                synthesized: true,
+                                ..event.clone()
+                            };
+
+                            state.physical_x = move_event.physical_x;
+                            state.physical_y = move_event.physical_y;
+
+                            converted_pointers.push(move_event);
+                        }
+                        state.is_down = false;
+                        converted_pointers.push(event);
+                    }
+                }
+                Phase::Add => {
+                    assert!(!self.pointer_states.contains_key(&event.device_id));
+                    let state = PointerState::from_event(&event);
+                    self.pointer_states.insert(event.device_id, state);
+
+                    converted_pointers.push(event);
+                }
+                Phase::Remove => {
+                    assert!(self.pointer_states.contains_key(&event.device_id));
+                    if let Some(state) = self.pointer_states.get_mut(&event.device_id) {
+                        // Synthesize a Cancel event if pointer is down.
+                        if state.is_down {
+                            let mut cancel_event = event.clone();
+                            cancel_event.phase = Phase::Cancel;
+                            cancel_event.synthesized = true;
+                            cancel_event.id = state.id;
+
+                            state.is_down = false;
+                            converted_pointers.push(cancel_event);
+                        }
+
+                        // Synthesize a hover event if the location does not match.
+                        if state.is_location_changed(&event) {
+                            let (physical_delta_x, physical_delta_y) = state.compute_delta(&event);
+                            let hover_event = PointerEvent {
+                                physical_delta_x,
+                                physical_delta_y,
+                                phase: Phase::Hover,
+                                synthesized: true,
+                                ..event.clone()
+                            };
+
+                            state.physical_x = hover_event.physical_x;
+                            state.physical_y = hover_event.physical_y;
+
+                            converted_pointers.push(hover_event);
+                        }
+                    }
+                    self.pointer_states.remove(&event.device_id);
+                    converted_pointers.push(event);
+                }
+                Phase::Hover => {
+                    let mut state = match self.pointer_states.get_mut(&event.device_id) {
+                        Some(state) => *state,
+                        None => {
+                            // Synthesize add event if the pointer is not previously added.
+                            let mut add_event = event.clone();
+                            add_event.phase = Phase::Add;
+                            add_event.synthesized = true;
+                            let state = PointerState::from_event(&add_event);
+                            self.pointer_states.insert(add_event.device_id, state);
+
+                            converted_pointers.push(add_event);
+                            state
+                        }
+                    };
+
+                    assert!(!state.is_down);
+                    if state.is_location_changed(&event) {
+                        let (physical_delta_x, physical_delta_y) = state.compute_delta(&event);
+                        event.physical_delta_x = physical_delta_x;
+                        event.physical_delta_y = physical_delta_y;
+
+                        state.physical_x = event.physical_x;
+                        state.physical_y = event.physical_y;
+                        converted_pointers.push(event);
+                    }
+                }
+                Phase::Down => {
+                    let mut state = match self.pointer_states.get_mut(&event.device_id) {
+                        Some(state) => *state,
+                        None => {
+                            // Synthesize add event if the pointer is not previously added.
+                            let mut add_event = event.clone();
+                            add_event.phase = Phase::Add;
+                            add_event.synthesized = true;
+                            let state = PointerState::from_event(&add_event);
+                            self.pointer_states.insert(add_event.device_id, state);
+
+                            converted_pointers.push(add_event);
+                            state
+                        }
+                    };
+
+                    assert!(!state.is_down);
+                    // Synthesize a hover event if the location does not match.
+                    if state.is_location_changed(&event) {
+                        let (physical_delta_x, physical_delta_y) = state.compute_delta(&event);
+                        let hover_event = PointerEvent {
+                            physical_delta_x,
+                            physical_delta_y,
+                            phase: Phase::Hover,
+                            synthesized: true,
+                            ..event.clone()
+                        };
+
+                        state.physical_x = hover_event.physical_x;
+                        state.physical_y = hover_event.physical_y;
+
+                        converted_pointers.push(hover_event);
+                    }
+                    self.next_pointer_id += 1;
+                    state.id = self.next_pointer_id;
+                    state.is_down = true;
+                    state.buttons = event.buttons;
+                    self.pointer_states.insert(event.device_id, state);
+                    converted_pointers.push(event);
+                }
+                Phase::Move => {
+                    // Makes sure we have an existing pointer in down state
+                    let mut state =
+                        self.pointer_states.get_mut(&event.device_id).expect("State should exist");
+                    assert!(state.is_down);
+                    event.id = state.id;
+
+                    // Skip this event if location does not change.
+                    if state.is_location_changed(&event) || state.is_button_state_changed(&event) {
+                        let (physical_delta_x, physical_delta_y) = state.compute_delta(&event);
+                        event.physical_delta_x = physical_delta_x;
+                        event.physical_delta_y = physical_delta_y;
+
+                        state.physical_x = event.physical_x;
+                        state.physical_y = event.physical_y;
+                        state.buttons = event.buttons;
+                        converted_pointers.push(event);
+                    }
+                }
+                Phase::Up => {
+                    // Makes sure we have an existing pointer in down state
+                    let mut state =
+                        self.pointer_states.get_mut(&event.device_id).expect("State should exist");
+                    assert!(state.is_down);
+                    event.id = state.id;
+
+                    // Up phase should include which buttons where released.
+                    let new_buttons = event.buttons;
+                    event.buttons = state.buttons;
+
+                    // Synthesize a move event if the location does not match.
+                    if state.is_location_changed(&event) {
+                        let (physical_delta_x, physical_delta_y) = state.compute_delta(&event);
+                        let move_event = PointerEvent {
+                            physical_delta_x,
+                            physical_delta_y,
+                            phase: Phase::Move,
+                            synthesized: true,
+                            ..event.clone()
+                        };
+
+                        state.physical_x = move_event.physical_x;
+                        state.physical_y = move_event.physical_y;
+
+                        converted_pointers.push(move_event);
+                    }
+                    state.is_down = false;
+                    state.buttons = new_buttons;
+                    converted_pointers.push(event);
+                }
+            },
+            // Handle scroll events.
+            _ => {}
+        }
+        converted_pointers
+    }
+}
+
+fn compute_mouse_phase(any_button_down: bool, mouse_down: &mut HashSet<u32>, id: u32) -> Phase {
+    if !mouse_down.contains(&id) && !any_button_down {
+        return Phase::Hover;
+    } else if !mouse_down.contains(&id) && any_button_down {
+        mouse_down.insert(id);
+        return Phase::Down;
+    } else if mouse_down.contains(&id) && any_button_down {
+        return Phase::Move;
+    } else if mouse_down.contains(&id) && !any_button_down {
+        mouse_down.remove(&id);
+        return Phase::Up;
+    } else {
+        return Phase::Cancel;
+    }
+}
+
+fn create_mouse_draft(
+    event: &fptr::MouseEvent,
+    phase: Phase,
+    view_parameters: &fptr::ViewParameters,
+    device_info: &fptr::MouseDeviceInfo,
+    pixel_ratio: f32,
+) -> PointerEvent {
+    assert!(has_valid_mouse_sample(event));
+
+    let sample = event.pointer_sample.as_ref().unwrap();
+
+    let mut pointer = PointerEvent::default();
+    pointer.timestamp = zx::Time::from_nanos(event.timestamp.unwrap_or(0));
+    pointer.phase = phase;
+    pointer.kind = DeviceKind::Mouse;
+    pointer.device_id = sample.device_id.unwrap_or(0);
+
+    let [logical_x, logical_y] =
+        viewport_to_view_coordinates(sample.position_in_viewport.unwrap(), view_parameters);
+    pointer.physical_x = logical_x * pixel_ratio;
+    pointer.physical_y = logical_y * pixel_ratio;
+
+    if sample.pressed_buttons.is_some() && device_info.buttons.is_some() {
+        let mut pointer_buttons: i64 = 0;
+        let pressed = sample.pressed_buttons.as_ref().unwrap();
+        let device_buttons = device_info.buttons.as_ref().unwrap();
+        for button_id in pressed {
+            if let Some(index) = device_buttons.iter().position(|&r| r == *button_id) {
+                pointer_buttons |= 1 << index;
+            }
+        }
+        pointer.buttons = pointer_buttons;
+    }
+
+    if sample.scroll_h.is_some()
+        || sample.scroll_v.is_some()
+        || sample.scroll_h_physical_pixel.is_some()
+        || sample.scroll_v_physical_pixel.is_some()
+    {
+        let tick_x_20ths = sample.scroll_h.unwrap_or(0) * SCROLL_OFFSET_MULTIPLIER;
+        let tick_y_20ths = sample.scroll_v.unwrap_or(0) * SCROLL_OFFSET_MULTIPLIER;
+        let offset_x = sample.scroll_h_physical_pixel.unwrap_or(tick_x_20ths as f64);
+        let offset_y = sample.scroll_v_physical_pixel.unwrap_or(tick_y_20ths as f64);
+
+        pointer.scroll_delta_x = offset_x;
+        pointer.scroll_delta_y = offset_y;
+    }
+
+    pointer
+}
+
+fn has_valid_mouse_sample(event: &fptr::MouseEvent) -> bool {
+    if event.pointer_sample.is_none() {
+        return false;
+    }
+    let sample = event.pointer_sample.as_ref().unwrap();
+    sample.device_id.is_some()
+        && sample.position_in_viewport.is_some()
+        && (sample.pressed_buttons.is_none()
+            || !sample.pressed_buttons.as_ref().unwrap().is_empty())
+}
+
+fn viewport_to_view_coordinates(
+    viewport_coordinates: [f32; 2],
+    view_parameters: &fptr::ViewParameters,
+) -> [f32; 2] {
+    let viewport_to_view_transform = view_parameters.viewport_to_view_transform;
+    // The transform matrix is a FIDL array with matrix data in column-major
+    // order. For a matrix with data [a b c d e f g h i], and with the viewport
+    // coordinates expressed as homogeneous coordinates, the logical view
+    // coordinates are obtained with the following formula:
+    //   |a d g|   |x|   |x'|
+    //   |b e h| * |y| = |y'|
+    //   |c f i|   |1|   |w'|
+    // which we then normalize based on the w component:
+    //   if z' not zero: (x'/w', y'/w')
+    //   else (x', y')
+    let m = viewport_to_view_transform;
+    let x = viewport_coordinates[0];
+    let y = viewport_coordinates[1];
+    let xp = m[0] * x + m[3] * y + m[6];
+    let yp = m[1] * x + m[4] * y + m[7];
+    let wp = m[2] * x + m[5] * y + m[8];
+    let [x, y] = if wp > EPSILON { [xp / wp, yp / wp] } else { [xp, yp] };
+
+    clamp_to_view_space(x, y, view_parameters)
+}
+
+fn clamp_to_view_space(x: f32, y: f32, p: &fptr::ViewParameters) -> [f32; 2] {
+    let min_x = p.view.min[0];
+    let min_y = p.view.min[1];
+    let max_x = p.view.max[0];
+    let max_y = p.view.max[1];
+    if min_x <= x && x < max_x && min_y <= y && y < max_y {
+        return [x, y]; // No clamping to perform.
+    }
+
+    // View boundary is [min_x, max_x) x [min_y, max_y). Note that min is
+    // inclusive, but max is exclusive - so we subtract epsilon.
+    let max_x_inclusive = max_x - EPSILON;
+    let max_y_inclusive = max_y - EPSILON;
+    let clamped_x = num::clamp(x, min_x, max_x_inclusive);
+    let clamped_y = num::clamp(y, min_y, max_y_inclusive);
+    return [clamped_x, clamped_y];
+}
diff --git a/session_shells/gazelle/pointer_fusion/src/pointer/touch.rs b/session_shells/gazelle/pointer_fusion/src/pointer/touch.rs
new file mode 100644
index 0000000..4b35963
--- /dev/null
+++ b/session_shells/gazelle/pointer_fusion/src/pointer/touch.rs
@@ -0,0 +1,12 @@
+// Copyright 2022 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use {super::*, fidl_fuchsia_ui_pointer as fptr};
+
+impl PointerFusionState {
+    pub(super) fn fuse_touch(&mut self, _event: fptr::TouchEvent) -> Vec<PointerEvent> {
+        // TODO(fxb/110099): Fuse touch events.
+        todo!("Fuse touch events");
+    }
+}
diff --git a/session_shells/gazelle/pointer_fusion/src/tests.rs b/session_shells/gazelle/pointer_fusion/src/tests.rs
new file mode 100644
index 0000000..5a3fa42
--- /dev/null
+++ b/session_shells/gazelle/pointer_fusion/src/tests.rs
@@ -0,0 +1,250 @@
+// Copyright 2022 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use {
+    super::*,
+    fidl_fuchsia_ui_pointer as fptr,
+    // fidl_fuchsia_input_report as input_report,
+    futures::FutureExt,
+};
+
+#[fuchsia::test]
+async fn test_mouse_event_without_view_parameters() {
+    // Fusing mouse event without [fptr::ViewParameters] should not result in a PointerEvent.
+    let mouse_event = InputEvent::mouse();
+    let (sender, mut receiver) = pointer_fusion(1.0);
+    sender.unbounded_send(mouse_event).unwrap();
+    let pointer_event = receiver.next().now_or_never();
+    assert!(pointer_event.is_none(), "Received {:?}", pointer_event);
+}
+
+#[fuchsia::test]
+async fn test_mouse_event_without_mouse_sample() {
+    // Fusing mouse event without [fptr::MousePointerSample] should not result in a PointerEvent.
+    let mouse_event = InputEvent::mouse().view(1024.0, 600.0);
+    let (sender, mut receiver) = pointer_fusion(1.0);
+    sender.unbounded_send(mouse_event).unwrap();
+    let pointer_event = receiver.next().now_or_never();
+    assert!(pointer_event.is_none(), "Received {:?}", pointer_event);
+}
+
+#[fuchsia::test]
+async fn test_mouse_event_without_device_info() {
+    // Fusing mouse event without [fptr::MouseDeviceInfo] should not result in a PointerEvent.
+    let mouse_event = InputEvent::mouse().view(1024.0, 600.0).position(512.0, 300.0);
+    let (sender, mut receiver) = pointer_fusion(1.0);
+    sender.unbounded_send(mouse_event).unwrap();
+    let pointer_event = receiver.next().now_or_never();
+    assert!(pointer_event.is_none(), "Received {:?}", pointer_event);
+}
+
+#[fuchsia::test]
+async fn test_pixel_ratio() {
+    let mouse_event = InputEvent::mouse()
+        .view(1024.0, 600.0)
+        .device_info(42)
+        .position(512.0, 300.0)
+        .button_down();
+
+    let (sender, mut receiver) = pointer_fusion(2.0);
+    sender.unbounded_send(mouse_event).unwrap();
+
+    let pointer_event = receiver.next().await.unwrap();
+    assert!(matches!(pointer_event.phase, Phase::Add));
+    assert!(pointer_event.physical_x - 1024.0 < std::f32::EPSILON);
+    assert!(pointer_event.physical_y - 600.0 < std::f32::EPSILON);
+}
+
+#[fuchsia::test]
+async fn test_mouse_starts_with_add_event() {
+    let mouse_event =
+        InputEvent::mouse().view(1024.0, 600.0).device_info(42).position(512.0, 300.0);
+
+    let (sender, mut receiver) = pointer_fusion(1.0);
+    sender.unbounded_send(mouse_event).unwrap();
+    let pointer_event = receiver.next().await.unwrap();
+    assert!(matches!(pointer_event.phase, Phase::Add));
+}
+
+#[fuchsia::test]
+async fn test_mouse_tap() {
+    let mouse_event = InputEvent::mouse()
+        .view(1024.0, 600.0)
+        .device_info(42)
+        .position(512.0, 300.0)
+        .button_down();
+
+    let (sender, mut receiver) = pointer_fusion(1.0);
+    sender.unbounded_send(mouse_event).unwrap();
+
+    let pointer_event = receiver.next().await.unwrap();
+    assert!(matches!(pointer_event.phase, Phase::Add));
+
+    let pointer_event = receiver.next().await.unwrap();
+    assert!(matches!(pointer_event.phase, Phase::Down));
+
+    let mouse_event = InputEvent::mouse().device_info(42).position(512.0, 300.0);
+    sender.unbounded_send(mouse_event).unwrap();
+
+    let pointer_event = receiver.next().await.unwrap();
+    assert!(matches!(pointer_event.phase, Phase::Up));
+}
+
+#[fuchsia::test]
+async fn test_mouse_hover() {
+    let mouse_event =
+        InputEvent::mouse().view(1024.0, 600.0).device_info(42).position(512.0, 300.0);
+
+    let (sender, mut receiver) = pointer_fusion(1.0);
+    sender.unbounded_send(mouse_event).unwrap();
+
+    let pointer_event = receiver.next().await.unwrap();
+    assert!(matches!(pointer_event.phase, Phase::Add));
+
+    // Changing mouse x position should result in Hover event.
+    let mouse_event = InputEvent::mouse().device_info(42).position(540.0, 300.0);
+    sender.unbounded_send(mouse_event).unwrap();
+
+    let pointer_event = receiver.next().await.unwrap();
+    assert!(matches!(pointer_event.phase, Phase::Hover));
+    assert!(pointer_event.physical_x - 540.0 < std::f32::EPSILON);
+
+    // Changing mouse y position should result in Hover event.
+    let mouse_event = InputEvent::mouse().device_info(42).position(540.0, 320.0);
+    sender.unbounded_send(mouse_event).unwrap();
+
+    let pointer_event = receiver.next().await.unwrap();
+    assert!(matches!(pointer_event.phase, Phase::Hover));
+    assert!(pointer_event.physical_y - 320.0 < std::f32::EPSILON);
+}
+
+#[fuchsia::test]
+async fn test_mouse_move() {
+    let mouse_event = InputEvent::mouse()
+        .view(1024.0, 600.0)
+        .device_info(42)
+        .position(512.0, 300.0)
+        .button_down();
+
+    let (sender, mut receiver) = pointer_fusion(1.0);
+    sender.unbounded_send(mouse_event).unwrap();
+
+    let pointer_event = receiver.next().await.unwrap();
+    assert!(matches!(pointer_event.phase, Phase::Add));
+
+    let pointer_event = receiver.next().await.unwrap();
+    assert!(matches!(pointer_event.phase, Phase::Down));
+
+    // Changing the position should result in Move event.
+    let mouse_event = InputEvent::mouse().device_info(42).position(540.0, 320.0).button_down();
+    sender.unbounded_send(mouse_event).unwrap();
+
+    let pointer_event = receiver.next().await.unwrap();
+    assert!(matches!(pointer_event.phase, Phase::Move));
+    assert!(pointer_event.physical_delta_x == 28.0);
+    assert!(pointer_event.physical_delta_y == 20.0);
+
+    // Keeping the same position should not result in Move event.
+    let mouse_event = InputEvent::mouse().device_info(42).position(540.0, 320.0).button_down();
+    sender.unbounded_send(mouse_event).unwrap();
+
+    let pointer_event = receiver.next().now_or_never();
+    assert!(pointer_event.is_none(), "Received {:?}", pointer_event);
+}
+
+#[fuchsia::test]
+async fn test_mouse_no_spurious_hovers() {
+    let mouse_event =
+        InputEvent::mouse().view(1024.0, 600.0).device_info(42).position(512.0, 300.0);
+
+    let (sender, mut receiver) = pointer_fusion(1.0);
+    sender.unbounded_send(mouse_event).unwrap();
+
+    let pointer_event = receiver.next().await.unwrap();
+    assert!(matches!(pointer_event.phase, Phase::Add));
+
+    // Same position should not result in any hover event.
+    let mouse_event = InputEvent::mouse().device_info(42).position(512.0, 300.0);
+    sender.unbounded_send(mouse_event).unwrap();
+
+    let pointer_event = receiver.next().now_or_never();
+    assert!(pointer_event.is_none(), "Received {:?}", pointer_event);
+}
+
+trait TestMouseEvent {
+    fn mouse() -> Self;
+    fn view(self, width: f32, height: f32) -> Self;
+    fn device_info(self, id: u32) -> Self;
+    fn position(self, x: f32, y: f32) -> Self;
+    fn button_down(self) -> Self;
+}
+
+impl TestMouseEvent for InputEvent {
+    fn mouse() -> Self {
+        InputEvent::MouseEvent(fptr::MouseEvent { ..fptr::MouseEvent::EMPTY })
+    }
+
+    fn view(mut self, width: f32, height: f32) -> Self {
+        match self {
+            InputEvent::MouseEvent(ref mut event) => {
+                event.view_parameters = Some(fptr::ViewParameters {
+                    view: fptr::Rectangle { min: [0.0, 0.0], max: [width, height] },
+                    viewport: fptr::Rectangle { min: [0.0, 0.0], max: [width, height] },
+                    viewport_to_view_transform: [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
+                });
+            }
+            _ => {}
+        }
+        self
+    }
+
+    fn device_info(mut self, id: u32) -> Self {
+        match self {
+            InputEvent::MouseEvent(ref mut event) => {
+                event.device_info = Some(fptr::MouseDeviceInfo {
+                    id: Some(id),
+                    buttons: Some([0, 1, 2].to_vec()),
+                    relative_motion_range: None,
+                    ..fptr::MouseDeviceInfo::EMPTY
+                });
+            }
+            _ => {}
+        }
+        self
+    }
+
+    fn position(mut self, x: f32, y: f32) -> Self {
+        match self {
+            InputEvent::MouseEvent(ref mut event) => {
+                let device_id = event
+                    .device_info
+                    .as_ref()
+                    .unwrap_or(&fptr::MouseDeviceInfo {
+                        id: Some(0),
+                        ..fptr::MouseDeviceInfo::EMPTY
+                    })
+                    .id;
+                event.pointer_sample = Some(fptr::MousePointerSample {
+                    device_id,
+                    position_in_viewport: Some([x, y]),
+                    ..fptr::MousePointerSample::EMPTY
+                });
+            }
+            _ => {}
+        }
+        self
+    }
+
+    fn button_down(mut self) -> Self {
+        match self {
+            InputEvent::MouseEvent(ref mut event) => {
+                if let Some(ref mut pointer_sample) = event.pointer_sample {
+                    pointer_sample.pressed_buttons = Some(vec![0]);
+                }
+            }
+            _ => {}
+        }
+        self
+    }
+}
diff --git a/session_shells/gazelle/session/BUILD.gn b/session_shells/gazelle/session/BUILD.gn
deleted file mode 100644
index 855ff07..0000000
--- a/session_shells/gazelle/session/BUILD.gn
+++ /dev/null
@@ -1,35 +0,0 @@
-# Copyright 2022 The Fuchsia Authors. All rights reserved.
-# Use of this source code is governed by a BSD-style license that can be
-# found in the LICENSE file.
-
-import("//build/components.gni")
-import("//build/dart/dart_component.gni")
-
-fuchsia_component("gazelle_session") {
-  assert(
-      dart_default_build_cfg.is_aot == flutter_default_build_cfg.is_aot &&
-          dart_default_build_cfg.is_product ==
-              flutter_default_build_cfg.is_product,
-      "gazelle_session requires dart and flutter runtimes use the same build configuration. Make sure 'fx set ... --args=(dart|flutter)_force_product=<bool>' flags are omitted, or match, for both dart and flutter.")
-  if (!dart_default_build_cfg.is_aot && !dart_default_build_cfg.is_product) {
-    manifest = "meta/gazelle_session_jit.cml"
-  } else if (!dart_default_build_cfg.is_aot &&
-             dart_default_build_cfg.is_product) {
-    manifest = "meta/gazelle_session_jit_product.cml"
-  } else if (dart_default_build_cfg.is_aot &&
-             !dart_default_build_cfg.is_product) {
-    manifest = "meta/gazelle_session_aot.cml"
-  } else if (dart_default_build_cfg.is_aot &&
-             dart_default_build_cfg.is_product) {
-    manifest = "meta/gazelle_session_aot_product.cml"
-  }
-}
-
-fuchsia_package("gazelle_session_pkg") {
-  package_name = "gazelle_session"
-  deps = [ ":gazelle_session" ]
-}
-
-group("session") {
-  public_deps = [ ":gazelle_session_pkg" ]
-}
diff --git a/session_shells/gazelle/session/meta/gazelle_session_aot.cml b/session_shells/gazelle/session/meta/gazelle_session_aot.cml
deleted file mode 100644
index 491f826..0000000
--- a/session_shells/gazelle/session/meta/gazelle_session_aot.cml
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright 2022 The Fuchsia Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-{
-    include: [ "//src/experiences/session_shells/gazelle/session/meta/gazelle_session_common.shard.cml" ],
-    children: [
-        {
-            name: "dart_aot_runner",
-            url: "fuchsia-pkg://fuchsia.com/dart_aot_runner#meta/dart_aot_runner.cm",
-            startup: "lazy",
-        },
-        {
-            name: "flutter_aot_runner",
-            url: "fuchsia-pkg://fuchsia.com/flutter_aot_runner#meta/flutter_aot_runner.cm",
-            startup: "lazy",
-        },
-    ],
-    offer: [
-        {
-            protocol: [
-                "fuchsia.feedback.CrashReporter",
-                "fuchsia.intl.PropertyProvider",
-                "fuchsia.logger.LogSink",
-                "fuchsia.posix.socket.Provider",
-                "fuchsia.tracing.provider.Registry",
-            ],
-            from: "parent",
-            to: [
-                "#dart_aot_runner",
-                "#flutter_aot_runner",
-            ],
-        },
-        {
-            protocol: [
-                "fuchsia.accessibility.semantics.SemanticsManager",
-                "fuchsia.fonts.Provider",
-                "fuchsia.memorypressure.Provider",
-                "fuchsia.settings.Intl",
-                "fuchsia.sysmem.Allocator",
-                "fuchsia.ui.composition.Allocator",
-                "fuchsia.ui.composition.Flatland",
-                "fuchsia.ui.input.ImeService",
-                "fuchsia.ui.input3.Keyboard",
-                "fuchsia.ui.scenic.Scenic",
-                "fuchsia.vulkan.loader.Loader",
-            ],
-            from: "parent",
-            to: [ "#flutter_aot_runner" ],
-        },
-        {
-            directory: "config-data",
-            from: "parent",
-            to: [
-                "#dart_aot_runner",
-                "#flutter_aot_runner",
-            ],
-        },
-    ],
-    environments: [
-        {
-            name: "application_env",
-            extends: "realm",
-            runners: [
-                {
-                    runner: "dart_aot_runner",
-                    from: "#dart_aot_runner",
-                },
-                {
-                    runner: "flutter_aot_runner",
-                    from: "#flutter_aot_runner",
-                },
-            ],
-        },
-    ],
-}
diff --git a/session_shells/gazelle/session/meta/gazelle_session_aot_product.cml b/session_shells/gazelle/session/meta/gazelle_session_aot_product.cml
deleted file mode 100644
index 679efe8..0000000
--- a/session_shells/gazelle/session/meta/gazelle_session_aot_product.cml
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright 2022 The Fuchsia Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-{
-    include: [ "//src/experiences/session_shells/gazelle/session/meta/gazelle_session_common.shard.cml" ],
-    children: [
-        {
-            name: "dart_aot_product_runner",
-            url: "fuchsia-pkg://fuchsia.com/dart_aot_product_runner#meta/dart_aot_product_runner.cm",
-            startup: "lazy",
-        },
-        {
-            name: "flutter_aot_product_runner",
-            url: "fuchsia-pkg://fuchsia.com/flutter_aot_product_runner#meta/flutter_aot_product_runner.cm",
-            startup: "lazy",
-        },
-    ],
-    offer: [
-        {
-            protocol: [
-                "fuchsia.feedback.CrashReporter",
-                "fuchsia.intl.PropertyProvider",
-                "fuchsia.logger.LogSink",
-                "fuchsia.posix.socket.Provider",
-                "fuchsia.tracing.provider.Registry",
-            ],
-            from: "parent",
-            to: [
-                "#dart_aot_product_runner",
-                "#flutter_aot_product_runner",
-            ],
-        },
-        {
-            protocol: [
-                "fuchsia.accessibility.semantics.SemanticsManager",
-                "fuchsia.fonts.Provider",
-                "fuchsia.memorypressure.Provider",
-                "fuchsia.settings.Intl",
-                "fuchsia.sysmem.Allocator",
-                "fuchsia.ui.composition.Allocator",
-                "fuchsia.ui.composition.Flatland",
-                "fuchsia.ui.input.ImeService",
-                "fuchsia.ui.input3.Keyboard",
-                "fuchsia.ui.scenic.Scenic",
-                "fuchsia.vulkan.loader.Loader",
-            ],
-            from: "parent",
-            to: [ "#flutter_aot_product_runner" ],
-        },
-        {
-            directory: "config-data",
-            from: "parent",
-            to: [
-                "#dart_aot_product_runner",
-                "#flutter_aot_product_runner",
-            ],
-        },
-    ],
-    environments: [
-        {
-            name: "application_env",
-            extends: "realm",
-            runners: [
-                {
-                    runner: "dart_aot_product_runner",
-                    from: "#dart_aot_product_runner",
-                },
-                {
-                    runner: "flutter_aot_product_runner",
-                    from: "#flutter_aot_product_runner",
-                },
-            ],
-        },
-    ],
-}
diff --git a/session_shells/gazelle/session/meta/gazelle_session_jit.cml b/session_shells/gazelle/session/meta/gazelle_session_jit.cml
deleted file mode 100644
index ccdf9d7..0000000
--- a/session_shells/gazelle/session/meta/gazelle_session_jit.cml
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright 2022 The Fuchsia Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-{
-    include: [ "//src/experiences/session_shells/gazelle/session/meta/gazelle_session_common.shard.cml" ],
-    children: [
-        {
-            name: "dart_jit_runner",
-            url: "fuchsia-pkg://fuchsia.com/dart_jit_runner#meta/dart_jit_runner.cm",
-            startup: "lazy",
-        },
-        {
-            name: "flutter_jit_runner",
-            url: "fuchsia-pkg://fuchsia.com/flutter_jit_runner#meta/flutter_jit_runner.cm",
-            startup: "lazy",
-        },
-    ],
-    offer: [
-        {
-            protocol: [
-                "fuchsia.feedback.CrashReporter",
-                "fuchsia.intl.PropertyProvider",
-                "fuchsia.logger.LogSink",
-                "fuchsia.posix.socket.Provider",
-                "fuchsia.tracing.provider.Registry",
-            ],
-            from: "parent",
-            to: [
-                "#dart_jit_runner",
-                "#flutter_jit_runner",
-            ],
-        },
-        {
-            protocol: [
-                "fuchsia.accessibility.semantics.SemanticsManager",
-                "fuchsia.fonts.Provider",
-                "fuchsia.memorypressure.Provider",
-                "fuchsia.settings.Intl",
-                "fuchsia.sysmem.Allocator",
-                "fuchsia.ui.composition.Allocator",
-                "fuchsia.ui.composition.Flatland",
-                "fuchsia.ui.input.ImeService",
-                "fuchsia.ui.input3.Keyboard",
-                "fuchsia.ui.scenic.Scenic",
-                "fuchsia.vulkan.loader.Loader",
-            ],
-            from: "parent",
-            to: [ "#flutter_jit_runner" ],
-        },
-        {
-            directory: "config-data",
-            from: "parent",
-            to: [
-                "#dart_jit_runner",
-                "#flutter_jit_runner",
-            ],
-        },
-    ],
-    environments: [
-        {
-            name: "application_env",
-            extends: "realm",
-            runners: [
-                {
-                    runner: "dart_jit_runner",
-                    from: "#dart_jit_runner",
-                },
-                {
-                    runner: "flutter_jit_runner",
-                    from: "#flutter_jit_runner",
-                },
-            ],
-        },
-    ],
-}
diff --git a/session_shells/gazelle/session/meta/gazelle_session_jit_product.cml b/session_shells/gazelle/session/meta/gazelle_session_jit_product.cml
deleted file mode 100644
index e52e289..0000000
--- a/session_shells/gazelle/session/meta/gazelle_session_jit_product.cml
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright 2022 The Fuchsia Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-{
-    include: [ "//src/experiences/session_shells/gazelle/session/meta/gazelle_session_common.shard.cml" ],
-    children: [
-        {
-            name: "dart_jit_product_runner",
-            url: "fuchsia-pkg://fuchsia.com/dart_jit_product_runner#meta/dart_jit_product_runner.cm",
-            startup: "lazy",
-        },
-        {
-            name: "flutter_jit_product_runner",
-            url: "fuchsia-pkg://fuchsia.com/flutter_jit_product_runner#meta/flutter_jit_product_runner.cm",
-            startup: "lazy",
-        },
-    ],
-    offer: [
-        {
-            protocol: [
-                "fuchsia.feedback.CrashReporter",
-                "fuchsia.intl.PropertyProvider",
-                "fuchsia.logger.LogSink",
-                "fuchsia.posix.socket.Provider",
-                "fuchsia.tracing.provider.Registry",
-            ],
-            from: "parent",
-            to: [
-                "#dart_jit_product_runner",
-                "#flutter_jit_product_runner",
-            ],
-        },
-        {
-            protocol: [
-                "fuchsia.accessibility.semantics.SemanticsManager",
-                "fuchsia.fonts.Provider",
-                "fuchsia.memorypressure.Provider",
-                "fuchsia.settings.Intl",
-                "fuchsia.sysmem.Allocator",
-                "fuchsia.ui.composition.Allocator",
-                "fuchsia.ui.composition.Flatland",
-                "fuchsia.ui.input.ImeService",
-                "fuchsia.ui.input3.Keyboard",
-                "fuchsia.ui.scenic.Scenic",
-                "fuchsia.vulkan.loader.Loader",
-            ],
-            from: "parent",
-            to: [ "#flutter_jit_product_runner" ],
-        },
-        {
-            directory: "config-data",
-            from: "parent",
-            to: [
-                "#dart_jit_product_runner",
-                "#flutter_jit_product_runner",
-            ],
-        },
-    ],
-    environments: [
-        {
-            name: "application_env",
-            extends: "realm",
-            runners: [
-                {
-                    runner: "dart_jit_product_runner",
-                    from: "#dart_jit_product_runner",
-                },
-                {
-                    runner: "flutter_jit_product_runner",
-                    from: "#flutter_jit_product_runner",
-                },
-            ],
-        },
-    ],
-}
diff --git a/session_shells/gazelle/shell/BUILD.gn b/session_shells/gazelle/shell/BUILD.gn
new file mode 100644
index 0000000..a6e7d03
--- /dev/null
+++ b/session_shells/gazelle/shell/BUILD.gn
@@ -0,0 +1,19 @@
+# Copyright 2022 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("//build/components.gni")
+import("//build/dart/dart_component.gni")
+
+fuchsia_component("gazelle_shell") {
+  manifest = "meta/gazelle_shell.cml"
+}
+
+fuchsia_package("gazelle_shell_pkg") {
+  package_name = "gazelle_shell"
+  deps = [ ":gazelle_shell" ]
+}
+
+group("shell") {
+  public_deps = [ ":gazelle_shell_pkg" ]
+}
diff --git a/session_shells/gazelle/session/meta/gazelle_session_common.shard.cml b/session_shells/gazelle/shell/meta/gazelle_shell.cml
similarity index 96%
rename from session_shells/gazelle/session/meta/gazelle_session_common.shard.cml
rename to session_shells/gazelle/shell/meta/gazelle_shell.cml
index a4f5290..d4b1343 100644
--- a/session_shells/gazelle/session/meta/gazelle_session_common.shard.cml
+++ b/session_shells/gazelle/shell/meta/gazelle_shell.cml
@@ -20,7 +20,6 @@
     collections: [
         {
             name: "elements",
-            environment: "#application_env",
             durability: "transient",
         },
     ],
@@ -28,7 +27,6 @@
         {
             protocol: [
                 "fuchsia.logger.LogSink",
-                "fuchsia.session.scene.Manager",
                 "fuchsia.ui.composition.Flatland",
             ],
             from: "parent",
@@ -149,7 +147,10 @@
             from: "framework",
         },
         {
-            protocol: [ "fuchsia.element.GraphicalPresenter" ],
+            protocol: [
+                "fuchsia.element.GraphicalPresenter",
+                "fuchsia.ui.app.ViewProvider",
+            ],
             from: "#wm",
         },
         {
diff --git a/session_shells/gazelle/wm/BUILD.gn b/session_shells/gazelle/wm/BUILD.gn
index 8be505c..c4bbcd0 100644
--- a/session_shells/gazelle/wm/BUILD.gn
+++ b/session_shells/gazelle/wm/BUILD.gn
@@ -4,6 +4,7 @@
 
 import("//build/components.gni")
 import("//build/rust/rustc_binary.gni")
+import("//src/lib/vulkan/vulkan.gni")
 
 rustc_binary("bin") {
   output_name = "wm"
@@ -20,7 +21,7 @@
   deps = [
     "//sdk/fidl/fuchsia.element:fuchsia.element_rust",
     "//sdk/fidl/fuchsia.math:fuchsia.math_rust",
-    "//sdk/fidl/fuchsia.session.scene:fuchsia.session.scene_rust",
+    "//sdk/fidl/fuchsia.ui.app:fuchsia.ui.app_rust",
     "//sdk/fidl/fuchsia.ui.composition:fuchsia.ui.composition_rust",
     "//sdk/fidl/fuchsia.ui.views:fuchsia.ui.views_rust",
     "//src/lib/fidl/rust/fidl",
@@ -33,11 +34,9 @@
   ]
 
   test_deps = [
-    "//sdk/fidl/fuchsia.ui.app:fuchsia.ui.app_rust",
     "//sdk/fidl/fuchsia.ui.test.scene:fuchsia.ui.test.scene_rust",
     "//src/lib/fuchsia-async",
     "//src/lib/fuchsia-component-test",
-    "//third_party/rust_crates:assert_matches",
   ]
 }
 
@@ -52,6 +51,9 @@
     log_settings = {
       max_severity = "ERROR"
     }
+
+    # Ensure the device has Vulkan.
+    environments = vulkan_envs
   }
 }
 
diff --git a/session_shells/gazelle/wm/meta/wm.cml b/session_shells/gazelle/wm/meta/wm.cml
index c6d60ff..368df70 100644
--- a/session_shells/gazelle/wm/meta/wm.cml
+++ b/session_shells/gazelle/wm/meta/wm.cml
@@ -12,20 +12,23 @@
     },
     capabilities: [
         {
-            protocol: [ "fuchsia.element.GraphicalPresenter" ],
+            protocol: [
+                "fuchsia.element.GraphicalPresenter",
+                "fuchsia.ui.app.ViewProvider",
+            ],
         },
     ],
     use: [
         {
-            protocol: [
-                "fuchsia.session.scene.Manager",
-                "fuchsia.ui.composition.Flatland",
-            ],
+            protocol: [ "fuchsia.ui.composition.Flatland" ],
         },
     ],
     expose: [
         {
-            protocol: [ "fuchsia.element.GraphicalPresenter" ],
+            protocol: [
+                "fuchsia.element.GraphicalPresenter",
+                "fuchsia.ui.app.ViewProvider",
+            ],
             from: "self",
         },
     ],
diff --git a/session_shells/gazelle/wm/src/main.rs b/session_shells/gazelle/wm/src/main.rs
index ede6e76..68462b9 100644
--- a/session_shells/gazelle/wm/src/main.rs
+++ b/session_shells/gazelle/wm/src/main.rs
@@ -2,54 +2,127 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-use {
-    anyhow::{anyhow, Context},
-    fidl_fuchsia_element as felement, fidl_fuchsia_session_scene as fscene,
-    fidl_fuchsia_ui_composition as ui_comp,
-    fuchsia_component::client::connect_to_protocol,
-    fuchsia_component::server::ServiceFs,
-    fuchsia_scenic::flatland,
-    futures::{stream, StreamExt},
-};
+use anyhow::Context;
+use fidl_fuchsia_element as felement;
+use fidl_fuchsia_ui_app as ui_app;
+use fidl_fuchsia_ui_composition as ui_comp;
+use fidl_fuchsia_ui_views as ui_views;
+use fuchsia_component::{client, server};
+use fuchsia_scenic::flatland;
+use futures::{stream, StreamExt};
 
 mod wm;
 
+// A fun picture to print to the log when gazelle launches. This is derived from
+// a drawing in the public domain, found at
+// https://www.rawpixel.com/image/6502476/png-sticker-vintage.
+const WELCOME_LOG: &'static str = "
+             â–’â–’â–‘          â–‘â–‘â–’
+             â–’â–’â–’          â–‘â–’â–‘
+             â–‘â–’â–’â–‘        â–’â–’â–’â–‘          GAZELLE
+              â–’â–’â–’â–‘      â–’â–’â–’
+              â–‘â–“â–’â–‘     â–’â–’â–“â–‘     â–‘â–‘â–‘â–‘
+   â–‘â–‘â–‘â–‘        â–“â–’â–’    â–‘â–’â–’â–’     â–‘â–‘â–‘â–‘â–’â–‘
+   â–‘â–’â–‘â–‘â–‘â–‘      â–’â–’â–’â–‘  â–‘â–’â–’â–“   â–‘â–‘â–’â–‘â–‘â–‘â–‘â–‘â–‘
+    â–‘â–‘ â–‘â–’â–’â–’â–‘   â–“â–’â–’â–‘  â–’â–’â–’â–’  â–‘â–’â–’â–’â–’â–‘â–‘â–‘
+      â–‘â–‘â–’â–’â–“â–’â–’â–‘â–‘â–“â–“â–“â–’â–‘â–’â–“â–“â–’â–’â–’â–’â–’â–‘â–’â–’â–’â–‘â–‘â–‘
+       â–‘â–‘â–‘â–’â–’â–‘â–’â–’â–“â–“â–“â–“â–“â–“â–“â–“â–‘â–‘â–“â–‘â–‘â–’â–’â–‘â–‘
+          â–‘â–’â–’â–’â–‘â–‘â–“â–“â–’â–“â–“â–’â–‘  â–’â–‘â–’â–‘â–‘
+            â–‘â–“â–“â–‘â–’â–“â–“â–“â–‘ â–‘â–’â–“â–“â–’â–‘
+             â–’â–’â–’â–‘â–“â–“â–’  â–‘â–’â–’â–“â–’
+              â–’â–’â–“â–“â–’â–’ â–’â–’â–‘â–’â–“â–’â–‘
+              â–‘â–’â–“â–’â–’â–’â–’â–’â–‘â–’â–“â–“â–‘â–’â–‘
+               â–’â–’â–’â–‘â–‘â–’â–’â–“â–“â–“â–“â–’â–’â–‘
+              â–‘â–“â–“â–“â–’â–‘â–‘â–“â–“â–“â–“â–“â–’â–’â–’                    â–‘â–‘â–‘    â–‘â–‘â–‘â–‘â–‘
+               â–’â–“â–“â–’â–’â–“â–“â–’â–’â–’â–“â–’â–‘â–’â–‘  â–‘â–‘â–‘     â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–’â–’â–’â–’â–’â–’â–’â–’â–’â–’â–’â–’â–’â–’â–’â–’â–’â–‘â–‘â–‘
+                â–’â–“â–“â–“â–“â–“â–’â–‘â–’â–“â–’â–’â–‘â–“â–’â–’â–’â–’â–’â–’â–’â–’â–’â–’â–’â–’â–“â–’â–’â–’â–’â–’â–‘â–‘â–‘â–‘â–’â–’â–‘â–‘â–‘â–‘â–‘â–’â–’â–’â–’â–“â–“â–’â–’â–’â–‘â–‘â–‘â–‘â–‘
+                â–‘â–“â–“â–“â–“â–“â–’â–‘â–’â–“â–“â–‘â–’â–’â–“â–’â–’â–’â–‘â–‘â–‘â–’â–’â–’â–’â–’â–’â–‘â–‘â–‘â–‘â–‘â–‘ â–‘â–‘ â–‘â–‘â–‘â–‘â–’â–’â–’â–’â–’â–’â–’â–’â–’â–’â–’â–‘â–’â–’â–“â–’â–‘
+                â–‘â–“â–“â–“â–“â–“â–’â–’â–’â–“â–“â–’â–’â–‘â–’â–’â–’â–’â–’â–‘ â–‘â–‘â–‘â–’â–’â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘    â–‘â–‘â–’â–’â–’â–’â–’â–‘â–‘â–‘â–‘â–’â–‘â–‘â–‘â–’â–’â–’â–’â–‘
+                 â–’â–“â–“â–“â–“â–“â–’â–‘â–’â–’â–’â–’â–’â–’â–’â–’â–‘â–‘ â–‘â–‘  â–‘â–’â–’â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘   â–‘â–‘â–‘â–‘â–’â–’â–’â–’â–‘â–‘â–‘â–’â–‘â–‘â–’â–“â–’â–“â–‘
+                  â–’â–“â–“â–“â–“â–’â–‘â–‘â–‘â–’â–’â–’â–“â–’â–‘   â–‘â–‘â–‘â–‘â–‘â–’â–’â–‘â–’â–’â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘    â–‘â–’â–’â–’â–’â–‘  â–‘â–’â–‘â–‘â–“â–’â–“â–‘
+                  â–‘â–’â–“â–“â–“â–’â–’â–‘â–‘â–‘â–‘â–‘â–’â–’â–‘â–‘  â–‘â–‘â–‘â–‘â–‘â–’â–‘â–‘â–‘â–’â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘  â–‘â–“â–“â–’â–‘  â–‘â–‘â–‘â–‘â–’â–“â–’â–“â–’
+                   â–‘â–’â–’â–“â–“â–“â–’â–’â–‘â–‘â–‘â–‘â–’â–‘â–‘ â–‘â–‘  â–‘â–‘â–’â–‘â–‘â–‘â–’â–’â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–’â–’â–’â–“â–“â–“â–’â–‘â–‘â–‘â–‘â–’â–‘â–‘â–’â–“â–’â–“â–“â–‘
+                     â–‘â–“â–“â–“â–“â–’â–’â–‘â–‘â–‘â–’â–’â–‘â–‘â–‘â–‘â–‘ â–‘â–‘â–’â–‘â–‘ â–‘â–’â–’â–’â–’â–’â–’â–’â–’â–’â–’â–“â–“â–“â–“â–“â–’â–“â–“â–‘â–‘â–‘â–‘â–’â–‘â–‘â–’â–“â–‘â–’â–“â–’
+                     â–‘â–’â–“â–“â–’â–’â–’â–’â–’â–‘â–’â–’â–‘ â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–“â–’â–‘â–‘â–‘â–’â–‘â–‘â–‘â–’â–’â–‘â–’â–’â–’   â–‘â–‘
+                      â–‘â–‘â–’â–’â–’â–’â–’â–’â–’â–’â–“â–’â–‘â–‘â–’â–‘â–‘â–‘â–‘â–‘ â–‘â–’â–’â–“â–“â–“â–“â–“â–“â–“â–“â–’â–’â–’â–‘ â–‘â–’â–“â–’â–’â–’â–‘â–’â–’â–‘â–‘â–’â–“â–’
+                        â–‘â–’â–’â–‘â–‘  â–‘â–‘â–’â–’â–’â–’â–’â–‘â–’â–’â–’â–’â–’â–’â–“â–“â–’â–‘â–‘â–‘â–‘    â–‘â–‘â–’â–“â–“â–“â–“â–’â–’â–“â–“â–’â–’â–’â–“â–“â–’
+                         â–‘â–‘â–’â–‘â–‘ â–‘â–‘â–’â–’â–‘â–’â–’â–‘â–’â–’â–’â–“â–“â–“â–“â–“â–’â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–’â–‘â–’â–“â–“â–“â–“â–“â–’â–’â–“â–“â–’â–’â–’â–“â–“â–’â–‘
+                           â–‘â–‘â–’â–’â–’â–‘â–‘â–’â–‘â–’â–“â–’â–’â–‘â–’â–’â–“â–“â–“â–“â–’â–‘â–‘â–‘â–‘â–‘â–‘â–‘â–‘   â–‘â–’â–“â–“â–“â–“â–“â–’â–“â–“â–’â–’â–“â–“â–“â–’â–‘
+                             â–‘â–‘â–“â–“â–’â–’â–’â–’â–’â–’â–’â–‘â–‘â–‘â–’â–“â–“â–’â–‘             â–‘â–’â–’â–’â–“â–“â–“â–“â–“â–“â–“â–’â–“â–“â–“â–’
+                               â–’â–“â–“â–“â–’â–’    â–‘â–‘â–’â–“â–“â–’                 â–‘â–’â–“â–“â–“â–’â–’â–“â–’â–“â–“â–“â–’
+                               â–’â–’â–“â–’â–’â–‘    â–‘â–’â–’â–’â–“â–’                  â–‘â–‘â–“â–’â–’â–‘â–’â–‘â–’â–’â–‘â–“";
+
 enum IncomingService {
     GraphicalPresenter(felement::GraphicalPresenterRequestStream),
+    ViewProvider(ui_app::ViewProviderRequestStream),
+}
+
+/// Reads incoming connection requests from the given Stream until it finds a
+/// request to connect to a `ViewProvider`. Meanwhile, it queues up other
+/// incoming connections in a Vec. It then returns both.
+///
+/// This panics if the stream closes without an incoming `ViewPresenter`
+/// connection.
+async fn first_view_provider_request_stream(
+    connections: &mut (impl stream::Stream<Item = IncomingService> + Unpin),
+) -> (ui_app::ViewProviderRequestStream, Vec<IncomingService>) {
+    let mut queue = Vec::new();
+
+    while let Some(connection) = connections.next().await {
+        match connection {
+            IncomingService::ViewProvider(stream) => return (stream, queue),
+            other => queue.push(other),
+        }
+    }
+    panic!("incoming connections stream closed without any ViewProvider connection")
+}
+
+/// Returns the `ViewCreationToken` and `ViewProviderControlHandle` from the
+/// first request on the given `ViewProviderRequestStream`. Panics if the first
+/// request isn't a call to `CreateView2` which passes a `ViewCreationToken`.
+async fn get_create_view2_request(
+    stream: &mut ui_app::ViewProviderRequestStream,
+) -> (ui_views::ViewCreationToken, ui_app::ViewProviderControlHandle) {
+    let first_request = stream
+        .next()
+        .await
+        .expect("ViewProviderRequestStream was empty")
+        .expect("reading from ViewProviderRequestStream");
+
+    match first_request {
+        ui_app::ViewProviderRequest::CreateView2 { args, control_handle } => (
+            args.view_creation_token.expect("first request did not contain a ViewCreationToken"),
+            control_handle,
+        ),
+        _ => panic!("Only CreateView2 is supported"),
+    }
 }
 
 #[fuchsia::main(logging = true)]
 async fn main() -> anyhow::Result<()> {
-    let flatland = connect_to_protocol::<flatland::FlatlandMarker>()?;
-    let scene_manager = connect_to_protocol::<fscene::ManagerMarker>()?;
+    tracing::info!("{}", WELCOME_LOG);
+    let flatland = client::connect_to_protocol::<flatland::FlatlandMarker>()?;
 
-    let mut fs = ServiceFs::new();
+    let mut fs = server::ServiceFs::new();
     fs.dir("svc").add_fidl_service(IncomingService::GraphicalPresenter);
+    fs.dir("svc").add_fidl_service(IncomingService::ViewProvider);
     fs.take_and_serve_directory_handle()?;
 
-    let mut view_creation_token_pair = flatland::ViewCreationTokenPair::new()?;
-
-    // This future can only be polled after the first call to `present`.
-    let present_root_result =
-        scene_manager.present_root_view(&mut view_creation_token_pair.viewport_creation_token);
+    // NOTE: `view_provider_request_stream` needs to stay alive or the caller
+    // gets unhappy.
+    let (mut view_provider_request_stream, queued_connections) =
+        first_view_provider_request_stream(&mut fs).await;
+    let (view_creation_token, _control_handle) =
+        get_create_view2_request(&mut view_provider_request_stream).await;
 
     let mut flatland_events = flatland.take_event_stream();
-    let mut incoming_connections = fs.fuse();
+    let mut incoming_connections = stream::iter(queued_connections.into_iter()).chain(fs).fuse();
 
     let mut graphical_presenter_requests = stream::SelectAll::new();
-    let mut events = stream::SelectAll::new();
 
-    let mut server =
-        wm::WindowManager::new(flatland.clone(), view_creation_token_pair.view_creation_token)
-            .await?;
-
+    let mut manager = wm::Manager::new(wm::View::new(flatland.clone(), view_creation_token).await?);
     flatland.present(flatland::PresentArgs::EMPTY)?;
 
-    present_root_result
-        .await
-        .context("presenting root view")?
-        .map_err(|err| anyhow!("presenting root view err: {:?}", err))?;
-
     let mut presentation_budget = 0;
     let mut presentation_requested = false;
 
@@ -81,16 +154,28 @@
                 match connection_request {
                     IncomingService::GraphicalPresenter(stream) =>
                         graphical_presenter_requests.push(stream),
+                        _ => {
+                            tracing::warn!("received a second attempt to connect to
+                                ViewProvider. Ignoring it.")
+                        }
                 },
             request = graphical_presenter_requests.select_next_some() => {
-                let present_view_request = request.context("getting PresentView request")?;
-                events.push(server.present_view(present_view_request)?);
+                let felement::GraphicalPresenterRequest::PresentView {
+                    view_spec,
+                    annotation_controller,
+                    view_controller_request,
+                    responder,
+                } = request.context("getting PresentView request")?;
+                manager.present_view(
+                    view_spec, annotation_controller, view_controller_request)?;
+
+                responder.send(&mut Ok(())).context("while replying to PresentView")?;
                 presentation_requested = true;
             },
-            event = events.select_next_some() => {
-                events.push(server.handle_event(event));
+            background_result = manager.select_background_task() => {
+                let () = background_result.expect("while doing background work");
                 presentation_requested = true;
-            }
+             }
         }
     }
 }
diff --git a/session_shells/gazelle/wm/src/wm.rs b/session_shells/gazelle/wm/src/wm.rs
index d3a3748..89e5fa3 100644
--- a/session_shells/gazelle/wm/src/wm.rs
+++ b/session_shells/gazelle/wm/src/wm.rs
@@ -2,15 +2,17 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
-use {
-    anyhow::{anyhow, Context},
-    fidl::endpoints,
-    fidl_fuchsia_element as felement, fidl_fuchsia_math as fmath,
-    fidl_fuchsia_ui_composition as ui_comp, fidl_fuchsia_ui_views as ui_views,
-    fuchsia_scenic::flatland,
-    fuchsia_scenic::ViewRefPair,
-    futures::{stream, FutureExt, StreamExt},
-    ui_views::{FocuserProxy, ViewCreationToken, ViewRef},
+use anyhow::{anyhow, bail, Context};
+use fidl::endpoints::{self, Proxy};
+use fidl_fuchsia_element as felement;
+use fidl_fuchsia_math as fmath;
+use fidl_fuchsia_ui_composition as ui_comp;
+use fidl_fuchsia_ui_views as ui_views;
+use fuchsia_scenic::flatland;
+use futures::{
+    future,
+    stream::{self, FusedStream},
+    FutureExt, StreamExt,
 };
 
 /// Width of the border around the screen.
@@ -20,22 +22,32 @@
 const BG_COLOR: ui_comp::ColorRgba =
     ui_comp::ColorRgba { red: 0.41, green: 0.24, blue: 0.21, alpha: 1.0 };
 
-/// Main server for the gazelle window manager.
-pub struct WindowManager {
+/// `View` implements the view in the Model-View-Controller sense - it allows
+/// manipulation of the graphics on-screen without worrying about all the
+/// low-level details.
+///
+/// Methods are all synchronous but may return Futures, indicating things that
+/// will happen eventually.
+pub struct View {
     flatland: flatland::FlatlandProxy,
     id_generator: flatland::IdGenerator,
-    root_focuser: FocuserProxy,
+    root_focuser: ui_views::FocuserProxy,
     viewport_size: fmath::SizeU,
     frame_transform_id: flatland::TransformId,
     window: Option<Window>,
 }
 
-impl WindowManager {
+struct Window {
+    window_id: WindowId,
+    child_view: Option<ui_views::ViewRef>,
+}
+
+impl View {
     /// Create a new WindowManager, build the UI, and attach to the given
     /// `view_creation_token`.
     pub async fn new(
         flatland: flatland::FlatlandProxy,
-        mut view_creation_token: ViewCreationToken,
+        mut view_creation_token: ui_views::ViewCreationToken,
     ) -> anyhow::Result<Self> {
         let (parent_viewport_watcher, parent_viewport_watcher_request) =
             endpoints::create_proxy::<flatland::ParentViewportWatcherMarker>()
@@ -46,7 +58,7 @@
         flatland
             .create_view2(
                 &mut view_creation_token,
-                &mut ui_views::ViewIdentityOnCreation::from(ViewRefPair::new()?),
+                &mut ui_views::ViewIdentityOnCreation::from(fuchsia_scenic::ViewRefPair::new()?),
                 flatland::ViewBoundProtocols {
                     view_focuser: Some(view_focuser_request),
                     ..flatland::ViewBoundProtocols::EMPTY
@@ -97,11 +109,18 @@
             .set_solid_fill(
                 &mut root_content_id.clone(),
                 &mut BG_COLOR.clone(),
-                &mut viewport_size.clone(),
+                // TODO(fxbug.dev/110653): Mysteriously, Scenic blows up when
+                // you make a rectangle the size of the viewport, under very
+                // specific circumstances. When that bug is fixed, change this
+                // to just `viewport_size.clone()`.
+                &mut fmath::SizeU {
+                    width: viewport_size.width - 1,
+                    height: viewport_size.height - 1,
+                },
             )
             .context("filling desktop")?;
 
-        Ok(WindowManager {
+        Ok(View {
             flatland,
             id_generator,
             root_focuser: view_focuser,
@@ -112,16 +131,18 @@
     }
 
     /// Create a window for an application.
-    //
-    // TODO(hjfreyer@google.com): Consider supporting applications that don't
-    // supply a `view_controller_server`.
+    ///
+    /// Returns an error if there's already an open window.
     pub fn create_window(
         &mut self,
         mut viewport_creation_token: ui_views::ViewportCreationToken,
-        _annotation_controller: Option<endpoints::ClientEnd<felement::AnnotationControllerMarker>>,
-        view_controller_server: endpoints::ServerEnd<felement::ViewControllerMarker>,
-    ) -> anyhow::Result<EventStream> {
+    ) -> anyhow::Result<CreateWindowResponse> {
+        if self.window.is_some() {
+            bail!("Only one window supported!")
+        }
+
         let content_id = self.id_generator.next_content_id();
+        let window_id = WindowId(content_id.value);
 
         let (child_view_watcher, child_view_watcher_server) =
             endpoints::create_proxy::<flatland::ChildViewWatcherMarker>()
@@ -145,75 +166,217 @@
             .set_content(&mut self.frame_transform_id.clone(), &mut content_id.clone())
             .context("attaching window viewport to frame")?;
 
-        self.window = Some(Window { _annotation_controller, content_id });
+        self.window = Some(Window { window_id, child_view: None });
 
-        // Stream that notifies us when the child actually attaches to the
+        // Future that resolves when the child actually attaches to the
         // viewport.
         //
-        // TODO(hjfreyer@google.com): Handle the case where `child_view_watcher`
-        // closes. That's the last opportunity for the shell to cleanup any
-        // state related to that child view.
-        //
         // TODO(hjfreyer@google.com): Use `child_view_watcher.get_status()` to
         // delay showing the window until the child is "ready" (i.e., it has
         // rendered its first frame).
-        let attached_event = child_view_watcher
+        let on_child_view_attached = child_view_watcher
             .get_view_ref()
-            .map(move |res| {
-                let child_view_ref = res.context("waiting for application's view_ref")?;
-                Ok(Event::ChildAttached { window_content_id: content_id, child_view_ref })
-            })
-            .map(Event::from)
-            .into_stream()
-            .boxed();
+            .map(|res| res.context("waiting for application's view_ref"))
+            .boxed_local();
 
-        // Stream that notifies us when the child requests that the window be
-        // dismissed OR when the view_controller closes.
-        let dismiss_event = view_controller_server
-            .into_stream()?
-            // Read the stream, forwarding any errors along, but end the stream
-            // as soon as we get a call to Dismiss.
-            .scan((), |_, request| async move {
-                match request.context("while reading ViewController request") {
-                    Ok(felement::ViewControllerRequest::Dismiss { .. }) => None,
-                    Err(err) => Some(Event::Err(err)),
-                }
-            })
-            // Tack a DismissRequested event onto the end of the stream.
-            .chain(stream::iter([Event::DismissRequested { window_content_id: content_id }]))
-            .boxed();
-
-        Ok(stream::select_all([attached_event, dismiss_event]).boxed())
-    }
-
-    /// Dismiss the active window, if any. If there isn't one, do nothing.
-    fn dismiss_window(&mut self) -> EventStream {
-        let maybe_window = std::mem::replace(&mut self.window, None);
-        if let Some(mut window) = maybe_window {
-            let release_viewport_result =
-                self.flatland.release_viewport(&mut window.content_id).map(|result| {
-                    Event::from(result.map(|_| Event::Ok).context("while releasing viewport"))
-                });
-
-            release_viewport_result.into_stream().boxed()
-        } else {
-            stream::empty().boxed()
+        // Future that resolves when the child view is closed.
+        let on_child_view_closed = async move {
+            match child_view_watcher.on_closed().await {
+                Ok(_) => Ok(()),
+                Err(status) => Err(anyhow!("error while awaiting channel close: {:?}", status)),
+            }
         }
+        .boxed_local();
+
+        Ok(CreateWindowResponse { window_id, on_child_view_attached, on_child_view_closed })
     }
 
-    /// Handle a call to GraphicalPresenter/PresentView by closing the active
-    /// window (if any), and creating a new window.
+    /// Associate a child `ViewRef` with the given `window_id`. This must be
+    /// done before you can call functions like `focus_window`.
+    pub fn register_window(
+        &mut self,
+        window_id: WindowId,
+        child_view: ui_views::ViewRef,
+    ) -> anyhow::Result<()> {
+        let window = match self.window.as_mut() {
+            Some(window) => window,
+            None => bail!(
+                "Tried to register window with ID {}, but there's no active window",
+                window_id.0
+            ),
+        };
+
+        if window.window_id != window_id {
+            bail!(
+                "Tried to register window with ID {}, but the active window has ID {}",
+                window_id.0,
+                window.window_id.0
+            );
+        }
+
+        if window.child_view.is_some() {
+            bail!(
+                "Tried to associate view with window {}, which already has view {:?}",
+                window_id.0,
+                &window.child_view
+            )
+        }
+
+        window.child_view = Some(child_view);
+        Ok(())
+    }
+
+    /// Delegate focus to the view corresponding to the window with the given
+    /// `window_id`. Requires `register_view` to have already been called.
+    pub fn focus_window(&mut self, window_id: WindowId) -> anyhow::Result<FocusWindowResponse> {
+        let window = match self.window.as_mut() {
+            Some(window) => window,
+            None => bail!(
+                "Tried to give focus to window with ID {}, but there's no active window",
+                window_id.0
+            ),
+        };
+
+        if window.window_id != window_id {
+            bail!(
+                "Tried to give focus to window with ID {}, but the active window has ID {}",
+                window_id.0,
+                window.window_id.0
+            );
+        }
+
+        let child_view = match window.child_view.as_ref() {
+            Some(child_view) => child_view,
+            None => bail!(
+                "Tried to give focus to window with ID {}, but it hasn't been registered",
+                window_id.0
+            ),
+        };
+
+        let set_auto_focus_result =
+            self.root_focuser.set_auto_focus(ui_views::FocuserSetAutoFocusRequest {
+                view_ref: Some(fuchsia_scenic::duplicate_view_ref(child_view)?),
+                ..ui_views::FocuserSetAutoFocusRequest::EMPTY
+            });
+
+        let on_completed = async move {
+            set_auto_focus_result
+                .await
+                .context("setting auto_focus")?
+                .map_err(|err| anyhow!("auto focus error: {:?}", err))
+        }
+        .boxed_local();
+
+        Ok(FocusWindowResponse { on_completed })
+    }
+
+    /// Dismiss the window with the given `window_id`.
+    pub fn dismiss_window(&mut self, window_id: WindowId) -> anyhow::Result<DismissWindowResponse> {
+        let window = match self.window.as_ref() {
+            Some(window) => window,
+            None => bail!(
+                "Tried to dismiss window with ID {}, but there's no active window",
+                window_id.0
+            ),
+        };
+
+        if window.window_id != window_id {
+            bail!(
+                "Tried to dismiss window with ID {}, but the active window has ID {}",
+                window_id.0,
+                window.window_id.0
+            );
+        }
+
+        self.flatland
+            .set_content(&mut self.frame_transform_id.clone(), &mut ui_comp::ContentId { value: 0 })
+            .context("detaching window viewport from frame")?;
+
+        let release_viewport_result =
+            self.flatland.release_viewport(&mut window_id.into_content_id());
+
+        let on_completed = async move {
+            let _token: ui_views::ViewportCreationToken =
+                release_viewport_result.await.context("while releasing viewport")?;
+            Ok(())
+        }
+        .boxed_local();
+
+        self.window = None;
+        Ok(DismissWindowResponse { on_completed })
+    }
+
+    pub fn active_window_id(&self) -> Option<WindowId> {
+        let window = self.window.as_ref()?;
+        Some(window.window_id)
+    }
+}
+
+#[derive(Debug, Copy, Clone, Hash, Ord, PartialOrd, Eq, PartialEq)]
+pub struct WindowId(u64);
+
+impl WindowId {
+    pub fn into_content_id(self) -> ui_comp::ContentId {
+        ui_comp::ContentId { value: self.0 }
+    }
+}
+
+/// Response for the `create_window` call.
+pub struct CreateWindowResponse {
+    /// ID for the window that was created.
+    pub window_id: WindowId,
+
+    /// A future that resolves when a child view has actually been attached to
+    /// the window. This `ViewRef` should then be passed back into
+    /// `register_window`, so we can do things like give it focus.
+    pub on_child_view_attached: future::LocalBoxFuture<'static, anyhow::Result<ui_views::ViewRef>>,
+
+    /// A future that resolves when the `ChildViewWatcher` associated with the
+    /// window closes.
+    pub on_child_view_closed: future::LocalBoxFuture<'static, anyhow::Result<()>>,
+}
+
+/// Response for the `focus_window` call.
+pub struct FocusWindowResponse {
+    /// A Future indicating the success/failure of the call.
+    pub on_completed: future::LocalBoxFuture<'static, anyhow::Result<()>>,
+}
+
+/// Response for the `dismiss_window` call.
+pub struct DismissWindowResponse {
+    /// A Future indicating the success/failure of the call.
+    pub on_completed: future::LocalBoxFuture<'static, anyhow::Result<()>>,
+}
+
+/// `Manager` implements business logic for the window manager. It responds to
+/// user requests, manipulates the `View`, and handles a queue of background
+/// tasks.
+///
+/// Users of `Manager` must call `select_background_task` regularly to drive
+/// internal background tasks and observe any errors going on in the background.
+pub struct Manager {
+    view: View,
+    background_tasks: stream::FuturesUnordered<
+        future::LocalBoxFuture<'static, Box<dyn FnOnce(&mut Manager) -> anyhow::Result<()>>>,
+    >,
+}
+
+impl Manager {
+    /// Create a new `Manager` that manipulates the given `View`.
+    pub fn new(view: View) -> Self {
+        Self { view, background_tasks: stream::FuturesUnordered::new() }
+    }
+
+    /// Handle a `GraphicalPresenter::PresentView` request.
+    //
+    // TODO(hjfreyer@google.com): Consider supporting applications that don't
+    // supply a `view_controller_request`.
     pub fn present_view(
         &mut self,
-        request: felement::GraphicalPresenterRequest,
-    ) -> anyhow::Result<EventStream> {
-        let felement::GraphicalPresenterRequest::PresentView {
-            view_spec,
-            annotation_controller,
-            view_controller_request,
-            responder,
-        } = request;
-
+        view_spec: felement::ViewSpec,
+        _annotation_controller: Option<endpoints::ClientEnd<felement::AnnotationControllerMarker>>,
+        view_controller_request: Option<endpoints::ServerEnd<felement::ViewControllerMarker>>,
+    ) -> anyhow::Result<()> {
         let viewport_creation_token = view_spec
             .viewport_creation_token
             .ok_or(anyhow!("view_spec didn't include viewport_creation_token"))?;
@@ -221,131 +384,186 @@
         let view_controller_server = view_controller_request
             .ok_or(anyhow!("request didn't include view_controller_request"))?;
 
-        let dismiss_window_events = self.dismiss_window();
+        // Dismiss the existing window, if any.
+        if let Some(window_id) = self.view.active_window_id() {
+            let dismiss_window_response = self.view.dismiss_window(window_id)?;
+            self.background_result(dismiss_window_response.on_completed);
+        }
 
-        let create_window_events = self.create_window(
-            viewport_creation_token,
-            annotation_controller,
-            view_controller_server,
-        )?;
+        let CreateWindowResponse { window_id, on_child_view_attached, on_child_view_closed } =
+            self.view.create_window(viewport_creation_token)?;
 
-        responder.send(&mut Ok(())).context("while replying to PresentView")?;
-        Ok(stream::select_all([dismiss_window_events, create_window_events]).boxed())
-    }
+        // Register the child view once it is attached to the window.
+        self.and_then_background_task(
+            on_child_view_attached,
+            move |this: &mut Manager, child_view_ref: ui_views::ViewRef| {
+                if this.view.active_window_id() != Some(window_id) {
+                    tracing::warn!(
+                        "Trying to register child view for {:?}, but the active window is {:?}",
+                        window_id,
+                        this.view.active_window_id()
+                    );
+                    return Ok(());
+                }
 
-    /// Handle an asynchronous event returned by another method.
-    pub fn handle_event(&mut self, event: Event) -> EventStream {
-        match event {
-            Event::Ok => stream::empty().boxed(),
-            Event::Err(err) => {
-                tracing::error!("async error: {}", err);
-                stream::empty().boxed()
+                this.view.register_window(window_id, child_view_ref)?;
+                let response = this.view.focus_window(window_id)?;
+
+                this.background_result(response.on_completed);
+                Ok(())
+            },
+        );
+
+        // A Future that resolves with `true` when the `ViewController` client
+        // calls `ViewController::Dismiss`, or `false` if the channel closes
+        // without a call to `Dismiss`. Logs any errors encountered along the
+        // way.
+        //
+        // We observe this, and call `View::dismiss_window` when requested.
+        let was_dismissed = view_controller_server.into_stream()?.any(|request| match request {
+            Ok(felement::ViewControllerRequest::Dismiss { .. }) => futures::future::ready(true),
+            Err(err) => {
+                tracing::warn!("while reading ViewController request: {}", err);
+                futures::future::ready(false)
             }
-            Event::ChildAttached { window_content_id, child_view_ref } => match &mut self.window {
-                Some(window) if window.content_id == window_content_id => self
-                    .root_focuser
-                    .set_auto_focus(ui_views::FocuserSetAutoFocusRequest {
-                        view_ref: Some(child_view_ref),
-                        ..ui_views::FocuserSetAutoFocusRequest::EMPTY
-                    })
-                    .map(|res| {
-                        Event::from(res.context("setting auto_focus").map(|inner_res| {
-                            match inner_res {
-                                Ok(()) => Event::Ok,
-                                Err(err) => Event::Err(anyhow!("auto focus error: {:?}", err)),
-                            }
-                        }))
-                    })
-                    .into_stream()
-                    .boxed(),
-                _ => stream::empty().boxed(),
-            },
-            Event::DismissRequested { window_content_id } => match &mut self.window {
-                Some(window) if window.content_id == window_content_id => self.dismiss_window(),
-                _ => stream::empty().boxed(),
-            },
-        }
+        });
+
+        self.background_task(was_dismissed, move |this: &mut Manager, was_dismissed| {
+            if was_dismissed && this.view.active_window_id() == Some(window_id) {
+                tracing::info!("Dismiss for window {:?} requested", window_id);
+                let dismiss_window_response = this.view.dismiss_window(window_id)?;
+                this.background_result(dismiss_window_response.on_completed);
+            }
+            Ok(())
+        });
+
+        // We also dismiss and clean-up the window if the child view is closed.
+        self.and_then_background_task(on_child_view_closed, move |this: &mut Manager, ()| {
+            if this.view.active_window_id() != Some(window_id) {
+                return Ok(());
+            }
+            let dismiss_window_response = this.view.dismiss_window(window_id)?;
+            this.background_result(dismiss_window_response.on_completed);
+            Ok(())
+        });
+
+        Ok(())
+    }
+
+    /// A Future that resolves when the next background task has been completed.
+    /// The Future returns a `Result` indicating whether the background task
+    /// succeeded or failed. If there is no background work, this blocks
+    /// forever.
+    ///
+    /// This is intended to be used in a `select!{}` block.
+    pub fn select_background_task(&mut self) -> SelectBackgroundTask<'_> {
+        return SelectBackgroundTask { manager: self };
+    }
+
+    /// Enqueues a background task from a Future and a closure. Note that `fut`
+    /// has a static lifetime, and therefore cannot depend on `self`. The
+    /// closure takes a `&mut Manager` and the output of `fut`.
+    ///
+    /// `fut` will be polled whenever the result of `select_background_task` is
+    /// polled, and once `fut` completes, `work` will be called on the result.
+    fn background_task<Fut, Work>(&mut self, fut: Fut, work: Work)
+    where
+        Fut: futures::Future + 'static,
+        Work: FnOnce(&mut Manager, Fut::Output) -> anyhow::Result<()> + 'static,
+    {
+        self.background_tasks.push(
+            fut.map(|res| -> Box<dyn FnOnce(&mut Manager) -> anyhow::Result<()>> {
+                Box::new(move |wrapper: &mut Manager| -> anyhow::Result<()> { work(wrapper, res) })
+            })
+            .boxed_local(),
+        );
+    }
+
+    /// Version of `background_task` that passes through any errors returned by
+    /// `fut`. This is to `background_task` what `and_then` is to `map`.
+    fn and_then_background_task<Ok, Fut, Work>(&mut self, fut: Fut, work: Work)
+    where
+        Fut: futures::Future<Output = anyhow::Result<Ok>> + 'static,
+        Work: FnOnce(&mut Manager, Ok) -> anyhow::Result<()> + 'static,
+    {
+        self.background_task(fut, |this, result| {
+            let ok = result?;
+            work(this, ok)
+        })
+    }
+
+    /// Version of `background_task` that does no work.
+    /// `select_background_task()` simply polls `fut` and then returns the
+    /// result once it's done.
+    fn background_result<Fut>(&mut self, fut: Fut)
+    where
+        Fut: futures::Future<Output = anyhow::Result<()>> + 'static,
+    {
+        self.background_task(fut, |_, result| result)
     }
 }
 
-/// An asynchronous event, generally returned by Flatland or another dependency.
+/// Future for the `select_background_task` method.
 ///
-/// Most methods in WindowManager are feed-forward and synchronous. When work
-/// needs to happen in response to something else, a method will return a stream
-/// of Events. That Stream should be polled by the main loop, and any events it
-/// emits should be passed back into WindowManager::handle_event.
-#[derive(Debug)]
-pub enum Event {
-    /// No-op. Indicates that some background work has completed successfully.
-    Ok,
-
-    /// Indicates that some background work encountered an error that we don't
-    /// know how to act on.
-    Err(anyhow::Error),
-
-    /// Indicates that a child view has attached to the viewport.
-    ChildAttached { window_content_id: flatland::ContentId, child_view_ref: ViewRef },
-
-    /// Indicates that the child has requested that the window be dismissed, or
-    /// has dropped its end of the ViewController for whatever other reason.
-    DismissRequested { window_content_id: flatland::ContentId },
+/// Based on `futures::stream::SelectNextSome`.
+pub struct SelectBackgroundTask<'a> {
+    manager: &'a mut Manager,
 }
 
-impl From<Result<Event, anyhow::Error>> for Event {
-    fn from(result: Result<Event, anyhow::Error>) -> Self {
-        match result {
-            Ok(event) => event,
-            Err(err) => Event::Err(err),
-        }
+impl<'a> future::FusedFuture for SelectBackgroundTask<'a> {
+    fn is_terminated(&self) -> bool {
+        self.manager.background_tasks.is_terminated()
     }
 }
 
-/// A boxed stream of Events with a static lifetime.
-pub type EventStream = stream::BoxStream<'static, Event>;
+impl<'a> futures::Future for SelectBackgroundTask<'a> {
+    type Output = anyhow::Result<()>;
 
-/// A helper structure for storing state associated with a (full screen) Window.
-struct Window {
-    _annotation_controller: Option<endpoints::ClientEnd<felement::AnnotationControllerMarker>>,
-    content_id: ui_comp::ContentId,
+    fn poll(
+        mut self: std::pin::Pin<&mut Self>,
+        cx: &mut std::task::Context<'_>,
+    ) -> std::task::Poll<Self::Output> {
+        assert!(
+            !self.manager.background_tasks.is_terminated(),
+            "SelectBackgroundTask polled after terminated"
+        );
+
+        if let Some(item) = futures::ready!((*self).manager.background_tasks.poll_next_unpin(cx)) {
+            std::task::Poll::Ready(item(&mut self.manager))
+        } else {
+            debug_assert!(self.manager.background_tasks.is_terminated());
+            cx.waker().wake_by_ref();
+            std::task::Poll::Pending
+        }
+    }
 }
 
 #[cfg(test)]
 mod tests {
-    use {
-        super::*,
-        anyhow::Error,
-        assert_matches::assert_matches,
-        fidl::endpoints::{create_proxy_and_stream, create_request_stream},
-        fidl_fuchsia_element as felement, fidl_fuchsia_ui_app as ui_app,
-        fidl_fuchsia_ui_composition as ui_comp, fidl_fuchsia_ui_test_scene as ui_test_scene,
-        fidl_fuchsia_ui_views as ui_views, fuchsia_async as fasync,
-        fuchsia_component_test::{
-            Capability, ChildOptions, LocalComponentHandles, RealmBuilder, Ref, Route,
-        },
-        fuchsia_scenic::flatland,
-        fuchsia_scenic::flatland::ViewCreationTokenPair,
-        futures::future::{AbortHandle, Abortable},
-        futures::{StreamExt, TryStreamExt},
-        std::collections::HashMap,
-    };
+    use anyhow::Error;
+    use fidl::endpoints::{self, Proxy};
+    use fidl_fuchsia_element as felement;
+    use fidl_fuchsia_ui_app as ui_app;
+    use fidl_fuchsia_ui_composition as ui_comp;
+    use fidl_fuchsia_ui_test_scene as ui_test_scene;
+    use fidl_fuchsia_ui_views as ui_views;
+    use fuchsia_async as fasync;
+    use fuchsia_component_test::{Capability, ChildOptions, RealmBuilder, Ref, Route};
+    use fuchsia_scenic::flatland;
+    use futures::{select, StreamExt};
 
-    // A stream that emits an empty value for each allowed call to present.
-    // Panics on any errors encountered.
-    fn present_budget_stream(
-        events: flatland::FlatlandEventStream,
-    ) -> impl stream::Stream<Item = ()> {
-        // You get one token for free.
-        stream::iter([()]).chain(events.filter_map(|event| {
-            futures::future::ready({
-                match event.unwrap() {
-                    ui_comp::FlatlandEvent::OnNextFrameBegin { .. } => Some(()),
-                    ui_comp::FlatlandEvent::OnFramePresented { .. } => None,
-                    ui_comp::FlatlandEvent::OnError { error } => {
-                        panic!("flatland error: {:?}", error)
-                    }
+    use super::*;
+
+    async fn await_next_on_frame_begin(events: &mut flatland::FlatlandEventStream) {
+        loop {
+            match events.next().await.unwrap().unwrap() {
+                ui_comp::FlatlandEvent::OnNextFrameBegin { .. } => return,
+                ui_comp::FlatlandEvent::OnFramePresented { .. } => (),
+                ui_comp::FlatlandEvent::OnError { error } => {
+                    panic!("flatland error: {:?}", error)
                 }
-            })
-        }))
+            }
+        }
     }
 
     #[fuchsia::test]
@@ -381,118 +599,158 @@
         let realm = builder.build().await?;
         let flatland =
             realm.root.connect_to_protocol_at_exposed_dir::<flatland::FlatlandMarker>()?;
-        let mut budget = present_budget_stream(flatland.take_event_stream());
-
-        let (view_provider, mut view_provider_request_stream) =
-            create_request_stream::<ui_app::ViewProviderMarker>()
-                .expect("failed to create ViewProvider request stream");
-
         let scene_controller =
             realm.root.connect_to_protocol_at_exposed_dir::<ui_test_scene::ControllerMarker>()?;
-        fasync::Task::spawn(async move {
-            let _view_ref_koid = scene_controller
-                .attach_client_view(ui_test_scene::ControllerAttachClientViewRequest {
-                    view_provider: Some(view_provider),
-                    ..ui_test_scene::ControllerAttachClientViewRequest::EMPTY
-                })
-                .await
-                .expect("failed to attach root client view");
-        })
-        .detach();
 
-        // Set up the window manager.
-        let view_creation_token = view_provider_request_stream
-            .map(|request| {
-                if let Ok(ui_app::ViewProviderRequest::CreateView2 { args, .. }) = request {
-                    args.view_creation_token.unwrap()
-                } else {
-                    panic!("Unexpected request: {:?}", request)
-                }
+        let (graphical_presenter_proxy, graphical_presenter_request_stream) =
+            endpoints::create_proxy_and_stream::<felement::GraphicalPresenterMarker>()?;
+
+        // A test server that loops on every frame.
+        async fn test_server(
+            flatland: flatland::FlatlandProxy,
+            scene_controller: ui_test_scene::ControllerProxy,
+            mut graphical_presenter_request_stream: felement::GraphicalPresenterRequestStream,
+        ) {
+            let (view_provider, view_provider_request_stream) =
+                endpoints::create_request_stream::<ui_app::ViewProviderMarker>()
+                    .expect("failed to create ViewProvider request stream");
+
+            fasync::Task::spawn(async move {
+                let _view_ref_koid = scene_controller
+                    .attach_client_view(ui_test_scene::ControllerAttachClientViewRequest {
+                        view_provider: Some(view_provider),
+                        ..ui_test_scene::ControllerAttachClientViewRequest::EMPTY
+                    })
+                    .await
+                    .expect("failed to attach root client view");
             })
-            .into_future()
-            .await
-            .0
-            .unwrap();
+            .detach();
 
-        let mut server = WindowManager::new(flatland.clone(), view_creation_token).await?;
+            // Set up the window manager.
+            let view_creation_token = view_provider_request_stream
+                .map(|request| {
+                    if let Ok(ui_app::ViewProviderRequest::CreateView2 { args, .. }) = request {
+                        args.view_creation_token.unwrap()
+                    } else {
+                        panic!("Unexpected request: {:?}", request)
+                    }
+                })
+                .into_future()
+                .await
+                .0
+                .unwrap();
 
-        budget.next().await;
-        flatland.present(flatland::PresentArgs::EMPTY)?;
+            let mut server =
+                Manager::new(View::new(flatland.clone(), view_creation_token).await.unwrap());
+            let mut flatland_events = flatland.take_event_stream();
 
-        // Create a window.
-        let ViewCreationTokenPair { mut view_creation_token, viewport_creation_token } =
-            ViewCreationTokenPair::new()?;
+            loop {
+                flatland.present(flatland::PresentArgs::EMPTY).unwrap();
 
-        let flatland2 =
-            realm.root.connect_to_protocol_at_exposed_dir::<flatland::FlatlandMarker>()?;
-        let mut budget2 = present_budget_stream(flatland2.take_event_stream());
+                await_next_on_frame_begin(&mut flatland_events).await;
+                select! {
+                    req = graphical_presenter_request_stream.next() => {
+                        if req.is_none() {
+                            return
+                        }
 
-        let (parent_viewport_watcher, parent_viewport_watcher_request) =
-            endpoints::create_proxy::<flatland::ParentViewportWatcherMarker>().unwrap();
+                        let felement::GraphicalPresenterRequest::PresentView {
+                            view_spec,
+                            annotation_controller,
+                            view_controller_request,
+                            responder,
+                        } = req.unwrap().unwrap();
+                        server.present_view(
+                            view_spec,
+                            annotation_controller,
+                            view_controller_request
+                        )
+                        .unwrap();
+                        responder.send(&mut Ok(())).unwrap();
+                    }
+                    bg = server.select_background_task() => {
+                        bg.unwrap();
+                    }
+                }
+            }
+        }
 
-        flatland2
-            .create_view2(
-                &mut view_creation_token,
-                &mut ui_views::ViewIdentityOnCreation::from(ViewRefPair::new().unwrap()),
-                flatland::ViewBoundProtocols::EMPTY,
-                parent_viewport_watcher_request,
-            )
-            .unwrap();
+        async fn test_client(
+            flatland2: flatland::FlatlandProxy,
+            graphical_presenter_proxy: felement::GraphicalPresenterProxy,
+        ) {
+            // Create a window.
+            let flatland::ViewCreationTokenPair {
+                mut view_creation_token,
+                viewport_creation_token,
+            } = flatland::ViewCreationTokenPair::new().unwrap();
 
-        let (view_controller_client, view_controller_server) =
-            endpoints::create_proxy::<felement::ViewControllerMarker>().unwrap();
-        let mut creation_events =
-            server.create_window(viewport_creation_token, None, view_controller_server)?;
+            let (view_controller_client, view_controller_server) =
+                endpoints::create_proxy::<felement::ViewControllerMarker>().unwrap();
 
-        budget.next().await;
-        flatland.present(flatland::PresentArgs::EMPTY)?;
-        budget2.next().await;
-        flatland2.present(flatland::PresentArgs::EMPTY)?;
+            let view_spec = felement::ViewSpec {
+                viewport_creation_token: Some(viewport_creation_token),
+                ..felement::ViewSpec::EMPTY
+            };
 
-        // Observe that the child was attached.
-        let event = creation_events.next().await.unwrap();
-        assert_matches!(
-            &event,
-            Event::ChildAttached { window_content_id: flatland::ContentId { value: 4 }, .. }
-        );
+            let () = graphical_presenter_proxy
+                .present_view(view_spec, None, Some(view_controller_server))
+                .await
+                .unwrap()
+                .unwrap();
 
-        let attached_events: Vec<_> = server.handle_event(event).collect().await;
-        assert_eq!(attached_events.len(), 1);
-        assert_matches!(attached_events[0], Event::Ok);
+            let (parent_viewport_watcher, parent_viewport_watcher_request) =
+                endpoints::create_proxy::<flatland::ParentViewportWatcherMarker>().unwrap();
 
-        assert_matches!(
-            parent_viewport_watcher.get_status().await,
-            Ok(ui_comp::ParentViewportStatus::ConnectedToDisplay)
-        );
+            let (view_ref_focused_proxy, view_ref_focused_server) =
+                endpoints::create_proxy::<fidl_fuchsia_ui_views::ViewRefFocusedMarker>().unwrap();
 
-        // Close the window.
-        view_controller_client.dismiss()?;
+            flatland2
+                .create_view2(
+                    &mut view_creation_token,
+                    &mut ui_views::ViewIdentityOnCreation::from(
+                        fuchsia_scenic::ViewRefPair::new().unwrap(),
+                    ),
+                    flatland::ViewBoundProtocols {
+                        view_ref_focused: Some(view_ref_focused_server),
+                        ..flatland::ViewBoundProtocols::EMPTY
+                    },
+                    parent_viewport_watcher_request,
+                )
+                .unwrap();
 
-        budget.next().await;
-        flatland.present(flatland::PresentArgs::EMPTY)?;
-        budget2.next().await;
-        flatland2.present(flatland::PresentArgs::EMPTY)?;
+            let mut events = flatland2.take_event_stream();
+            flatland2.present(flatland::PresentArgs::EMPTY).unwrap();
+            await_next_on_frame_begin(&mut events).await;
 
-        let event = creation_events.next().await;
-        assert_matches!(
-            &event,
-            Some(Event::DismissRequested { window_content_id: flatland::ContentId { value: 4 } })
-        );
+            // Wait for the child to be attached.
+            while parent_viewport_watcher.get_status().await.unwrap()
+                != ui_comp::ParentViewportStatus::ConnectedToDisplay
+            {}
 
-        let dismiss_events = server.handle_event(event.unwrap());
+            // Wait for the child to get focus.
+            while view_ref_focused_proxy.watch().await.unwrap().focused != Some(true) {}
 
-        budget.next().await;
-        flatland.present(flatland::PresentArgs::EMPTY)?;
-        budget2.next().await;
-        flatland2.present(flatland::PresentArgs::EMPTY)?;
+            // Dismiss the view, and wait for the parent_viewport_watcher to close.
+            view_controller_client.dismiss().unwrap();
+            parent_viewport_watcher.on_closed().await.unwrap();
+        }
 
-        let dismiss_events: Vec<_> = dismiss_events.collect().await;
-        assert_eq!(dismiss_events.len(), 1);
-        assert_matches!(dismiss_events[0], Event::Ok);
+        let server_task = fasync::Task::local(test_server(
+            flatland,
+            scene_controller,
+            graphical_presenter_request_stream,
+        ));
 
-        // Check that the parent_viewport_watcher stream closes.
-        assert_matches!(parent_viewport_watcher.take_event_stream().into_future().await.0, None);
+        let client_task = fasync::Task::local(test_client(
+            realm.root.connect_to_protocol_at_exposed_dir::<flatland::FlatlandMarker>()?,
+            graphical_presenter_proxy,
+        ));
 
+        // client_task completing deletes the `GraphicalPresenterProxy`, which
+        // tells the server to shut down.
+        client_task.await;
+        server_task.await;
         realm.destroy().await?;
         Ok(())
     }
diff --git a/tests/BUILD.gn b/tests/BUILD.gn
index f1424e3..4ce3d93 100644
--- a/tests/BUILD.gn
+++ b/tests/BUILD.gn
@@ -14,9 +14,9 @@
   sources = [ "ermine_driver.dart" ]
 
   deps = [
-    "//sdk/fidl/fuchsia.input",
-    "//sdk/fidl/fuchsia.ui.input",
-    "//sdk/fidl/fuchsia.ui.input3",
+    "//sdk/fidl/fuchsia.input:fuchsia.input_dart",
+    "//sdk/fidl/fuchsia.ui.input:fuchsia.ui.input_dart",
+    "//sdk/fidl/fuchsia.ui.input3:fuchsia.ui.input3_dart",
     "//sdk/testing/sl4f/client",
     "//sdk/testing/sl4f/flutter_driver_sl4f",
     "//third_party/dart-pkg/git/flutter/packages/flutter_driver",
diff --git a/tests/chrome/BUILD.gn b/tests/chrome/BUILD.gn
index 9c8298e..5ebe014 100644
--- a/tests/chrome/BUILD.gn
+++ b/tests/chrome/BUILD.gn
@@ -20,7 +20,7 @@
 
   deps = [
     "//sdk/dart/fuchsia_logger",
-    "//sdk/fidl/fuchsia.input",
+    "//sdk/fidl/fuchsia.input:fuchsia.input_dart",
     "//sdk/testing/sl4f/client",
     "//sdk/testing/sl4f/flutter_driver_sl4f",
     "//src/experiences/tests:ermine_driver",
@@ -52,7 +52,7 @@
 
   deps = [
     "//sdk/dart/fuchsia_logger",
-    "//sdk/fidl/fuchsia.input",
+    "//sdk/fidl/fuchsia.input:fuchsia.input_dart",
     "//sdk/testing/sl4f/client",
     "//sdk/testing/sl4f/flutter_driver_sl4f",
     "//src/experiences/tests:ermine_driver",
diff --git a/tests/e2e/BUILD.gn b/tests/e2e/BUILD.gn
index be5a790..e27411b 100644
--- a/tests/e2e/BUILD.gn
+++ b/tests/e2e/BUILD.gn
@@ -32,9 +32,9 @@
   ]
 
   deps = [
-    "//sdk/fidl/fuchsia.input",
-    "//sdk/fidl/fuchsia.ui.input",
-    "//sdk/fidl/fuchsia.ui.input3",
+    "//sdk/fidl/fuchsia.input:fuchsia.input_dart",
+    "//sdk/fidl/fuchsia.ui.input:fuchsia.ui.input_dart",
+    "//sdk/fidl/fuchsia.ui.input3:fuchsia.ui.input3_dart",
     "//sdk/testing/sl4f/client",
     "//sdk/testing/sl4f/flutter_driver_sl4f",
     "//src/experiences/tests:ermine_driver",
@@ -70,9 +70,9 @@
   sources = [ "ermine_terminal_test.dart" ]
 
   deps = [
-    "//sdk/fidl/fuchsia.input",
-    "//sdk/fidl/fuchsia.ui.input",
-    "//sdk/fidl/fuchsia.ui.input3",
+    "//sdk/fidl/fuchsia.input:fuchsia.input_dart",
+    "//sdk/fidl/fuchsia.ui.input:fuchsia.ui.input_dart",
+    "//sdk/fidl/fuchsia.ui.input3:fuchsia.ui.input3_dart",
     "//sdk/testing/sl4f/client",
     "//sdk/testing/sl4f/flutter_driver_sl4f",
     "//src/experiences/tests:ermine_driver",
@@ -107,9 +107,9 @@
   sources = [ "ermine_smoke_test.dart" ]
 
   deps = [
-    "//sdk/fidl/fuchsia.input",
-    "//sdk/fidl/fuchsia.ui.input",
-    "//sdk/fidl/fuchsia.ui.input3",
+    "//sdk/fidl/fuchsia.input:fuchsia.input_dart",
+    "//sdk/fidl/fuchsia.ui.input:fuchsia.ui.input_dart",
+    "//sdk/fidl/fuchsia.ui.input3:fuchsia.ui.input3_dart",
     "//sdk/testing/sl4f/client",
     "//sdk/testing/sl4f/flutter_driver_sl4f",
     "//src/experiences/tests:ermine_driver",
@@ -157,7 +157,9 @@
     deps = [
       ":experiences_ermine_session_shell_e2e_test($host_toolchain)",
       ":experiences_ermine_smoke_e2e_test($host_toolchain)",
-      ":experiences_ermine_terminal_e2e_test($host_toolchain)",
+
+      # TODO(fxbug.dev/110585): Re-enable once terminal is supported.
+      # ":experiences_ermine_terminal_e2e_test($host_toolchain)",
     ]
   }
 }
diff --git a/tests/lib/ermine_driver.dart b/tests/lib/ermine_driver.dart
index 2a2ba0f..f6c7d37 100644
--- a/tests/lib/ermine_driver.dart
+++ b/tests/lib/ermine_driver.dart
@@ -8,7 +8,8 @@
 import 'dart:math';
 
 // ignore_for_file: import_of_legacy_library_into_null_safe
-
+import 'package:fidl_fuchsia_input/fidl_async.dart' as input;
+import 'package:fidl_fuchsia_ui_input3/fidl_async.dart' as input3;
 import 'package:fidl_fuchsia_input/fidl_async.dart';
 import 'package:fidl_fuchsia_ui_input3/fidl_async.dart' hide KeyEvent;
 import 'package:flutter_driver/flutter_driver.dart';
@@ -186,12 +187,12 @@
     const key1Release = Duration(milliseconds: 600);
 
     final input = Input(sl4f);
-    await input.keyEvents([
-      KeyEvent(modifier, key1Press, KeyEventType.pressed),
-      KeyEvent(key, key2Press, KeyEventType.pressed),
-      KeyEvent(key, key2Release, KeyEventType.released),
-      KeyEvent(modifier, key1Release, KeyEventType.released),
-    ]);
+    await input.sendKeyEvents([
+      InputKeyEvent(modifier, key1Press, KeyEventType.pressed),
+      InputKeyEvent(key, key2Press, KeyEventType.pressed),
+      InputKeyEvent(key, key2Release, KeyEventType.released),
+      InputKeyEvent(modifier, key1Release, KeyEventType.released),
+    ].map((e) => e.toJson()).toList());
     await driver.waitUntilNoTransientCallbacks();
   }
 
@@ -205,14 +206,14 @@
     const key1Release = Duration(milliseconds: 700);
 
     final input = Input(sl4f);
-    await input.keyEvents([
-      KeyEvent(modifier1, key1Press, KeyEventType.pressed),
-      KeyEvent(modifier2, key2Press, KeyEventType.pressed),
-      KeyEvent(key, key3Press, KeyEventType.pressed),
-      KeyEvent(key, key3Release, KeyEventType.released),
-      KeyEvent(modifier2, key2Release, KeyEventType.released),
-      KeyEvent(modifier1, key1Release, KeyEventType.released),
-    ]);
+    await input.sendKeyEvents([
+      InputKeyEvent(modifier1, key1Press, KeyEventType.pressed),
+      InputKeyEvent(modifier2, key2Press, KeyEventType.pressed),
+      InputKeyEvent(key, key3Press, KeyEventType.pressed),
+      InputKeyEvent(key, key3Release, KeyEventType.released),
+      InputKeyEvent(modifier2, key2Release, KeyEventType.released),
+      InputKeyEvent(modifier1, key1Release, KeyEventType.released),
+    ].map((e) => e.toJson()).toList());
     await driver.waitUntilNoTransientCallbacks();
   }
 
@@ -617,3 +618,20 @@
     return Rectangle(0, 0, 0, 0);
   }
 }
+
+/// Describes  single key event to pass into input_facade.
+class InputKeyEvent {
+  final input.Key _key;
+  final Duration _durationSinceStart;
+  final input3.KeyEventType _type;
+
+  /// Creates a new [KeyEvent].
+  InputKeyEvent(this._key, this._durationSinceStart, this._type);
+
+  /// Return this as a primitive object that can be JSON-encoded.
+  Map<String, dynamic> toJson() => {
+        'key': _key.$value,
+        'duration_millis': _durationSinceStart.inMilliseconds,
+        'type': _type.$value,
+      };
+}
diff --git a/tests/performance/BUILD.gn b/tests/performance/BUILD.gn
index 549d900..f81dd7c 100644
--- a/tests/performance/BUILD.gn
+++ b/tests/performance/BUILD.gn
@@ -22,9 +22,9 @@
   sources = [ "workstation_performance_navigation_test.dart" ]
 
   deps = [
-    "//sdk/fidl/fuchsia.input",
-    "//sdk/fidl/fuchsia.ui.input",
-    "//sdk/fidl/fuchsia.ui.input3",
+    "//sdk/fidl/fuchsia.input:fuchsia.input_dart",
+    "//sdk/fidl/fuchsia.ui.input:fuchsia.ui.input_dart",
+    "//sdk/fidl/fuchsia.ui.input3:fuchsia.ui.input3_dart",
     "//sdk/testing/sl4f/client",
     "//sdk/testing/sl4f/flutter_driver_sl4f",
     "//src/experiences/tests:ermine_driver",