Merge "Support delegating socket connection" into main
diff --git a/Android.bp b/Android.bp
index 4ebb8f6..deb19b0 100644
--- a/Android.bp
+++ b/Android.bp
@@ -5238,6 +5238,7 @@
         "protos/perfetto/trace/android/initial_display_state.proto",
         "protos/perfetto/trace/android/network_trace.proto",
         "protos/perfetto/trace/android/packages_list.proto",
+        "protos/perfetto/trace/android/shell_transition.proto",
         "protos/perfetto/trace/android/surfaceflinger_common.proto",
         "protos/perfetto/trace/android/surfaceflinger_layers.proto",
         "protos/perfetto/trace/android/surfaceflinger_transactions.proto",
@@ -5267,6 +5268,7 @@
         "external/perfetto/protos/perfetto/trace/android/initial_display_state.gen.cc",
         "external/perfetto/protos/perfetto/trace/android/network_trace.gen.cc",
         "external/perfetto/protos/perfetto/trace/android/packages_list.gen.cc",
+        "external/perfetto/protos/perfetto/trace/android/shell_transition.gen.cc",
         "external/perfetto/protos/perfetto/trace/android/surfaceflinger_common.gen.cc",
         "external/perfetto/protos/perfetto/trace/android/surfaceflinger_layers.gen.cc",
         "external/perfetto/protos/perfetto/trace/android/surfaceflinger_transactions.gen.cc",
@@ -5296,6 +5298,7 @@
         "external/perfetto/protos/perfetto/trace/android/initial_display_state.gen.h",
         "external/perfetto/protos/perfetto/trace/android/network_trace.gen.h",
         "external/perfetto/protos/perfetto/trace/android/packages_list.gen.h",
+        "external/perfetto/protos/perfetto/trace/android/shell_transition.gen.h",
         "external/perfetto/protos/perfetto/trace/android/surfaceflinger_common.gen.h",
         "external/perfetto/protos/perfetto/trace/android/surfaceflinger_layers.gen.h",
         "external/perfetto/protos/perfetto/trace/android/surfaceflinger_transactions.gen.h",
@@ -5320,6 +5323,7 @@
         "protos/perfetto/trace/android/initial_display_state.proto",
         "protos/perfetto/trace/android/network_trace.proto",
         "protos/perfetto/trace/android/packages_list.proto",
+        "protos/perfetto/trace/android/shell_transition.proto",
         "protos/perfetto/trace/android/surfaceflinger_common.proto",
         "protos/perfetto/trace/android/surfaceflinger_layers.proto",
         "protos/perfetto/trace/android/surfaceflinger_transactions.proto",
@@ -5348,6 +5352,7 @@
         "external/perfetto/protos/perfetto/trace/android/initial_display_state.pb.cc",
         "external/perfetto/protos/perfetto/trace/android/network_trace.pb.cc",
         "external/perfetto/protos/perfetto/trace/android/packages_list.pb.cc",
+        "external/perfetto/protos/perfetto/trace/android/shell_transition.pb.cc",
         "external/perfetto/protos/perfetto/trace/android/surfaceflinger_common.pb.cc",
         "external/perfetto/protos/perfetto/trace/android/surfaceflinger_layers.pb.cc",
         "external/perfetto/protos/perfetto/trace/android/surfaceflinger_transactions.pb.cc",
@@ -5376,6 +5381,7 @@
         "external/perfetto/protos/perfetto/trace/android/initial_display_state.pb.h",
         "external/perfetto/protos/perfetto/trace/android/network_trace.pb.h",
         "external/perfetto/protos/perfetto/trace/android/packages_list.pb.h",
+        "external/perfetto/protos/perfetto/trace/android/shell_transition.pb.h",
         "external/perfetto/protos/perfetto/trace/android/surfaceflinger_common.pb.h",
         "external/perfetto/protos/perfetto/trace/android/surfaceflinger_layers.pb.h",
         "external/perfetto/protos/perfetto/trace/android/surfaceflinger_transactions.pb.h",
@@ -5390,6 +5396,7 @@
 genrule {
     name: "perfetto_protos_perfetto_trace_android_winscope_descriptor",
     srcs: [
+        "protos/perfetto/trace/android/shell_transition.proto",
         "protos/perfetto/trace/android/surfaceflinger_common.proto",
         "protos/perfetto/trace/android/surfaceflinger_layers.proto",
         "protos/perfetto/trace/android/surfaceflinger_transactions.proto",
@@ -5418,6 +5425,7 @@
         "protos/perfetto/trace/android/initial_display_state.proto",
         "protos/perfetto/trace/android/network_trace.proto",
         "protos/perfetto/trace/android/packages_list.proto",
+        "protos/perfetto/trace/android/shell_transition.proto",
         "protos/perfetto/trace/android/surfaceflinger_common.proto",
         "protos/perfetto/trace/android/surfaceflinger_layers.proto",
         "protos/perfetto/trace/android/surfaceflinger_transactions.proto",
@@ -5447,6 +5455,7 @@
         "external/perfetto/protos/perfetto/trace/android/initial_display_state.pbzero.cc",
         "external/perfetto/protos/perfetto/trace/android/network_trace.pbzero.cc",
         "external/perfetto/protos/perfetto/trace/android/packages_list.pbzero.cc",
+        "external/perfetto/protos/perfetto/trace/android/shell_transition.pbzero.cc",
         "external/perfetto/protos/perfetto/trace/android/surfaceflinger_common.pbzero.cc",
         "external/perfetto/protos/perfetto/trace/android/surfaceflinger_layers.pbzero.cc",
         "external/perfetto/protos/perfetto/trace/android/surfaceflinger_transactions.pbzero.cc",
@@ -5476,6 +5485,7 @@
         "external/perfetto/protos/perfetto/trace/android/initial_display_state.pbzero.h",
         "external/perfetto/protos/perfetto/trace/android/network_trace.pbzero.h",
         "external/perfetto/protos/perfetto/trace/android/packages_list.pbzero.h",
+        "external/perfetto/protos/perfetto/trace/android/shell_transition.pbzero.h",
         "external/perfetto/protos/perfetto/trace/android/surfaceflinger_common.pbzero.h",
         "external/perfetto/protos/perfetto/trace/android/surfaceflinger_layers.pbzero.h",
         "external/perfetto/protos/perfetto/trace/android/surfaceflinger_transactions.pbzero.h",
@@ -5695,6 +5705,7 @@
         "protos/perfetto/trace/android/initial_display_state.proto",
         "protos/perfetto/trace/android/network_trace.proto",
         "protos/perfetto/trace/android/packages_list.proto",
+        "protos/perfetto/trace/android/shell_transition.proto",
         "protos/perfetto/trace/android/surfaceflinger_common.proto",
         "protos/perfetto/trace/android/surfaceflinger_layers.proto",
         "protos/perfetto/trace/android/surfaceflinger_transactions.proto",
@@ -11258,6 +11269,8 @@
 filegroup {
     name: "perfetto_src_trace_processor_importers_proto_winscope_full",
     srcs: [
+        "src/trace_processor/importers/proto/winscope/shell_transitions_parser.cc",
+        "src/trace_processor/importers/proto/winscope/shell_transitions_tracker.cc",
         "src/trace_processor/importers/proto/winscope/surfaceflinger_layers_parser.cc",
         "src/trace_processor/importers/proto/winscope/surfaceflinger_transactions_parser.cc",
         "src/trace_processor/importers/proto/winscope/winscope_args_parser.cc",
@@ -13109,6 +13122,7 @@
         "protos/perfetto/trace/android/initial_display_state.proto",
         "protos/perfetto/trace/android/network_trace.proto",
         "protos/perfetto/trace/android/packages_list.proto",
+        "protos/perfetto/trace/android/shell_transition.proto",
         "protos/perfetto/trace/android/surfaceflinger_common.proto",
         "protos/perfetto/trace/android/surfaceflinger_layers.proto",
         "protos/perfetto/trace/android/surfaceflinger_transactions.proto",
@@ -14460,7 +14474,7 @@
         host: {
             static_libs: [
                 "libprotobuf-cpp-full",
-                "libsqlite",
+                "libsqlite_static_noicu",
                 "libz",
                 "sqlite_ext_percentile",
             ],
@@ -14612,7 +14626,7 @@
         ":perfetto_src_traceconv_utils",
     ],
     static_libs: [
-        "libsqlite",
+        "libsqlite_static_noicu",
         "libz",
         "perfetto_src_trace_processor_demangle",
         "sqlite_ext_percentile",
diff --git a/BUILD b/BUILD
index f869fb1..df6a288 100644
--- a/BUILD
+++ b/BUILD
@@ -1579,6 +1579,10 @@
 perfetto_filegroup(
     name = "src_trace_processor_importers_proto_winscope_full",
     srcs = [
+        "src/trace_processor/importers/proto/winscope/shell_transitions_parser.cc",
+        "src/trace_processor/importers/proto/winscope/shell_transitions_parser.h",
+        "src/trace_processor/importers/proto/winscope/shell_transitions_tracker.cc",
+        "src/trace_processor/importers/proto/winscope/shell_transitions_tracker.h",
         "src/trace_processor/importers/proto/winscope/surfaceflinger_layers_parser.cc",
         "src/trace_processor/importers/proto/winscope/surfaceflinger_layers_parser.h",
         "src/trace_processor/importers/proto/winscope/surfaceflinger_transactions_parser.cc",
@@ -4262,6 +4266,7 @@
         "protos/perfetto/trace/android/initial_display_state.proto",
         "protos/perfetto/trace/android/network_trace.proto",
         "protos/perfetto/trace/android/packages_list.proto",
+        "protos/perfetto/trace/android/shell_transition.proto",
         "protos/perfetto/trace/android/surfaceflinger_common.proto",
         "protos/perfetto/trace/android/surfaceflinger_layers.proto",
         "protos/perfetto/trace/android/surfaceflinger_transactions.proto",
@@ -4278,6 +4283,7 @@
 perfetto_proto_library(
     name = "protos_perfetto_trace_android_winscope_deps_protos",
     srcs = [
+        "protos/perfetto/trace/android/shell_transition.proto",
         "protos/perfetto/trace/android/surfaceflinger_common.proto",
         "protos/perfetto/trace/android/surfaceflinger_layers.proto",
         "protos/perfetto/trace/android/surfaceflinger_transactions.proto",
diff --git a/include/perfetto/base/build_config.h b/include/perfetto/base/build_config.h
index cd41e86..a0e6bb9 100644
--- a/include/perfetto/base/build_config.h
+++ b/include/perfetto/base/build_config.h
@@ -133,12 +133,10 @@
 //   http://msdn.microsoft.com/en-us/library/b0084kay.aspx
 //   http://www.agner.org/optimize/calling_conventions.pdf
 //   or with gcc, run: "echo | gcc -E -dM -"
-#if defined(_M_X64) || defined(__x86_64__)
-#define PERFETTO_BUILDFLAG_DEFINE_PERFETTO_ARCH_CPU_ARM64() 0
-#define PERFETTO_BUILDFLAG_DEFINE_PERFETTO_ARCH_CPU_X86_64() 1
-#elif defined(__aarch64__) || defined(_M_ARM64)
+#if defined(__aarch64__) || defined(_M_ARM64)
 #define PERFETTO_BUILDFLAG_DEFINE_PERFETTO_ARCH_CPU_ARM64() 1
-#define PERFETTO_BUILDFLAG_DEFINE_PERFETTO_ARCH_CPU_X86_64() 0
+#else
+#define PERFETTO_BUILDFLAG_DEFINE_PERFETTO_ARCH_CPU_ARM64() 0
 #endif
 
 // perfetto_build_flags.h contains the tweakable build flags defined via GN.
diff --git a/protos/perfetto/trace/android/BUILD.gn b/protos/perfetto/trace/android/BUILD.gn
index c5e842f..509c448 100644
--- a/protos/perfetto/trace/android/BUILD.gn
+++ b/protos/perfetto/trace/android/BUILD.gn
@@ -28,6 +28,7 @@
     "initial_display_state.proto",
     "network_trace.proto",
     "packages_list.proto",
+    "shell_transition.proto",
     "surfaceflinger_common.proto",
     "surfaceflinger_layers.proto",
     "surfaceflinger_transactions.proto",
@@ -37,6 +38,7 @@
 perfetto_proto_library("winscope_deps") {
   proto_generators = [ "source_set" ]
   sources = [
+    "shell_transition.proto",
     "surfaceflinger_common.proto",
     "surfaceflinger_layers.proto",
     "surfaceflinger_transactions.proto",
diff --git a/protos/perfetto/trace/android/shell_transition.proto b/protos/perfetto/trace/android/shell_transition.proto
new file mode 100644
index 0000000..9a34d3a
--- /dev/null
+++ b/protos/perfetto/trace/android/shell_transition.proto
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+syntax = "proto2";
+
+package perfetto.protos;
+
+// ShellTransition messages record information about the shell transitions in
+// the system. This is used to track the animations that are created and execute
+// through the shell transition system.
+message ShellTransition {
+  // The unique identifier of the transition.
+  optional int32 id = 1;
+
+  // The time the transition was created on the WM side
+  // (using SystemClock.elapsedRealtimeNanos())
+  optional int64 create_time_ns = 2;
+  // The time the transition was sent from the WM side to shell
+  // (using SystemClock.elapsedRealtimeNanos())
+  optional int64 send_time_ns = 3;
+  // The time the transition was dispatched by shell to execute
+  // (using SystemClock.elapsedRealtimeNanos())
+  optional int64 dispatch_time_ns = 4;
+  // If the transition merge was accepted by the transition handler, this
+  // contains the time the transition was merged into transition with id
+  // `merge_target`.
+  // (using SystemClock.elapsedRealtimeNanos())
+  optional int64 merge_time_ns = 5;
+  // The time shell proposed the transition should be merged to the transition
+  // handler into transition with id `merge_target`.
+  // (using SystemClock.elapsedRealtimeNanos()).
+  optional int64 merge_request_time_ns = 6;
+  // If the transition was aborted on the shell side, this is the time that
+  // occured.
+  // (using SystemClock.elapsedRealtimeNanos())
+  optional int64 shell_abort_time_ns = 7;
+  // If the transition was aborted on the wm side, this is the time that
+  // occured.
+  // (using SystemClock.elapsedRealtimeNanos())
+  optional int64 wm_abort_time_ns = 8;
+  // The time WM considers the transition to be complete.
+  // (using SystemClock.elapsedRealtimeNanos())
+  optional int64 finish_time_ns = 9;
+
+  // The id of the transaction that WM proposed to use as the starting
+  // transaction. It contains all the layer changes required to setup the
+  // transition and should be executed right at the start of the transition
+  // by the transition handler.
+  optional uint64 start_transaction_id = 10;
+  // The if of the transaction that WM proposed to use as the finish
+  // transaction. It contains all the layer changes required to set the final
+  // state of the transition.
+  optional uint64 finish_transaction_id = 11;
+
+  // The id of the handler that executed the transition. A HandlerMappings
+  // message in the trace will contain the mapping of id to a string
+  // representation of the handler.
+  optional int32 handler = 12;
+  // The transition type of this transition (e.g. TO_FRONT, OPEN, CLOSE).
+  optional int32 type = 13;
+
+  // The list of targets that are part of this transition.
+  repeated Target targets = 14;
+  // The id of the transition we have requested to merge or have merged this
+  // transition into.
+  optional int32 merge_target = 15;
+
+  // The flags set on this transition.
+  optional int32 flags = 16;
+  // The time the starting window was removed. Tracked because this can
+  // happen after the transition finishes, but the app may not yet be visible
+  // until the starting window is removed. So in a sense the transition is not
+  // finished until the starting window is removed. (b/284302118)
+  // (using SystemClock.elapsedRealtimeNanos())
+  optional int64 starting_window_remove_time_ns = 17;
+
+  // Contains the information about the windows targeted in a transition.
+  message Target {
+    // The transition mode of this target (e.g. TO_FRONT, CLOSE...)
+    optional int32 mode = 1;
+    // The layer id of this target.
+    optional int32 layer_id = 2;
+    // The window id of this target.
+    optional int32 window_id = 3;
+    // The flags set on this target.
+    optional int32 flags = 4;
+  }
+}
+
+// Contains mappings from handler ids to string representation of the handlers.
+message ShellHandlerMappings {
+  repeated ShellHandlerMapping mapping = 1;
+}
+
+message ShellHandlerMapping {
+  // The id of the handler used in the ShellTransition message.
+  optional int32 id = 1;
+  // A human readable and meaningful string representation of the handler.
+  optional string name = 2;
+}
diff --git a/protos/perfetto/trace/android/winscope.proto b/protos/perfetto/trace/android/winscope.proto
index 78fbdcc..296e3ee 100644
--- a/protos/perfetto/trace/android/winscope.proto
+++ b/protos/perfetto/trace/android/winscope.proto
@@ -18,6 +18,7 @@
 
 package perfetto.protos;
 
+import "protos/perfetto/trace/android/shell_transition.proto";
 import "protos/perfetto/trace/android/surfaceflinger_layers.proto";
 import "protos/perfetto/trace/android/surfaceflinger_transactions.proto";
 
@@ -26,4 +27,5 @@
 message WinscopeTraceData {
   optional LayersSnapshotProto layers_snapshot = 1;
   optional TransactionTraceEntry transactions = 2;
+  optional ShellTransition shell_transition = 3;
 }
diff --git a/protos/perfetto/trace/perfetto_trace.proto b/protos/perfetto/trace/perfetto_trace.proto
index 2ae554d..2c09723 100644
--- a/protos/perfetto/trace/perfetto_trace.proto
+++ b/protos/perfetto/trace/perfetto_trace.proto
@@ -4284,6 +4284,104 @@
 
 // End of protos/perfetto/trace/android/packages_list.proto
 
+// Begin of protos/perfetto/trace/android/shell_transition.proto
+
+// ShellTransition messages record information about the shell transitions in
+// the system. This is used to track the animations that are created and execute
+// through the shell transition system.
+message ShellTransition {
+  // The unique identifier of the transition.
+  optional int32 id = 1;
+
+  // The time the transition was created on the WM side
+  // (using SystemClock.elapsedRealtimeNanos())
+  optional int64 create_time_ns = 2;
+  // The time the transition was sent from the WM side to shell
+  // (using SystemClock.elapsedRealtimeNanos())
+  optional int64 send_time_ns = 3;
+  // The time the transition was dispatched by shell to execute
+  // (using SystemClock.elapsedRealtimeNanos())
+  optional int64 dispatch_time_ns = 4;
+  // If the transition merge was accepted by the transition handler, this
+  // contains the time the transition was merged into transition with id
+  // `merge_target`.
+  // (using SystemClock.elapsedRealtimeNanos())
+  optional int64 merge_time_ns = 5;
+  // The time shell proposed the transition should be merged to the transition
+  // handler into transition with id `merge_target`.
+  // (using SystemClock.elapsedRealtimeNanos()).
+  optional int64 merge_request_time_ns = 6;
+  // If the transition was aborted on the shell side, this is the time that
+  // occured.
+  // (using SystemClock.elapsedRealtimeNanos())
+  optional int64 shell_abort_time_ns = 7;
+  // If the transition was aborted on the wm side, this is the time that
+  // occured.
+  // (using SystemClock.elapsedRealtimeNanos())
+  optional int64 wm_abort_time_ns = 8;
+  // The time WM considers the transition to be complete.
+  // (using SystemClock.elapsedRealtimeNanos())
+  optional int64 finish_time_ns = 9;
+
+  // The id of the transaction that WM proposed to use as the starting
+  // transaction. It contains all the layer changes required to setup the
+  // transition and should be executed right at the start of the transition
+  // by the transition handler.
+  optional uint64 start_transaction_id = 10;
+  // The if of the transaction that WM proposed to use as the finish
+  // transaction. It contains all the layer changes required to set the final
+  // state of the transition.
+  optional uint64 finish_transaction_id = 11;
+
+  // The id of the handler that executed the transition. A HandlerMappings
+  // message in the trace will contain the mapping of id to a string
+  // representation of the handler.
+  optional int32 handler = 12;
+  // The transition type of this transition (e.g. TO_FRONT, OPEN, CLOSE).
+  optional int32 type = 13;
+
+  // The list of targets that are part of this transition.
+  repeated Target targets = 14;
+  // The id of the transition we have requested to merge or have merged this
+  // transition into.
+  optional int32 merge_target = 15;
+
+  // The flags set on this transition.
+  optional int32 flags = 16;
+  // The time the starting window was removed. Tracked because this can
+  // happen after the transition finishes, but the app may not yet be visible
+  // until the starting window is removed. So in a sense the transition is not
+  // finished until the starting window is removed. (b/284302118)
+  // (using SystemClock.elapsedRealtimeNanos())
+  optional int64 starting_window_remove_time_ns = 17;
+
+  // Contains the information about the windows targeted in a transition.
+  message Target {
+    // The transition mode of this target (e.g. TO_FRONT, CLOSE...)
+    optional int32 mode = 1;
+    // The layer id of this target.
+    optional int32 layer_id = 2;
+    // The window id of this target.
+    optional int32 window_id = 3;
+    // The flags set on this target.
+    optional int32 flags = 4;
+  }
+}
+
+// Contains mappings from handler ids to string representation of the handlers.
+message ShellHandlerMappings {
+  repeated ShellHandlerMapping mapping = 1;
+}
+
+message ShellHandlerMapping {
+  // The id of the handler used in the ShellTransition message.
+  optional int32 id = 1;
+  // A human readable and meaningful string representation of the handler.
+  optional string name = 2;
+}
+
+// End of protos/perfetto/trace/android/shell_transition.proto
+
 // Begin of protos/perfetto/trace/android/surfaceflinger_common.proto
 
 message RegionProto {
@@ -13376,6 +13474,8 @@
     // Winscope traces
     LayersSnapshotProto surfaceflinger_layers_snapshot = 93;
     TransactionTraceEntry surfaceflinger_transactions = 94;
+    ShellTransition shell_transition = 96;
+    ShellHandlerMappings shell_handler_mappings = 97;
 
     // Events from the Windows etw infrastructure.
     EtwTraceEventBundle etw_events = 95;
diff --git a/protos/perfetto/trace/trace_packet.proto b/protos/perfetto/trace/trace_packet.proto
index 9966894..86a90fe 100644
--- a/protos/perfetto/trace/trace_packet.proto
+++ b/protos/perfetto/trace/trace_packet.proto
@@ -29,6 +29,7 @@
 import "protos/perfetto/trace/android/initial_display_state.proto";
 import "protos/perfetto/trace/android/network_trace.proto";
 import "protos/perfetto/trace/android/packages_list.proto";
+import "protos/perfetto/trace/android/shell_transition.proto";
 import "protos/perfetto/trace/android/surfaceflinger_layers.proto";
 import "protos/perfetto/trace/android/surfaceflinger_transactions.proto";
 import "protos/perfetto/trace/chrome/chrome_benchmark_metadata.proto";
@@ -216,6 +217,8 @@
     // Winscope traces
     LayersSnapshotProto surfaceflinger_layers_snapshot = 93;
     TransactionTraceEntry surfaceflinger_transactions = 94;
+    ShellTransition shell_transition = 96;
+    ShellHandlerMappings shell_handler_mappings = 97;
 
     // Events from the Windows etw infrastructure.
     EtwTraceEventBundle etw_events = 95;
diff --git a/protos/third_party/chromium/chrome_track_event.proto b/protos/third_party/chromium/chrome_track_event.proto
index 90cde7d..a68feef 100644
--- a/protos/third_party/chromium/chrome_track_event.proto
+++ b/protos/third_party/chromium/chrome_track_event.proto
@@ -1460,7 +1460,7 @@
 
 message StartUp {
   // This enum must be kept up to date with LaunchCauseMetrics.LaunchCause.
-  enum LauchCauseType {
+  enum LaunchCauseType {
     OTHER = 0;
     CUSTOM_TAB = 1;
     TWA = 2;
@@ -1483,7 +1483,8 @@
   }
 
   optional int64 activity_id = 1;
-  optional LauchCauseType launch_cause = 2;
+  // deprecated field 2.
+  optional LaunchCauseType launch_cause = 3;
 }
 
 message WebContentInteraction {
diff --git a/src/trace_processor/importers/common/args_tracker.h b/src/trace_processor/importers/common/args_tracker.h
index 4751fef..2a3a8b5 100644
--- a/src/trace_processor/importers/common/args_tracker.h
+++ b/src/trace_processor/importers/common/args_tracker.h
@@ -139,6 +139,12 @@
         context_->storage->mutable_surfaceflinger_transactions_table(), id);
   }
 
+  BoundInserter AddArgsTo(tables::WindowManagerShellTransitionsTable::Id id) {
+    return AddArgsTo(
+        context_->storage->mutable_window_manager_shell_transitions_table(),
+        id);
+  }
+
   BoundInserter AddArgsTo(MetadataId id) {
     auto* table = context_->storage->mutable_metadata_table();
     uint32_t row = *table->id().IndexOf(id);
diff --git a/src/trace_processor/importers/proto/winscope/BUILD.gn b/src/trace_processor/importers/proto/winscope/BUILD.gn
index 2d48f0c..7b04e85 100644
--- a/src/trace_processor/importers/proto/winscope/BUILD.gn
+++ b/src/trace_processor/importers/proto/winscope/BUILD.gn
@@ -16,30 +16,35 @@
 
 source_set("full") {
   sources = [
+    "shell_transitions_parser.cc",
+    "shell_transitions_parser.h",
+    "shell_transitions_tracker.cc",
+    "shell_transitions_tracker.h",
     "surfaceflinger_layers_parser.cc",
     "surfaceflinger_layers_parser.h",
     "surfaceflinger_transactions_parser.cc",
     "surfaceflinger_transactions_parser.h",
-    "winscope_args_parser.h",
     "winscope_args_parser.cc",
+    "winscope_args_parser.h",
     "winscope_module.cc",
     "winscope_module.h",
   ]
   deps = [
     ":gen_cc_winscope_descriptor",
+    "../:proto_importer_module",
     "../../../../../gn:default_deps",
-    "../../../../../protos/perfetto/trace/android:zero",
     "../../../../../protos/perfetto/trace:zero",
+    "../../../../../protos/perfetto/trace/android:zero",
     "../../../storage",
     "../../../tables",
     "../../../types",
     "../../common",
     "../../common:parser_types",
-    "../:proto_importer_module",
   ]
 }
 
 perfetto_cc_proto_descriptor("gen_cc_winscope_descriptor") {
   descriptor_name = "winscope.descriptor"
-  descriptor_target = "../../../../../protos/perfetto/trace/android:winscope_descriptor"
+  descriptor_target =
+      "../../../../../protos/perfetto/trace/android:winscope_descriptor"
 }
diff --git a/src/trace_processor/importers/proto/winscope/shell_transitions_parser.cc b/src/trace_processor/importers/proto/winscope/shell_transitions_parser.cc
new file mode 100644
index 0000000..68d2a82
--- /dev/null
+++ b/src/trace_processor/importers/proto/winscope/shell_transitions_parser.cc
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "src/trace_processor/importers/proto/winscope/shell_transitions_parser.h"
+#include "src/trace_processor/importers/proto/winscope/shell_transitions_tracker.h"
+
+#include "protos/perfetto/trace/android/shell_transition.pbzero.h"
+#include "src/trace_processor/importers/common/args_tracker.h"
+#include "src/trace_processor/importers/proto/winscope/winscope.descriptor.h"
+#include "src/trace_processor/importers/proto/winscope/winscope_args_parser.h"
+#include "src/trace_processor/storage/trace_storage.h"
+#include "src/trace_processor/types/trace_processor_context.h"
+
+namespace perfetto {
+namespace trace_processor {
+
+ShellTransitionsParser::ShellTransitionsParser(TraceProcessorContext* context)
+    : context_(context), args_parser_{pool_} {
+  pool_.AddFromFileDescriptorSet(kWinscopeDescriptor.data(),
+                                 kWinscopeDescriptor.size());
+}
+
+void ShellTransitionsParser::ParseTransition(protozero::ConstBytes blob) {
+  protos::pbzero::ShellTransition::Decoder transition(blob);
+
+  auto row_id =
+      ShellTransitionsTracker::GetOrCreate(context_)->InternTransition(
+          transition.id());
+
+  auto* window_manager_shell_transitions_table =
+      context_->storage->mutable_window_manager_shell_transitions_table();
+  auto row = window_manager_shell_transitions_table->FindById(row_id).value();
+
+  if (transition.has_dispatch_time_ns()) {
+    row.set_ts(transition.dispatch_time_ns());
+  }
+
+  auto inserter = context_->args_tracker->AddArgsTo(row_id);
+  WinscopeArgsParser writer(inserter, *context_->storage.get());
+  base::Status status = args_parser_.ParseMessage(
+      blob, kShellTransitionsProtoName, nullptr /* parse all fields */, writer);
+
+  if (!status.ok()) {
+    context_->storage->IncrementStats(
+        stats::winscope_shell_transitions_parse_errors);
+  }
+}
+
+void ShellTransitionsParser::ParseHandlerMappings(protozero::ConstBytes blob) {
+  auto* shell_handlers_table =
+      context_->storage
+          ->mutable_window_manager_shell_transition_handlers_table();
+
+  protos::pbzero::ShellHandlerMappings::Decoder handler_mappings(blob);
+  for (auto it = handler_mappings.mapping(); it; ++it) {
+    protos::pbzero::ShellHandlerMapping::Decoder mapping(it.field().as_bytes());
+
+    tables::WindowManagerShellTransitionHandlersTable::Row row;
+    row.handler_id = mapping.id();
+    row.handler_name = context_->storage->InternString(
+        base::StringView(mapping.name().ToStdString()));
+    shell_handlers_table->Insert(row);
+  }
+}
+
+}  // namespace trace_processor
+}  // namespace perfetto
diff --git a/src/trace_processor/importers/proto/winscope/shell_transitions_parser.h b/src/trace_processor/importers/proto/winscope/shell_transitions_parser.h
new file mode 100644
index 0000000..44b86d1
--- /dev/null
+++ b/src/trace_processor/importers/proto/winscope/shell_transitions_parser.h
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SRC_TRACE_PROCESSOR_IMPORTERS_PROTO_WINSCOPE_SHELL_TRANSITIONS_PARSER_H_
+#define SRC_TRACE_PROCESSOR_IMPORTERS_PROTO_WINSCOPE_SHELL_TRANSITIONS_PARSER_H_
+
+#include "src/trace_processor/util/descriptors.h"
+#include "src/trace_processor/util/proto_to_args_parser.h"
+
+namespace perfetto {
+
+namespace trace_processor {
+
+class TraceProcessorContext;
+
+class ShellTransitionsParser {
+ public:
+  explicit ShellTransitionsParser(TraceProcessorContext*);
+  void ParseTransition(protozero::ConstBytes);
+  void ParseHandlerMappings(protozero::ConstBytes);
+
+ private:
+  static constexpr auto* kShellTransitionsProtoName =
+      ".perfetto.protos.ShellTransition";
+
+  TraceProcessorContext* const context_;
+  DescriptorPool pool_;
+  util::ProtoToArgsParser args_parser_;
+};
+}  // namespace trace_processor
+}  // namespace perfetto
+
+#endif  // SRC_TRACE_PROCESSOR_IMPORTERS_PROTO_WINSCOPE_SHELL_TRANSITIONS_PARSER_H_
diff --git a/src/trace_processor/importers/proto/winscope/shell_transitions_tracker.cc b/src/trace_processor/importers/proto/winscope/shell_transitions_tracker.cc
new file mode 100644
index 0000000..6025d91
--- /dev/null
+++ b/src/trace_processor/importers/proto/winscope/shell_transitions_tracker.cc
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "shell_transitions_tracker.h"
+#include "perfetto/ext/base/crash_keys.h"
+#include "src/trace_processor/importers/common/process_tracker.h"
+#include "src/trace_processor/storage/metadata.h"
+#include "src/trace_processor/types/trace_processor_context.h"
+
+namespace perfetto {
+namespace trace_processor {
+ShellTransitionsTracker::ShellTransitionsTracker(TraceProcessorContext* context)
+    : context_(context) {}
+
+ShellTransitionsTracker::~ShellTransitionsTracker() = default;
+
+tables::WindowManagerShellTransitionsTable::Id
+ShellTransitionsTracker::InternTransition(int32_t transition_id) {
+  auto pos = transition_id_to_row_mapping_.find(transition_id);
+  if (pos != transition_id_to_row_mapping_.end()) {
+    return pos->second;
+  }
+
+  auto* window_manager_shell_transitions_table =
+      context_->storage->mutable_window_manager_shell_transitions_table();
+
+  tables::WindowManagerShellTransitionsTable::Row row;
+  row.transition_id = transition_id;
+  auto row_id = window_manager_shell_transitions_table->Insert(row).id;
+
+  transition_id_to_row_mapping_.insert({transition_id, row_id});
+
+  return row_id;
+}
+}  // namespace trace_processor
+}  // namespace perfetto
diff --git a/src/trace_processor/importers/proto/winscope/shell_transitions_tracker.h b/src/trace_processor/importers/proto/winscope/shell_transitions_tracker.h
new file mode 100644
index 0000000..07ef736
--- /dev/null
+++ b/src/trace_processor/importers/proto/winscope/shell_transitions_tracker.h
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef SRC_TRACE_PROCESSOR_IMPORTERS_PROTO_WINSCOPE_SHELL_TRANSITIONS_TRACKER_H_
+#define SRC_TRACE_PROCESSOR_IMPORTERS_PROTO_WINSCOPE_SHELL_TRANSITIONS_TRACKER_H_
+
+#include "perfetto/trace_processor/basic_types.h"
+#include "src/trace_processor/storage/trace_storage.h"
+#include "src/trace_processor/types/trace_processor_context.h"
+
+namespace perfetto {
+namespace trace_processor {
+
+// Tracks information in the transition table.
+class ShellTransitionsTracker : public Destructible {
+ public:
+  explicit ShellTransitionsTracker(TraceProcessorContext*);
+  virtual ~ShellTransitionsTracker() override;
+
+  static ShellTransitionsTracker* GetOrCreate(TraceProcessorContext* context) {
+    if (!context->shell_transitions_tracker) {
+      context->shell_transitions_tracker.reset(
+          new ShellTransitionsTracker(context));
+    }
+    return static_cast<ShellTransitionsTracker*>(
+        context->shell_transitions_tracker.get());
+  }
+
+  tables::WindowManagerShellTransitionsTable::Id InternTransition(
+      int32_t transition_id);
+
+ private:
+  TraceProcessorContext* context_;
+  std::unordered_map<int32_t, tables::WindowManagerShellTransitionsTable::Id>
+      transition_id_to_row_mapping_;
+};
+
+}  // namespace trace_processor
+}  // namespace perfetto
+
+#endif  // SRC_TRACE_PROCESSOR_IMPORTERS_PROTO_WINSCOPE_SHELL_TRANSITIONS_TRACKER_H_
diff --git a/src/trace_processor/importers/proto/winscope/winscope_module.cc b/src/trace_processor/importers/proto/winscope/winscope_module.cc
index de9e224..7f6c154 100644
--- a/src/trace_processor/importers/proto/winscope/winscope_module.cc
+++ b/src/trace_processor/importers/proto/winscope/winscope_module.cc
@@ -23,11 +23,14 @@
 
 WinscopeModule::WinscopeModule(TraceProcessorContext* context)
     : surfaceflinger_layers_parser_(context),
-      surfaceflinger_transactions_parser_(context) {
+      surfaceflinger_transactions_parser_(context),
+      shell_transitions_parser_(context) {
   RegisterForField(TracePacket::kSurfaceflingerLayersSnapshotFieldNumber,
                    context);
   RegisterForField(TracePacket::kSurfaceflingerTransactionsFieldNumber,
                    context);
+  RegisterForField(TracePacket::kShellTransitionFieldNumber, context);
+  RegisterForField(TracePacket::kShellHandlerMappingsFieldNumber, context);
 }
 
 void WinscopeModule::ParseTracePacketData(const TracePacket::Decoder& decoder,
@@ -43,6 +46,13 @@
       surfaceflinger_transactions_parser_.Parse(
           timestamp, decoder.surfaceflinger_transactions());
       return;
+    case TracePacket::kShellTransitionFieldNumber:
+      shell_transitions_parser_.ParseTransition(decoder.shell_transition());
+      return;
+    case TracePacket::kShellHandlerMappingsFieldNumber:
+      shell_transitions_parser_.ParseHandlerMappings(
+          decoder.shell_handler_mappings());
+      return;
   }
 }
 
diff --git a/src/trace_processor/importers/proto/winscope/winscope_module.h b/src/trace_processor/importers/proto/winscope/winscope_module.h
index d9428d0..fffe42c 100644
--- a/src/trace_processor/importers/proto/winscope/winscope_module.h
+++ b/src/trace_processor/importers/proto/winscope/winscope_module.h
@@ -21,6 +21,7 @@
 #include "perfetto/base/build_config.h"
 #include "src/trace_processor/importers/common/parser_types.h"
 #include "src/trace_processor/importers/proto/proto_importer_module.h"
+#include "src/trace_processor/importers/proto/winscope/shell_transitions_parser.h"
 #include "src/trace_processor/importers/proto/winscope/surfaceflinger_layers_parser.h"
 #include "src/trace_processor/importers/proto/winscope/surfaceflinger_transactions_parser.h"
 
@@ -41,6 +42,7 @@
  private:
   SurfaceFlingerLayersParser surfaceflinger_layers_parser_;
   SurfaceFlingerTransactionsParser surfaceflinger_transactions_parser_;
+  ShellTransitionsParser shell_transitions_parser_;
 };
 
 }  // namespace trace_processor
diff --git a/src/trace_processor/storage/stats.h b/src/trace_processor/storage/stats.h
index d583632..1e40489 100644
--- a/src/trace_processor/storage/stats.h
+++ b/src/trace_processor/storage/stats.h
@@ -262,6 +262,11 @@
                                           kSingle,  kInfo,     kAnalysis,      \
       "SurfaceFlinger transactions packet has unknown fields, which results "  \
       "in some arguments missing. You may need a newer version of trace "      \
+      "processor to parse them."),                                             \
+  F(winscope_shell_transitions_parse_errors,                                   \
+                                          kSingle,  kInfo,     kAnalysis,      \
+      "Shell transition packet has unknown fields, which results "  \
+      "in some arguments missing. You may need a newer version of trace "      \
       "processor to parse them.")
 // clang-format on
 
diff --git a/src/trace_processor/storage/trace_storage.h b/src/trace_processor/storage/trace_storage.h
index e75233e..79463f0 100644
--- a/src/trace_processor/storage/trace_storage.h
+++ b/src/trace_processor/storage/trace_storage.h
@@ -738,6 +738,24 @@
     return &surfaceflinger_transactions_table_;
   }
 
+  const tables::WindowManagerShellTransitionsTable&
+  window_manager_shell_transitions_table() const {
+    return window_manager_shell_transitions_table_;
+  }
+  tables::WindowManagerShellTransitionsTable*
+  mutable_window_manager_shell_transitions_table() {
+    return &window_manager_shell_transitions_table_;
+  }
+
+  const tables::WindowManagerShellTransitionHandlersTable&
+  window_manager_shell_transition_handlers_table() const {
+    return window_manager_shell_transition_handlers_table_;
+  }
+  tables::WindowManagerShellTransitionHandlersTable*
+  mutable_window_manager_shell_transition_handlers_table() {
+    return &window_manager_shell_transition_handlers_table_;
+  }
+
   const tables::ExperimentalProtoPathTable& experimental_proto_path_table()
       const {
     return experimental_proto_path_table_;
@@ -995,6 +1013,10 @@
   tables::SurfaceFlingerLayerTable surfaceflinger_layer_table_{&string_pool_};
   tables::SurfaceFlingerTransactionsTable surfaceflinger_transactions_table_{
       &string_pool_};
+  tables::WindowManagerShellTransitionsTable
+      window_manager_shell_transitions_table_{&string_pool_};
+  tables::WindowManagerShellTransitionHandlersTable
+      window_manager_shell_transition_handlers_table_{&string_pool_};
 
   tables::ExperimentalProtoPathTable experimental_proto_path_table_{
       &string_pool_};
diff --git a/src/trace_processor/tables/table_destructors.cc b/src/trace_processor/tables/table_destructors.cc
index 16981fa..989d5e2 100644
--- a/src/trace_processor/tables/table_destructors.cc
+++ b/src/trace_processor/tables/table_destructors.cc
@@ -120,6 +120,10 @@
     default;
 SurfaceFlingerLayerTable::~SurfaceFlingerLayerTable() = default;
 SurfaceFlingerTransactionsTable::~SurfaceFlingerTransactionsTable() = default;
+WindowManagerShellTransitionsTable::~WindowManagerShellTransitionsTable() =
+    default;
+WindowManagerShellTransitionHandlersTable::
+    ~WindowManagerShellTransitionHandlersTable() = default;
 
 }  // namespace tables
 
diff --git a/src/trace_processor/tables/winscope_tables.py b/src/trace_processor/tables/winscope_tables.py
index 22f2080..12e7cef 100644
--- a/src/trace_processor/tables/winscope_tables.py
+++ b/src/trace_processor/tables/winscope_tables.py
@@ -18,6 +18,7 @@
 from python.generators.trace_processor_table.public import CppTableId
 from python.generators.trace_processor_table.public import TableDoc
 from python.generators.trace_processor_table.public import CppUint32
+from python.generators.trace_processor_table.public import CppString
 
 SURFACE_FLINGER_LAYERS_SNAPSHOT_TABLE = Table(
     python_module=__file__,
@@ -60,16 +61,53 @@
         C('arg_set_id', CppUint32()),
     ],
     tabledoc=TableDoc(
-        doc='SurfaceFlinger transactions. Each row contains a set of transactions that SurfaceFlinger committed together.',
+        doc='SurfaceFlinger transactions. Each row contains a set of ' +
+        'transactions that SurfaceFlinger committed together.',
         group='Winscope',
         columns={
             'ts': 'Timestamp of the transactions commit',
             'arg_set_id': 'Extra args parsed from the proto message',
         }))
 
+WINDOW_MANAGER_SHELL_TRANSITIONS_TABLE = Table(
+    python_module=__file__,
+    class_name='WindowManagerShellTransitionsTable',
+    sql_name='window_manager_shell_transitions',
+    columns=[
+        C('ts', CppInt64()),
+        C('transition_id', CppInt64()),
+        C('arg_set_id', CppUint32()),
+    ],
+    tabledoc=TableDoc(
+        doc='Window Manager Shell Transitions',
+        group='Winscope',
+        columns={
+            'ts': 'The timestamp the transition started playing',
+            'transition_id': 'The id of the transition',
+            'arg_set_id': 'Extra args parsed from the proto message',
+        }))
+
+WINDOW_MANAGER_SHELL_TRANSITION_HANDLERS_TABLE = Table(
+    python_module=__file__,
+    class_name='WindowManagerShellTransitionHandlersTable',
+    sql_name='window_manager_shell_transition_handlers',
+    columns=[
+        C('handler_id', CppInt64()),
+        C('handler_name', CppString()),
+    ],
+    tabledoc=TableDoc(
+        doc='Window Manager Shell Transition Handlers',
+        group='Winscope',
+        columns={
+            'handler_id': 'The id of the handler',
+            'handler_name': 'The name of the handler',
+        }))
+
 # Keep this list sorted.
 ALL_TABLES = [
     SURFACE_FLINGER_LAYERS_SNAPSHOT_TABLE,
     SURFACE_FLINGER_LAYER_TABLE,
     SURFACE_FLINGER_TRANSACTIONS_TABLE,
+    WINDOW_MANAGER_SHELL_TRANSITIONS_TABLE,
+    WINDOW_MANAGER_SHELL_TRANSITION_HANDLERS_TABLE,
 ]
diff --git a/src/trace_processor/trace_processor_impl.cc b/src/trace_processor/trace_processor_impl.cc
index 976357c..db65dae 100644
--- a/src/trace_processor/trace_processor_impl.cc
+++ b/src/trace_processor/trace_processor_impl.cc
@@ -854,6 +854,10 @@
   RegisterStaticTable(storage->surfaceflinger_layer_table());
   RegisterStaticTable(storage->surfaceflinger_transactions_table());
 
+  RegisterStaticTable(storage->window_manager_shell_transitions_table());
+  RegisterStaticTable(
+      storage->window_manager_shell_transition_handlers_table());
+
   RegisterStaticTable(storage->metadata_table());
   RegisterStaticTable(storage->cpu_table());
   RegisterStaticTable(storage->cpu_freq_table());
diff --git a/src/trace_processor/types/trace_processor_context.h b/src/trace_processor/types/trace_processor_context.h
index c2644ee..1eb16e4 100644
--- a/src/trace_processor/types/trace_processor_context.h
+++ b/src/trace_processor/types/trace_processor_context.h
@@ -123,6 +123,7 @@
   std::unique_ptr<Destructible> i2c_tracker;             // I2CTracker
   std::unique_ptr<Destructible> perf_data_tracker;       // PerfDataTracker
   std::unique_ptr<Destructible> content_analyzer;
+  std::unique_ptr<Destructible> shell_transitions_tracker;
 
   // These fields are trace readers which will be called by |forwarding_parser|
   // once the format of the trace is discovered. They are placed here as they
diff --git a/test/trace_processor/diff_tests/include_index.py b/test/trace_processor/diff_tests/include_index.py
index 0aea755..e585f47 100644
--- a/test/trace_processor/diff_tests/include_index.py
+++ b/test/trace_processor/diff_tests/include_index.py
@@ -51,6 +51,7 @@
 from diff_tests.parser.android.tests_games import AndroidGames
 from diff_tests.parser.android.tests_surfaceflinger_layers import SurfaceFlingerLayers
 from diff_tests.parser.android.tests_surfaceflinger_transactions import SurfaceFlingerTransactions
+from diff_tests.parser.android.tests_shell_transitions import ShellTransitions
 from diff_tests.parser.android_fs.tests import AndroidFs
 from diff_tests.parser.atrace.tests import Atrace
 from diff_tests.parser.atrace.tests_error_handling import AtraceErrorHandling
@@ -171,6 +172,8 @@
                             'SurfaceFlingerLayers').fetch(),
       *SurfaceFlingerTransactions(index_path, 'parser/android',
                                   'SurfaceFlingerTransactions').fetch(),
+      *ShellTransitions(index_path, 'parser/android',
+                        'ShellTransitions').fetch(),
       *TrackEvent(index_path, 'parser/track_event', 'TrackEvent').fetch(),
       *TranslatedArgs(index_path, 'parser/translated_args',
                       'TranslatedArgs').fetch(),
diff --git a/test/trace_processor/diff_tests/parser/android/shell_handlers.textproto b/test/trace_processor/diff_tests/parser/android/shell_handlers.textproto
new file mode 100644
index 0000000..9b0182a
--- /dev/null
+++ b/test/trace_processor/diff_tests/parser/android/shell_handlers.textproto
@@ -0,0 +1,21 @@
+packet {
+  trusted_uid: 10225
+  trusted_packet_sequence_id: 12
+  previous_packet_dropped: true
+  trusted_pid: 3981
+  first_packet_on_sequence: true
+  shell_handler_mappings {
+    mapping {
+        id: 1
+        name: "DefaultTransitionHandler"
+    }
+    mapping {
+        id: 2
+        name: "RecentsTransitionHandler"
+    }
+    mapping {
+        id: 3
+        name: "FreeformTaskTransitionHandler"
+    }
+  }
+}
diff --git a/test/trace_processor/diff_tests/parser/android/shell_transitions.textproto b/test/trace_processor/diff_tests/parser/android/shell_transitions.textproto
new file mode 100644
index 0000000..b92eb39
--- /dev/null
+++ b/test/trace_processor/diff_tests/parser/android/shell_transitions.textproto
@@ -0,0 +1,167 @@
+packet {
+  trusted_uid: 1000
+  trusted_packet_sequence_id: 2
+  previous_packet_dropped: true
+  trusted_pid: 1305
+  first_packet_on_sequence: true
+  shell_transition {
+    id: 7
+    create_time_ns: 76799049027
+    send_time_ns: 76875395422
+    start_transaction_id: 5604932321952
+    finish_transaction_id: 5604932321954
+  }
+}
+packet {
+  trusted_uid: 1000
+  trusted_packet_sequence_id: 2
+  trusted_pid: 1305
+  shell_transition {
+    id: 10
+    create_time_ns: 77854865352
+    send_time_ns: 77894307328
+    start_transaction_id: 5604932322158
+    finish_transaction_id: 5604932322159
+  }
+}
+packet {
+  trusted_uid: 1000
+  trusted_packet_sequence_id: 2
+  trusted_pid: 1305
+  shell_transition {
+    id: 11
+    create_time_ns: 82498121051
+    send_time_ns: 82535513345
+    start_transaction_id: 5604932322346
+    finish_transaction_id: 5604932322347
+  }
+}
+packet {
+  trusted_uid: 1000
+  trusted_packet_sequence_id: 3
+  previous_packet_dropped: true
+  trusted_pid: 1305
+  first_packet_on_sequence: true
+  shell_transition {
+    id: 8
+    create_time_ns: 76955664017
+    send_time_ns: 77277756832
+    start_transaction_id: 5604932322028
+    finish_transaction_id: 5604932322029
+  }
+}
+packet {
+  trusted_uid: 1000
+  trusted_packet_sequence_id: 4
+  previous_packet_dropped: true
+  trusted_pid: 1305
+  first_packet_on_sequence: true
+  shell_transition {
+    id: 7
+    starting_window_remove_time_ns: 77706603918
+  }
+}
+packet {
+  trusted_uid: 1000
+  trusted_packet_sequence_id: 5
+  previous_packet_dropped: true
+  trusted_pid: 1305
+  first_packet_on_sequence: true
+  shell_transition {
+    id: 9
+    create_time_ns: 77825423417
+    send_time_ns: 77843436723
+    start_transaction_id: 5604932322137
+    finish_transaction_id: 5604932322138
+  }
+}
+packet {
+  trusted_uid: 1000
+  trusted_packet_sequence_id: 5
+  trusted_pid: 1305
+  shell_transition {
+    id: 9
+    finish_time_ns: 77873935462
+  }
+}
+packet {
+  trusted_uid: 1000
+  trusted_packet_sequence_id: 5
+  trusted_pid: 1305
+  shell_transition {
+    id: 10
+    finish_time_ns: 78621610429
+  }
+}
+packet {
+  trusted_uid: 10241
+  trusted_packet_sequence_id: 6
+  previous_packet_dropped: true
+  trusted_pid: 2528
+  first_packet_on_sequence: true
+  shell_transition {
+    id: 7
+    dispatch_time_ns: 76879063147
+    handler: 2
+  }
+}
+packet {
+  trusted_uid: 10241
+  trusted_packet_sequence_id: 6
+  trusted_pid: 2528
+  shell_transition {
+    id: 8
+    merge_time_ns: 77278725500
+    merge_target: 7
+  }
+}
+packet {
+  trusted_uid: 10241
+  trusted_packet_sequence_id: 6
+  trusted_pid: 2528
+  shell_transition {
+    id: 8
+    dispatch_time_ns: 77320527177
+    handler: 3
+  }
+}
+packet {
+  trusted_uid: 10241
+  trusted_packet_sequence_id: 6
+  trusted_pid: 2528
+  shell_transition {
+    id: 9
+    dispatch_time_ns: 77876414832
+    handler: 3
+  }
+}
+packet {
+  trusted_uid: 10241
+  trusted_packet_sequence_id: 6
+  trusted_pid: 2528
+  shell_transition {
+    id: 10
+    dispatch_time_ns: 77899001013
+    handler: 4
+  }
+}
+packet {
+  trusted_uid: 10241
+  trusted_packet_sequence_id: 6
+  trusted_pid: 2528
+  shell_transition {
+    id: 11
+    dispatch_time_ns: 82536817137
+    handler: 2
+  }
+}
+packet {
+  trusted_uid: 10241
+  trusted_packet_sequence_id: 6
+  trusted_pid: 2528
+  shell_transition {
+    id: 12
+    merge_time_ns: 82697060749
+    merge_target: 11
+  }
+}
diff --git a/test/trace_processor/diff_tests/parser/android/shell_transitions_simple_merge.textproto b/test/trace_processor/diff_tests/parser/android/shell_transitions_simple_merge.textproto
new file mode 100644
index 0000000..6c9cb65
--- /dev/null
+++ b/test/trace_processor/diff_tests/parser/android/shell_transitions_simple_merge.textproto
@@ -0,0 +1,62 @@
+packet {
+  trusted_uid: 1000
+  trusted_packet_sequence_id: 2
+  previous_packet_dropped: true
+  trusted_pid: 1336
+  first_packet_on_sequence: true
+  shell_transition {
+    id: 15
+    create_time_ns: 2187614568227
+    send_time_ns: 2187671767120
+    start_transaction_id: 5738076308937
+    finish_transaction_id: 5738076308938
+    type: 1
+    targets {
+      mode: 1
+      layer_id: 244
+      window_id: 219481253
+      flags: 0
+    }
+    targets {
+      mode: 4
+      layer_id: 47
+      window_id: 54474511
+      flags: 1
+    }
+    flags: 0
+  }
+}
+packet {
+  trusted_uid: 1000
+  trusted_packet_sequence_id: 3
+  previous_packet_dropped: true
+  trusted_pid: 1336
+  first_packet_on_sequence: true
+  shell_transition {
+    id: 15
+    finish_time_ns: 2188195968659
+  }
+}
+packet {
+  trusted_uid: 1000
+  trusted_packet_sequence_id: 5
+  previous_packet_dropped: true
+  trusted_pid: 1336
+  first_packet_on_sequence: true
+  shell_transition {
+    id: 15
+    starting_window_remove_time_ns: 2188652838898
+  }
+}
+packet {
+  trusted_uid: 10225
+  trusted_packet_sequence_id: 12
+  previous_packet_dropped: true
+  trusted_pid: 3981
+  first_packet_on_sequence: true
+  shell_transition {
+    id: 15
+    dispatch_time_ns: 2187673373973
+    handler: 2
+  }
+}
diff --git a/test/trace_processor/diff_tests/parser/android/tests_shell_transitions.py b/test/trace_processor/diff_tests/parser/android/tests_shell_transitions.py
new file mode 100644
index 0000000..a8e0328
--- /dev/null
+++ b/test/trace_processor/diff_tests/parser/android/tests_shell_transitions.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+# Copyright (C) 2023 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License a
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from python.generators.diff_tests.testing import Path
+from python.generators.diff_tests.testing import Csv
+from python.generators.diff_tests.testing import DiffTestBlueprint
+from python.generators.diff_tests.testing import TestSuite
+
+
+class ShellTransitions(TestSuite):
+
+  def test_has_expected_transition_rows(self):
+    return DiffTestBlueprint(
+        trace=Path('shell_transitions.textproto'),
+        query="""
+        SELECT
+          id, transition_id
+        FROM
+          window_manager_shell_transitions;
+        """,
+        out=Csv("""
+        "id","transition_id"
+        0,7
+        1,10
+        2,11
+        3,8
+        4,9
+        5,12
+        """))
+
+  def test_has_expected_transition_args(self):
+    return DiffTestBlueprint(
+        trace=Path('shell_transitions_simple_merge.textproto'),
+        query="""
+        SELECT
+          args.key, args.display_value
+        FROM
+          window_manager_shell_transitions JOIN args ON window_manager_shell_transitions.arg_set_id = args.arg_set_id
+        WHERE window_manager_shell_transitions.transition_id = 15
+        ORDER BY args.key;
+        """,
+        out=Csv("""
+        "key","display_value"
+        "create_time_ns","2187614568227"
+        "dispatch_time_ns","2187673373973"
+        "finish_time_ns","2188195968659"
+        "finish_transaction_id","5738076308938"
+        "flags","0"
+        "handler","2"
+        "id","15"
+        "send_time_ns","2187671767120"
+        "start_transaction_id","5738076308937"
+        "starting_window_remove_time_ns","2188652838898"
+        "targets[0].flags","0"
+        "targets[0].layer_id","244"
+        "targets[0].mode","1"
+        "targets[0].window_id","219481253"
+        "targets[1].flags","1"
+        "targets[1].layer_id","47"
+        "targets[1].mode","4"
+        "targets[1].window_id","54474511"
+        "type","1"
+        """))
+
+  def test_has_shell_handlers(self):
+    return DiffTestBlueprint(
+        trace=Path('shell_handlers.textproto'),
+        query="""
+      SELECT
+        handler_id, handler_name
+      FROM
+        window_manager_shell_transition_handlers;
+      """,
+        out=Csv("""
+      "handler_id","handler_name"
+      1,"DefaultTransitionHandler"
+      2,"RecentsTransitionHandler"
+      3,"FreeformTaskTransitionHandler"
+      """))
diff --git a/tools/gen_android_bp b/tools/gen_android_bp
index 11f6d16..6d00144 100755
--- a/tools/gen_android_bp
+++ b/tools/gen_android_bp
@@ -335,7 +335,7 @@
 
 def enable_sqlite(module):
   if module.type == 'cc_binary_host':
-    module.static_libs.add('libsqlite')
+    module.static_libs.add('libsqlite_static_noicu')
     module.static_libs.add('sqlite_ext_percentile')
   elif module.host_supported:
     # Copy what the sqlite3 command line tool does.
@@ -344,7 +344,7 @@
     module.android.shared_libs.add('liblog')
     module.android.shared_libs.add('libutils')
     module.android.static_libs.add('sqlite_ext_percentile')
-    module.host.static_libs.add('libsqlite')
+    module.host.static_libs.add('libsqlite_static_noicu')
     module.host.static_libs.add('sqlite_ext_percentile')
   else:
     module.shared_libs.add('libsqlite')
diff --git a/ui/release/channels.json b/ui/release/channels.json
index 91a0c0e..8ee9b76 100644
--- a/ui/release/channels.json
+++ b/ui/release/channels.json
@@ -6,7 +6,7 @@
     },
     {
       "name": "canary",
-      "rev": "c69b33b9abcc20fad9ad5f39de883216e4b43130"
+      "rev": "9ca89e30931314dec4af1131d516e07e39d8657d"
     },
     {
       "name": "autopush",
diff --git a/ui/src/common/basic_async_track.ts b/ui/src/common/basic_async_track.ts
index 41dfe3d..d5e0dda 100644
--- a/ui/src/common/basic_async_track.ts
+++ b/ui/src/common/basic_async_track.ts
@@ -18,8 +18,7 @@
 import {raf} from '../core/raf_scheduler';
 import {globals} from '../frontend/globals';
 import {PxSpan, TimeScale} from '../frontend/time_scale';
-import {SliceRect} from '../frontend/track';
-import {Track, TrackContext} from '../public';
+import {SliceRect, Track, TrackContext} from '../public';
 
 import {TrackData} from './track_data';
 
diff --git a/ui/src/common/color.ts b/ui/src/common/color.ts
new file mode 100644
index 0000000..127bfbb
--- /dev/null
+++ b/ui/src/common/color.ts
@@ -0,0 +1,290 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {hsluvToRgb} from 'hsluv';
+
+import {clamp} from '../base/math_utils';
+
+// This file contains a library for working with colors in various color spaces
+// and formats.
+
+const LIGHTNESS_MIN = 0;
+const LIGHTNESS_MAX = 100;
+
+const SATURATION_MIN = 0;
+const SATURATION_MAX = 100;
+
+// Most color formats can be defined using 3 numbers in a standardized order, so
+// this tuple serves as a compact way to store various color formats.
+// E.g. HSL, RGB
+type ColorTuple = [number, number, number];
+
+// Definition of an HSL color with named fields.
+interface HSL {
+  readonly h: number;  // 0-360
+  readonly s: number;  // 0-100
+  readonly l: number;  // 0-100
+}
+
+// Defines an interface to an immutable color object, which can be defined in
+// any arbitrary format or color space and provides function to modify the color
+// and conversions to CSS compatible style strings.
+// Because this color object is effectively immutable, a new color object is
+// returned when modifying the color, rather than editing the current object
+// in-place.
+// Also, because these objects are immutable, it's expected that readonly
+// properties such as |cssString| are efficient, as they can be computed at
+// creation time, so they may be used in the hot path (render loop).
+export interface Color {
+  readonly cssString: string;
+
+  // The perceived brightness of the color using a weighted average of the
+  // r, g and b channels based on human perception.
+  readonly perceivedBrightness: number;
+
+  // Bring up the lightness by |percent| percent.
+  lighten(percent: number, max?: number): Color;
+
+  // Bring down the lightness by |percent| percent.
+  darken(percent: number, min?: number): Color;
+
+  // Bring up the saturation by |percent| percent.
+  saturate(percent: number, max?: number): Color;
+
+  // Bring down the saturation by |percent| percent.
+  desaturate(percent: number, min?: number): Color;
+
+  // Set one or more HSL values.
+  setHSL(hsl: Partial<HSL>): Color;
+
+  setAlpha(alpha: number|undefined): Color;
+}
+
+// Common base class for HSL colors. Avoids code duplication.
+abstract class HSLColorBase<T extends Color> {
+  readonly hsl: ColorTuple;
+  readonly alpha?: number;
+
+  // Values are in the range:
+  // Hue:        0-360
+  // Saturation: 0-100
+  // Lightness:  0-100
+  // Alpha:      0-1
+  constructor(init: ColorTuple|HSL|string, alpha?: number) {
+    if (Array.isArray(init)) {
+      this.hsl = init;
+    } else if (typeof init === 'string') {
+      const rgb = hexToRgb(init);
+      this.hsl = rgbToHsl(rgb);
+    } else {
+      this.hsl = [init.h, init.s, init.l];
+    }
+    this.alpha = alpha;
+  }
+
+  // Subclasses should implement this to teach the base class how to create a
+  // new object of the subclass type.
+  abstract create(hsl: ColorTuple|HSL, alpha?: number): T;
+
+  lighten(amount: number, max = LIGHTNESS_MAX): T {
+    const [h, s, l] = this.hsl;
+    const newLightness = clamp(l + amount, LIGHTNESS_MIN, max);
+    return this.create([h, s, newLightness], this.alpha);
+  }
+
+  darken(amount: number, min = LIGHTNESS_MIN): T {
+    const [h, s, l] = this.hsl;
+    const newLightness = clamp(l - amount, min, LIGHTNESS_MAX);
+    return this.create([h, s, newLightness], this.alpha);
+  }
+
+  saturate(amount: number, max = SATURATION_MAX): T {
+    const [h, s, l] = this.hsl;
+    const newSaturation = clamp(s + amount, SATURATION_MIN, max);
+    return this.create([h, newSaturation, l], this.alpha);
+  }
+
+  desaturate(amount: number, min = SATURATION_MIN): T {
+    const [h, s, l] = this.hsl;
+    const newSaturation = clamp(s - amount, min, SATURATION_MAX);
+    return this.create([h, newSaturation, l], this.alpha);
+  }
+
+  setHSL(hsl: Partial<HSL>): T {
+    const [h, s, l] = this.hsl;
+    return this.create({h, s, l, ...hsl}, this.alpha);
+  }
+
+  setAlpha(alpha: number|undefined): T {
+    return this.create(this.hsl, alpha);
+  }
+}
+
+// Describes a color defined in standard HSL color space.
+export class HSLColor extends HSLColorBase<HSLColor> implements Color {
+  readonly cssString: string;
+  readonly perceivedBrightness: number;
+
+  // Values are in the range:
+  // Hue:        0-360
+  // Saturation: 0-100
+  // Lightness:  0-100
+  // Alpha:      0-1
+  constructor(hsl: ColorTuple|HSL|string, alpha?: number) {
+    super(hsl, alpha);
+
+    const [r, g, b] = hslToRgb(...this.hsl);
+
+    this.perceivedBrightness = perceivedBrightness(r, g, b);
+
+    if (this.alpha === undefined) {
+      this.cssString = `rgb(${r} ${g} ${b})`;
+    } else {
+      this.cssString = `rgb(${r} ${g} ${b} / ${this.alpha})`;
+    }
+  }
+
+  create(values: ColorTuple|HSL, alpha?: number|undefined): HSLColor {
+    return new HSLColor(values, alpha);
+  }
+}
+
+// Describes a color defined in HSLuv color space.
+// See: https://www.hsluv.org/
+export class HSLuvColor extends HSLColorBase<HSLuvColor> implements Color {
+  readonly cssString: string;
+  readonly perceivedBrightness: number;
+
+  constructor(hsl: ColorTuple|HSL, alpha?: number) {
+    super(hsl, alpha);
+
+    const rgb = hsluvToRgb(this.hsl);
+    const r = Math.floor(rgb[0] * 255);
+    const g = Math.floor(rgb[1] * 255);
+    const b = Math.floor(rgb[2] * 255);
+
+    this.perceivedBrightness = perceivedBrightness(r, g, b);
+
+    if (this.alpha === undefined) {
+      this.cssString = `rgb(${r} ${g} ${b})`;
+    } else {
+      this.cssString = `rgb(${r} ${g} ${b} / ${this.alpha})`;
+    }
+  }
+
+  create(raw: ColorTuple|HSL, alpha?: number|undefined): HSLuvColor {
+    return new HSLuvColor(raw, alpha);
+  }
+}
+
+// Hue: 0-360
+// Saturation: 0-100
+// Lightness: 0-100
+// RGB: 0-255
+export function hslToRgb(h: number, s: number, l: number): ColorTuple {
+  h = h;
+  s = s / SATURATION_MAX;
+  l = l / LIGHTNESS_MAX;
+
+  const c = (1 - Math.abs(2 * l - 1)) * s;
+  const x = c * (1 - Math.abs((h / 60) % 2 - 1));
+  const m = l - c / 2;
+
+  let [r, g, b] = [0, 0, 0];
+
+  if (0 <= h && h < 60) {
+    [r, g, b] = [c, x, 0];
+  } else if (60 <= h && h < 120) {
+    [r, g, b] = [x, c, 0];
+  } else if (120 <= h && h < 180) {
+    [r, g, b] = [0, c, x];
+  } else if (180 <= h && h < 240) {
+    [r, g, b] = [0, x, c];
+  } else if (240 <= h && h < 300) {
+    [r, g, b] = [x, 0, c];
+  } else if (300 <= h && h < 360) {
+    [r, g, b] = [c, 0, x];
+  }
+
+  // Convert to 0-255 range
+  r = Math.round((r + m) * 255);
+  g = Math.round((g + m) * 255);
+  b = Math.round((b + m) * 255);
+
+  return [r, g, b];
+}
+
+export function hexToRgb(hex: string): ColorTuple {
+  // Convert hex to RGB first
+  let r: number = 0;
+  let g: number = 0;
+  let b: number = 0;
+
+  if (hex.length === 4) {
+    r = parseInt(hex[1] + hex[1], 16);
+    g = parseInt(hex[2] + hex[2], 16);
+    b = parseInt(hex[3] + hex[3], 16);
+  } else if (hex.length === 7) {
+    r = parseInt(hex.substring(1, 3), 16);
+    g = parseInt(hex.substring(3, 5), 16);
+    b = parseInt(hex.substring(5, 7), 16);
+  }
+
+  return [r, g, b];
+}
+
+export function rgbToHsl(rgb: ColorTuple): ColorTuple {
+  let [r, g, b] = rgb;
+  r /= 255;
+  g /= 255;
+  b /= 255;
+  const max = Math.max(r, g, b);
+  const min = Math.min(r, g, b);
+  let h: number = (max + min) / 2;
+  let s: number = (max + min) / 2;
+  const l: number = (max + min) / 2;
+
+  if (max === min) {
+    h = s = 0;  // achromatic
+  } else {
+    const d = max - min;
+    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+    switch (max) {
+      case r:
+        h = (g - b) / d + (g < b ? 6 : 0);
+        break;
+      case g:
+        h = (b - r) / d + 2;
+        break;
+      case b:
+        h = (r - g) / d + 4;
+        break;
+    }
+    h /= 6;
+  }
+
+  return [h * 360, s * 100, l * 100];
+}
+
+// Return the perceived brightness of a color using a weighted average of the
+// r, g and b channels based on human perception.
+function perceivedBrightness(r: number, g: number, b: number): number {
+  // YIQ calculation from https://24ways.org/2010/calculating-color-contrast
+  return ((r * 299) + (g * 587) + (b * 114)) / 1000;
+}
+
+// Comparison function used for sorting colors.
+export function colorCompare(a: Color, b: Color): number {
+  return a.cssString.localeCompare(b.cssString);
+}
diff --git a/ui/src/common/color_unittest.ts b/ui/src/common/color_unittest.ts
new file mode 100644
index 0000000..4973308
--- /dev/null
+++ b/ui/src/common/color_unittest.ts
@@ -0,0 +1,113 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {HSLColor, hslToRgb, HSLuvColor} from './color';
+
+describe('HSLColor', () => {
+  const col = new HSLColor({h: 123, s: 66, l: 45});
+
+  test('cssString', () => {
+    expect(col.cssString).toBe('rgb(39 190 47)');
+    expect(new HSLColor({h: 0, s: 0, l: 0}).cssString).toBe('rgb(0 0 0)');
+    expect(new HSLColor({h: 0, s: 100, l: 100}).cssString)
+        .toBe('rgb(255 255 255)');
+    expect(new HSLColor({h: 90, s: 25, l: 55}).cssString)
+        .toBe('rgb(140 169 112)');
+    expect(new HSLColor({h: 180, s: 80, l: 40}, 0.7).cssString)
+        .toBe('rgb(20 184 184 / 0.7)');
+  });
+
+  test('lighten', () => {
+    expect(col.lighten(20).hsl).toEqual([123, 66, 65]);
+    expect(col.lighten(100).hsl).toEqual([123, 66, 100]);
+    expect(col.lighten(-100).hsl).toEqual([123, 66, 0]);
+  });
+
+  test('saturate', () => {
+    expect(col.saturate(20).hsl).toEqual([123, 86, 45]);
+    expect(col.saturate(100).hsl).toEqual([123, 100, 45]);
+    expect(col.saturate(-100).hsl).toEqual([123, 0, 45]);
+  });
+
+  test('setAlpha', () => {
+    expect(col.setAlpha(.7).alpha).toEqual(.7);
+    expect(col.setAlpha(undefined).alpha).toEqual(undefined);
+  });
+
+  test('perceivedBrightness', () => {
+    // Test a few obviously light/dark colours.
+    expect(new HSLColor({h: 0, s: 0, l: 0}).perceivedBrightness)
+        .toBeLessThan(128);
+    expect(new HSLColor({h: 0, s: 0, l: 100}).perceivedBrightness)
+        .toBeGreaterThan(128);
+
+    expect(new HSLColor({h: 0, s: 0, l: 40}).perceivedBrightness)
+        .toBeLessThan(128);
+    expect(new HSLColor({h: 0, s: 0, l: 60}).perceivedBrightness)
+        .toBeGreaterThan(128);
+  });
+});
+
+describe('HSLuvColor', () => {
+  const col = new HSLuvColor({h: 123, s: 66, l: 45});
+
+  test('cssString', () => {
+    expect(col.cssString).toBe('rgb(69 117 58)');
+    expect(new HSLColor({h: 0, s: 0, l: 0}).cssString).toBe('rgb(0 0 0)');
+    expect(new HSLColor({h: 0, s: 100, l: 100}).cssString)
+        .toBe('rgb(255 255 255)');
+    expect(new HSLuvColor({h: 90, s: 25, l: 55}).cssString)
+        .toBe('rgb(131 133 112)');
+    expect(new HSLuvColor({h: 240, s: 100, l: 100}, 0.3).cssString)
+        .toBe('rgb(254 255 255 / 0.3)');
+  });
+
+  test('lighten', () => {
+    expect(col.lighten(20).hsl).toEqual([123, 66, 65]);
+    expect(col.lighten(100).hsl).toEqual([123, 66, 100]);
+    expect(col.lighten(-100).hsl).toEqual([123, 66, 0]);
+  });
+
+  test('saturate', () => {
+    expect(col.saturate(20).hsl).toEqual([123, 86, 45]);
+    expect(col.saturate(100).hsl).toEqual([123, 100, 45]);
+    expect(col.saturate(-100).hsl).toEqual([123, 0, 45]);
+  });
+
+  test('setAlpha', () => {
+    expect(col.setAlpha(.7).alpha).toEqual(.7);
+    expect(col.setAlpha(undefined).alpha).toEqual(undefined);
+  });
+
+  test('perceivedBrightness', () => {
+    // Test a few obviously light/dark colours.
+    expect(new HSLuvColor({h: 0, s: 0, l: 0}).perceivedBrightness)
+        .toBeLessThan(128);
+    expect(new HSLuvColor({h: 0, s: 0, l: 100}).perceivedBrightness)
+        .toBeGreaterThan(128);
+
+    expect(new HSLuvColor({h: 0, s: 0, l: 40}).perceivedBrightness)
+        .toBeLessThan(128);
+    expect(new HSLuvColor({h: 0, s: 0, l: 60}).perceivedBrightness)
+        .toBeGreaterThan(128);
+  });
+});
+
+test('hslToRGB', () => {
+  // Pick a few well-known conversions to check we're in the right ballpark.
+  expect(hslToRgb(0, 0, 0)).toEqual([0, 0, 0]);
+  expect(hslToRgb(0, 100, 50)).toEqual([255, 0, 0]);
+  expect(hslToRgb(120, 100, 50)).toEqual([0, 255, 0]);
+  expect(hslToRgb(240, 100, 50)).toEqual([0, 0, 255]);
+});
diff --git a/ui/src/common/colorizer.ts b/ui/src/common/colorizer.ts
index 326bb63..584deb5 100644
--- a/ui/src/common/colorizer.ts
+++ b/ui/src/common/colorizer.ts
@@ -13,91 +13,161 @@
 // limitations under the License.
 
 import {hsl} from 'color-convert';
-import {hsluvToRgb} from 'hsluv';
 
-import {clamp} from '../base/math_utils';
 import {hash} from '../common/hash';
-import {cachedHsluvToHex} from '../frontend/hsluv_cache';
+import {featureFlags} from '../core/feature_flags';
 
-export interface Color {
-  h: number;
-  s: number;
-  l: number;
-  a?: number;
+import {Color, HSLColor, HSLuvColor} from './color';
+
+// 128 would provide equal weighting between dark and light text, but we want to
+// slightly prefer light text for stylistic reasons.
+// 140 means we must be brighter on average before switching to dark text.
+const PERCEIVED_BRIGHTNESS_LIMIT = 140;
+
+// This file defines some opinionated colors and provides functions to access
+// random but predictable colors based on a seed, as well as standardized ways
+// to access colors for core objects such as slices and thread states.
+
+// We have, over the years, accumulated a number of different color palettes
+// which are used for different parts of the UI.
+// It would be nice to combine these into a single palette in the future, but
+// changing colors is difficult especially for slice colors, as folks get used
+// to certain slices being certain colors and are resistant to change.
+// However we do it, we should make it possible for folks to switch back the a
+// previous palette, or define their own.
+
+const USE_CONSISTENT_COLORS = featureFlags.register({
+  id: 'useConsistentColors',
+  name: 'Use common color palette for timeline elements',
+  description: 'Use the same color palette for all timeline elements.',
+  defaultValue: false,
+});
+
+// |ColorScheme| defines a collection of colors which can be used for various UI
+// elements. In the future we would expand this interface to include light and
+// dark variants.
+export interface ColorScheme {
+  // The base color to be used for the bulk of the element.
+  readonly base: Color;
+
+  // A variant on the base color, commonly used for highlighting.
+  readonly variant: Color;
+
+  // Grayed out color to represent a disabled state.
+  readonly disabled: Color;
+
+  // Appropriate colors for text to be displayed on top of the above colors.
+  readonly textBase: Color;
+  readonly textVariant: Color;
+  readonly textDisabled: Color;
 }
 
-const MD_PALETTE: Color[] = [
-  {h: 4, s: 90, l: 58},
-  {h: 340, s: 82, l: 52},
-  {h: 291, s: 64, l: 42},
-  {h: 262, s: 52, l: 47},
-  {h: 231, s: 48, l: 48},
-  {h: 207, s: 90, l: 54},
-  {h: 199, s: 98, l: 48},
-  {h: 187, s: 100, l: 42},
-  {h: 174, s: 100, l: 29},
-  {h: 122, s: 39, l: 49},
-  {h: 88, s: 50, l: 53},
-  {h: 66, s: 70, l: 54},
-  {h: 45, s: 100, l: 51},
-  {h: 36, s: 100, l: 50},
-  {h: 14, s: 100, l: 57},
-  {h: 16, s: 25, l: 38},
-  {h: 200, s: 18, l: 46},
-  {h: 54, s: 100, l: 62},
+const MD_PALETTE_RAW: Color[] = [
+  new HSLColor({h: 4, s: 90, l: 58}),
+  new HSLColor({h: 340, s: 82, l: 52}),
+  new HSLColor({h: 291, s: 64, l: 42}),
+  new HSLColor({h: 262, s: 52, l: 47}),
+  new HSLColor({h: 231, s: 48, l: 48}),
+  new HSLColor({h: 207, s: 90, l: 54}),
+  new HSLColor({h: 199, s: 98, l: 48}),
+  new HSLColor({h: 187, s: 100, l: 42}),
+  new HSLColor({h: 174, s: 100, l: 29}),
+  new HSLColor({h: 122, s: 39, l: 49}),
+  new HSLColor({h: 88, s: 50, l: 53}),
+  new HSLColor({h: 66, s: 70, l: 54}),
+  new HSLColor({h: 45, s: 100, l: 51}),
+  new HSLColor({h: 36, s: 100, l: 50}),
+  new HSLColor({h: 14, s: 100, l: 57}),
+  new HSLColor({h: 16, s: 25, l: 38}),
+  new HSLColor({h: 200, s: 18, l: 46}),
+  new HSLColor({h: 54, s: 100, l: 62}),
 ];
 
-export const GRAY_COLOR: Color = {
-  h: 0,
-  s: 0,
-  l: 62,
-};
+const WHITE_COLOR = new HSLColor([0, 0, 100]);
+const BLACK_COLOR = new HSLColor([0, 0, 0]);
+const GRAY_COLOR = new HSLColor([0, 0, 90]);
+
+const MD_PALETTE: ColorScheme[] = MD_PALETTE_RAW.map((color): ColorScheme => {
+  const base = color.lighten(10, 60).desaturate(20);
+  const variant = base.lighten(30, 80).desaturate(20);
+
+  return {
+    base,
+    variant,
+    disabled: GRAY_COLOR,
+    textBase: WHITE_COLOR,  // White text suits MD colors quite well
+    textVariant: WHITE_COLOR,
+    textDisabled: WHITE_COLOR,  // Low contrast is on purpose
+  };
+});
+
+// Create a color scheme based on a single color, which defines the variant
+// color as a slightly darker and more saturated version of the base color.
+export function makeColorScheme(base: Color, variant?: Color): ColorScheme {
+  variant = variant ?? base.darken(15).saturate(15);
+
+  return {
+    base,
+    variant,
+    disabled: GRAY_COLOR,
+    textBase: base.perceivedBrightness >= PERCEIVED_BRIGHTNESS_LIMIT ?
+        BLACK_COLOR :
+        WHITE_COLOR,
+    textVariant: variant.perceivedBrightness >= PERCEIVED_BRIGHTNESS_LIMIT ?
+        BLACK_COLOR :
+        WHITE_COLOR,
+    textDisabled: WHITE_COLOR,  // Low contrast is on purpose
+  };
+}
+
+const GRAY = makeColorScheme(new HSLColor([0, 0, 62]));
+const DESAT_RED = makeColorScheme(new HSLColor([3, 30, 49]));
+const DARK_GREEN = makeColorScheme(new HSLColor([120, 44, 34]));
+const LIME_GREEN = makeColorScheme(new HSLColor([75, 55, 47]));
+const TRANSPARENT_WHITE = makeColorScheme(new HSLColor([0, 1, 97], 0.55));
+const ORANGE = makeColorScheme(new HSLColor([36, 100, 50]));
+const INDIGO = makeColorScheme(new HSLColor([231, 48, 48]));
 
 // A piece of wisdom from a long forgotten blog post: "Don't make
 // colors you want to change something normal like grey."
-export const UNEXPECTED_PINK_COLOR: Color = {
-  h: 330,
-  s: 1.0,
-  l: 0.706,
-};
+export const UNEXPECTED_PINK = makeColorScheme(new HSLColor([330, 100, 70]));
 
-export function hueForCpu(cpu: number): number {
-  return (128 + (32 * cpu)) % 256;
+// Selects a predictable color scheme from a palette of material design colors,
+// based on a string seed.
+function materialColorScheme(seed: string): ColorScheme {
+  const colorIdx = hash(seed, MD_PALETTE.length);
+  return MD_PALETTE[colorIdx];
 }
 
-const DESAT_RED: Color = {
-  h: 3,
-  s: 30,
-  l: 49,
-};
-const DARK_GREEN: Color = {
-  h: 120,
-  s: 44,
-  l: 34,
-};
-const LIME_GREEN: Color = {
-  h: 75,
-  s: 55,
-  l: 47,
-};
-const TRANSPARENT_WHITE: Color = {
-  h: 0,
-  s: 1,
-  l: 97,
-  a: 0.55,
-};
-const ORANGE: Color = {
-  h: 36,
-  s: 100,
-  l: 50,
-};
-const INDIGO: Color = {
-  h: 231,
-  s: 48,
-  l: 48,
-};
+const proceduralColorCache = new Map<string, ColorScheme>();
 
-export function colorForState(state: string): Readonly<Color> {
+// Procedurally generates a predictable color scheme based on a string seed.
+function proceduralColorScheme(seed: string): ColorScheme {
+  const colorScheme = proceduralColorCache.get(seed);
+  if (colorScheme) {
+    return colorScheme;
+  } else {
+    const hue = hash(seed, 360);
+    // Saturation 100 would give the most differentiation between colors, but
+    // it's garish.
+    const saturation = 80;
+
+    // Prefer using HSLuv, not the browser's built-in vanilla HSL handling. This
+    // is because this function chooses hue/lightness uniform at random, but HSL
+    // is not perceptually uniform.
+    // See https://www.boronine.com/2012/03/26/Color-Spaces-for-Human-Beings/.
+    const base =
+        new HSLuvColor({h: hue, s: saturation, l: hash(seed + 'x', 40) + 40});
+    const variant = new HSLuvColor({h: hue, s: saturation, l: 30});
+    const colorScheme = makeColorScheme(base, variant);
+
+    proceduralColorCache.set(seed, colorScheme);
+
+    return colorScheme;
+  }
+}
+
+export function colorForState(state: string): ColorScheme {
   if (state === 'Running') {
     return DARK_GREEN;
   } else if (state.startsWith('Runnable')) {
@@ -113,163 +183,59 @@
   return INDIGO;
 }
 
-export function textColorForState(stateCode: string): string {
-  const background = colorForState(stateCode);
-  return background.l > 80 ? '#404040' : '#fff';
+export function colorForTid(tid: number): ColorScheme {
+  return materialColorScheme(tid.toString());
 }
 
-export function colorForString(identifier: string): Color {
-  const colorIdx = hash(identifier, MD_PALETTE.length);
-  return Object.assign({}, MD_PALETTE[colorIdx]);
-}
-
-export function colorForTid(tid: number): Color {
-  return colorForString(tid.toString());
-}
-
-export function colorForThread(thread?: {pid?: number, tid: number}): Color {
+export function colorForThread(thread?: {pid?: number, tid: number}):
+    ColorScheme {
   if (thread === undefined) {
-    return Object.assign({}, GRAY_COLOR);
+    return GRAY;
   }
   const tid = thread.pid ? thread.pid : thread.tid;
   return colorForTid(tid);
 }
 
-// 40 different random hues 9 degrees apart.
+export function colorForCpu(cpu: number): Color {
+  if (USE_CONSISTENT_COLORS.get()) {
+    return materialColorScheme(cpu.toString()).base;
+  } else {
+    const hue = (128 + (32 * cpu)) % 256;
+    return new HSLColor({h: hue, s: 50, l: 50});
+  }
+}
+
 export function randomColor(): string {
-  const hue = Math.floor(Math.random() * 40) * 9;
-  return '#' + hsl.hex([hue, 90, 30]);
+  const rand = Math.random();
+  if (USE_CONSISTENT_COLORS.get()) {
+    return materialColorScheme(rand.toString()).base.cssString;
+  } else {
+    // 40 different random hues 9 degrees apart.
+    const hue = Math.floor(rand * 40) * 9;
+    return '#' + hsl.hex([hue, 90, 30]);
+  }
 }
 
-// Chooses a color uniform at random based on hash(sliceName).  Returns [hue,
-// saturation, lightness].
-//
-// Prefer converting this to an RGB color using hsluv, not the browser's
-// built-in vanilla HSL handling.  This is because this function chooses
-// hue/lightness uniform at random, but HSL is not perceptually uniform.  See
-// https://www.boronine.com/2012/03/26/Color-Spaces-for-Human-Beings/.
-//
-// If isSelected, the color will be particularly dark, making it stand out.
-export function hslForSlice(
-    sliceName: string, isSelected: boolean|null): [number, number, number] {
-  const hue = hash(sliceName, 360);
-  // Saturation 100 would give the most differentiation between colors, but it's
-  // garish.
-  const saturation = 80;
-  const lightness = isSelected ? 30 : hash(sliceName + 'x', 40) + 40;
-  return [hue, saturation, lightness];
-}
-
-// Lightens the color for thread slices to represent wall time.
-export function colorForThreadIdleSlice(
-    hue: number,
-    saturation: number,
-    lightness: number,
-    isSelected: boolean|null): string {
-  // Increase lightness by 80% when selected and 40% otherwise,
-  // without exceeding 88.
-  let newLightness = isSelected ? lightness * 1.8 : lightness * 1.4;
-  newLightness = Math.min(newLightness, 88);
-  return cachedHsluvToHex(hue, saturation, newLightness);
-}
-
-export function colorCompare(x: Color, y: Color): number {
-  return (x.h - y.h) || (x.s - y.s) || (x.l - y.l);
-}
-
-// Return true if two colors have the same value.
-export function colorsEqual(a: Color, b: Color): boolean {
-  return a.h === b.h && a.s === b.s && a.l === b.l && a.a === b.a;
-}
-
-export function getColorForSlice(
-    sliceName: string, hasFocus: boolean|null): Color {
+export function getColorForSlice(sliceName: string): ColorScheme {
   const name = sliceName.replace(/( )?\d+/g, '');
-  const [hue, saturation, lightness] = hslForSlice(name, hasFocus);
-
-  return {
-    h: hue,
-    s: saturation,
-    l: lightness,
-  };
+  if (USE_CONSISTENT_COLORS.get()) {
+    return materialColorScheme(name);
+  } else {
+    return proceduralColorScheme(name);
+  }
 }
 
-const LIGHTNESS_MAX = 100;
-const LIGHTNESS_MIN = 0;
-
-// Lighten color by a percentage.
-export function colorLighten(color: Color, amount: number): Color {
-  return {
-    ...color,
-    l: clamp(color.l + amount, LIGHTNESS_MIN, LIGHTNESS_MAX),
-  };
+export function colorForFtrace(name: string): ColorScheme {
+  return materialColorScheme(name);
 }
 
-// Darken color by a percentage.
-export function colorDarken(color: Color, amount: number): Color {
-  return colorLighten(color, -amount);
-}
-
-const SATURATION_MAX = 100;
-const SATURATION_MIN = 0;
-
-// Saturate color by a percentage.
-export function colorSaturate(color: Color, amount: number): Color {
-  return {
-    ...color,
-    s: clamp(color.s + amount, SATURATION_MIN, SATURATION_MAX),
-  };
-}
-
-// Desaturate color by a percentage.
-export function colorDesaturate(color: Color, amount: number): Color {
-  return colorSaturate(color, -amount);
-}
-
-// Convert color to RGB values in the range 0-255
-export function colorToRGB(color: Color): [number, number, number] {
-  const h = color.h;
-  const s = color.s / SATURATION_MAX;
-  const l = color.l / LIGHTNESS_MAX;
-
-  const c = (1 - Math.abs(2 * l - 1)) * s;
-  const x = c * (1 - Math.abs((h / 60) % 2 - 1));
-  const m = l - c / 2;
-
-  let [r, g, b] = [0, 0, 0];
-
-  if (0 <= h && h < 60) {
-    [r, g, b] = [c, x, 0];
-  } else if (60 <= h && h < 120) {
-    [r, g, b] = [x, c, 0];
-  } else if (120 <= h && h < 180) {
-    [r, g, b] = [0, c, x];
-  } else if (180 <= h && h < 240) {
-    [r, g, b] = [0, x, c];
-  } else if (240 <= h && h < 300) {
-    [r, g, b] = [x, 0, c];
-  } else if (300 <= h && h < 360) {
-    [r, g, b] = [c, 0, x];
+export function colorForSample(callsiteId: number, isHovered: boolean): string {
+  let colorScheme;
+  if (USE_CONSISTENT_COLORS.get()) {
+    colorScheme = materialColorScheme(String(callsiteId));
+  } else {
+    colorScheme = proceduralColorScheme(String(callsiteId));
   }
 
-  // Convert to 0-255 range
-  r = Math.round((r + m) * 255);
-  g = Math.round((g + m) * 255);
-  b = Math.round((b + m) * 255);
-
-  return [r, g, b];
-}
-
-// Get whether a color should be considered "light" based on its perceived
-// brightness.
-export function colorIsLight(color: Color): boolean {
-  // YIQ calculation from https://24ways.org/2010/calculating-color-contrast
-  // hsluvToRgb returns rgb in range 0..1
-  const [r, g, b] = hsluvToRgb([color.h, color.s, color.l]);
-  const yiq = ((r * 255 * 299) + (g * 255 * 587) + (b * 255 * 114)) / 1000;
-  return yiq >= 128;
-}
-
-export function colorIsDark(color: Color): boolean {
-  return !colorIsLight(color);
+  return isHovered ? colorScheme.variant.cssString : colorScheme.base.cssString;
 }
diff --git a/ui/src/common/colorizer_unittest.ts b/ui/src/common/colorizer_unittest.ts
index a163568..ffc931e 100644
--- a/ui/src/common/colorizer_unittest.ts
+++ b/ui/src/common/colorizer_unittest.ts
@@ -13,15 +13,8 @@
 // limitations under the License.
 
 import {
-  Color,
-  colorCompare,
+  colorForCpu,
   colorForThread,
-  colorIsLight,
-  colorLighten,
-  colorSaturate,
-  colorsEqual,
-  colorToRGB,
-  hueForCpu,
 } from './colorizer';
 
 const PROCESS_A_THREAD_A = {
@@ -61,66 +54,12 @@
   expect(colorUnkA).toEqual(colorUnkB);
 });
 
-test('it copies colors', () => {
+test('it doesn\'t copy colors', () => {
   const a = colorForThread(PROCESS_A_THREAD_A);
   const b = colorForThread(PROCESS_A_THREAD_A);
-  expect(a === b).toEqual(false);
+  expect(a).toBe(b);
 });
 
 test('it gives different cpus different hues', () => {
-  expect(hueForCpu(0)).not.toEqual(hueForCpu(1));
-});
-
-test('colorCompare', () => {
-  const col: Color = {h: 123, s: 66, l: 45};
-
-  expect(colorCompare({...col}, col)).toBe(0);
-
-  expect(colorCompare({...col, h: 34}, col)).toBeLessThan(0);
-  expect(colorCompare({...col, h: 156}, col)).toBeGreaterThan(0);
-
-  expect(colorCompare({...col, s: 22}, col)).toBeLessThan(0);
-  expect(colorCompare({...col, s: 100}, col)).toBeGreaterThan(0);
-
-  expect(colorCompare({...col, l: 22}, col)).toBeLessThan(0);
-  expect(colorCompare({...col, l: 76}, col)).toBeGreaterThan(0);
-});
-
-test('colorsEqual', () => {
-  const col: Color = {h: 123, s: 66, l: 45};
-  expect(colorsEqual(col, {h: 123, s: 66, l: 45})).toBeTruthy();
-  expect(colorsEqual(col, {h: 86, s: 66, l: 45})).toBeFalsy();
-  expect(colorsEqual(col, {h: 123, s: 43, l: 45})).toBeFalsy();
-  expect(colorsEqual(col, {h: 123, s: 43, l: 78})).toBeFalsy();
-});
-
-test('colorLighten', () => {
-  const col: Color = {h: 123, s: 66, l: 45};
-  expect(colorLighten(col, 20)).toEqual({...col, l: 65});
-  expect(colorLighten(col, 100)).toEqual({...col, l: 100});
-  expect(colorLighten(col, -100)).toEqual({...col, l: 0});
-});
-
-test('colorSaturate', () => {
-  const col: Color = {h: 123, s: 66, l: 45};
-  expect(colorSaturate(col, 20)).toEqual({...col, s: 86});
-  expect(colorSaturate(col, 100)).toEqual({...col, s: 100});
-  expect(colorSaturate(col, -100)).toEqual({...col, s: 0});
-});
-
-test('colorToRGB', () => {
-  // Pick a few well-known conversions to check we're in the right ballpark.
-  expect(colorToRGB({h: 0, s: 0, l: 0})).toEqual([0, 0, 0]);
-  expect(colorToRGB({h: 0, s: 100, l: 50})).toEqual([255, 0, 0]);
-  expect(colorToRGB({h: 120, s: 100, l: 50})).toEqual([0, 255, 0]);
-  expect(colorToRGB({h: 240, s: 100, l: 50})).toEqual([0, 0, 255]);
-});
-
-test('lightness calculations', () => {
-  // Pick a few obvious light/dark colours to check we're in the right ballpark.
-  expect(colorIsLight({h: 0, s: 0, l: 0})).toBeFalsy();
-  expect(colorIsLight({h: 0, s: 0, l: 100})).toBeTruthy();
-
-  expect(colorIsLight({h: 0, s: 0, l: 40})).toBeFalsy();
-  expect(colorIsLight({h: 0, s: 0, l: 60})).toBeTruthy();
+  expect(colorForCpu(0)).not.toEqual(colorForCpu(1));
 });
diff --git a/ui/src/common/track_adapter.ts b/ui/src/common/track_adapter.ts
index 8dbb089..7514c5f 100644
--- a/ui/src/common/track_adapter.ts
+++ b/ui/src/common/track_adapter.ts
@@ -18,7 +18,8 @@
 import {assertExists} from '../base/logging';
 import {duration, Span, time} from '../base/time';
 import {PxSpan, TimeScale} from '../frontend/time_scale';
-import {NewTrackArgs, SliceRect} from '../frontend/track';
+import {NewTrackArgs} from '../frontend/track';
+import {SliceRect} from '../public';
 import {EngineProxy} from '../trace_processor/engine';
 
 import {BasicAsyncTrack} from './basic_async_track';
diff --git a/ui/src/controller/aggregation/slice_aggregation_controller.ts b/ui/src/controller/aggregation/slice_aggregation_controller.ts
index 4de7003..7543110 100644
--- a/ui/src/controller/aggregation/slice_aggregation_controller.ts
+++ b/ui/src/controller/aggregation/slice_aggregation_controller.ts
@@ -17,7 +17,9 @@
 import {Area, Sorting} from '../../common/state';
 import {globals} from '../../frontend/globals';
 import {Engine} from '../../trace_processor/engine';
-import {ASYNC_SLICE_TRACK_KIND} from '../../tracks/async_slices';
+import {
+  ASYNC_SLICE_TRACK_KIND,
+} from '../../tracks/async_slices/async_slice_track';
 import {SLICE_TRACK_KIND} from '../../tracks/chrome_slices';
 
 import {AggregationController} from './aggregation_controller';
diff --git a/ui/src/controller/track_decider.ts b/ui/src/controller/track_decider.ts
index c123e78..8c6433b 100644
--- a/ui/src/controller/track_decider.ts
+++ b/ui/src/controller/track_decider.ts
@@ -39,7 +39,7 @@
   STR_NULL,
 } from '../trace_processor/query_result';
 import {ACTUAL_FRAMES_SLICE_TRACK_KIND} from '../tracks/actual_frames';
-import {ASYNC_SLICE_TRACK_KIND} from '../tracks/async_slices';
+import {ASYNC_SLICE_TRACK_KIND} from '../tracks/async_slices/async_slice_track';
 import {
   ENABLE_SCROLL_JANK_PLUGIN_V2,
   getScrollJankTracks,
@@ -288,14 +288,25 @@
         }
       }
 
-      const track: AddTrackArgs = {
-        uri: `perfetto.AsyncSlices#${rawName}`,
-        trackSortKey: PrimaryTrackSortKey.ASYNC_SLICE_TRACK,
-        trackGroup,
-        name,
-      };
+      if (showV1()) {
+        const track: AddTrackArgs = {
+          uri: `perfetto.AsyncSlices#${rawName}`,
+          trackSortKey: PrimaryTrackSortKey.ASYNC_SLICE_TRACK,
+          trackGroup,
+          name,
+        };
+        this.tracksToAdd.push(track);
+      }
 
-      this.tracksToAdd.push(track);
+      if (showV2()) {
+        const track: AddTrackArgs = {
+          uri: `perfetto.AsyncSlices#${rawName}.v2`,
+          trackSortKey: PrimaryTrackSortKey.ASYNC_SLICE_TRACK,
+          trackGroup,
+          name,
+        };
+        this.tracksToAdd.push(track);
+      }
     }
   }
 
@@ -1022,12 +1033,24 @@
         processName,
         kind: ASYNC_SLICE_TRACK_KIND,
       });
-      this.tracksToAdd.push({
-        uri: `perfetto.AsyncSlices#process.${pid}${rawTrackIds}`,
-        name,
-        trackSortKey: PrimaryTrackSortKey.ASYNC_SLICE_TRACK,
-        trackGroup: uuid,
-      });
+
+      if (showV1()) {
+        this.tracksToAdd.push({
+          uri: `perfetto.AsyncSlices#process.${pid}${rawTrackIds}`,
+          name,
+          trackSortKey: PrimaryTrackSortKey.ASYNC_SLICE_TRACK,
+          trackGroup: uuid,
+        });
+      }
+
+      if (showV2()) {
+        this.tracksToAdd.push({
+          uri: `perfetto.AsyncSlices#process.${pid}${rawTrackIds}.v2`,
+          name,
+          trackSortKey: PrimaryTrackSortKey.ASYNC_SLICE_TRACK,
+          trackGroup: uuid,
+        });
+      }
     }
   }
 
@@ -1078,12 +1101,24 @@
       const kind = ACTUAL_FRAMES_SLICE_TRACK_KIND;
       const name =
           getTrackName({name: trackName, upid, pid, processName, kind});
-      this.tracksToAdd.push({
-        uri: `perfetto.ActualFrames#${upid}`,
-        name,
-        trackSortKey: PrimaryTrackSortKey.ACTUAL_FRAMES_SLICE_TRACK,
-        trackGroup: uuid,
-      });
+
+      if (showV1()) {
+        this.tracksToAdd.push({
+          uri: `perfetto.ActualFrames#${upid}`,
+          name,
+          trackSortKey: PrimaryTrackSortKey.ACTUAL_FRAMES_SLICE_TRACK,
+          trackGroup: uuid,
+        });
+      }
+
+      if (showV2()) {
+        this.tracksToAdd.push({
+          uri: `perfetto.ActualFrames#${upid}.v2`,
+          name,
+          trackSortKey: PrimaryTrackSortKey.ACTUAL_FRAMES_SLICE_TRACK,
+          trackGroup: uuid,
+        });
+      }
     }
   }
 
@@ -1135,12 +1170,24 @@
       const kind = EXPECTED_FRAMES_SLICE_TRACK_KIND;
       const name =
           getTrackName({name: trackName, upid, pid, processName, kind});
-      this.tracksToAdd.push({
-        uri: `perfetto.ExpectedFrames#${upid}`,
-        name,
-        trackSortKey: PrimaryTrackSortKey.EXPECTED_FRAMES_SLICE_TRACK,
-        trackGroup: uuid,
-      });
+
+      if (showV1()) {
+        this.tracksToAdd.push({
+          uri: `perfetto.ExpectedFrames#${upid}`,
+          name,
+          trackSortKey: PrimaryTrackSortKey.EXPECTED_FRAMES_SLICE_TRACK,
+          trackGroup: uuid,
+        });
+      }
+
+      if (showV2()) {
+        this.tracksToAdd.push({
+          uri: `perfetto.ExpectedFrames#${upid}.v2`,
+          name,
+          trackSortKey: PrimaryTrackSortKey.EXPECTED_FRAMES_SLICE_TRACK,
+          trackGroup: uuid,
+        });
+      }
     }
   }
 
diff --git a/ui/src/frontend/aggregation_panel.ts b/ui/src/frontend/aggregation_panel.ts
index ea90a3f..fed608c 100644
--- a/ui/src/frontend/aggregation_panel.ts
+++ b/ui/src/frontend/aggregation_panel.ts
@@ -20,7 +20,7 @@
   Column,
   ThreadStateExtra,
 } from '../common/aggregation_data';
-import {colorForState, textColorForState} from '../common/colorizer';
+import {colorForState} from '../common/colorizer';
 import {translateState} from '../common/thread_state';
 
 import {globals} from './globals';
@@ -122,15 +122,14 @@
     if (data === undefined) return undefined;
     const states = [];
     for (let i = 0; i < data.states.length; i++) {
-      const color = colorForState(data.states[i]);
-      const textColor = textColorForState(data.states[i]);
+      const colorScheme = colorForState(data.states[i]);
       const width = data.values[i] / data.totalMs * 100;
       states.push(
           m('.state',
             {
               style: {
-                background: `hsl(${color.h},${color.s}%,${color.l}%)`,
-                color: `${textColor}`,
+                background: colorScheme.base.cssString,
+                color: colorScheme.textBase.cssString,
                 width: `${width}%`,
               },
             },
diff --git a/ui/src/frontend/base_slice_track.ts b/ui/src/frontend/base_slice_track.ts
index e8a0cbf..c0c637e 100644
--- a/ui/src/frontend/base_slice_track.ts
+++ b/ui/src/frontend/base_slice_track.ts
@@ -27,27 +27,19 @@
   drawIncompleteSlice,
   drawTrackHoverTooltip,
 } from '../common/canvas_utils';
-import {
-  Color,
-  colorCompare,
-  colorDesaturate,
-  colorIsLight,
-  colorLighten,
-  colorsEqual,
-  UNEXPECTED_PINK_COLOR,
-} from '../common/colorizer';
+import {colorCompare} from '../common/color';
+import {UNEXPECTED_PINK} from '../common/colorizer';
 import {Selection, SelectionKind} from '../common/state';
 import {raf} from '../core/raf_scheduler';
-import {Slice} from '../public';
+import {Slice, SliceRect} from '../public';
 import {LONG, NUM} from '../trace_processor/query_result';
 
 import {checkerboardExcept} from './checkerboard';
 import {globals} from './globals';
-import {cachedHsluvToHex} from './hsluv_cache';
 import {DEFAULT_SLICE_LAYOUT, SliceLayout} from './slice_layout';
 import {constraintsToQuerySuffix} from './sql_utils';
 import {PxSpan, TimeScale} from './time_scale';
-import {NewTrackArgs, SliceRect, TrackBase} from './track';
+import {NewTrackArgs, TrackBase} from './track';
 import {BUCKETS_PER_PIXEL, CacheKey, TrackCache} from './track_cache';
 
 // The common class that underpins all tracks drawing slices.
@@ -59,7 +51,7 @@
 const SLICE_MIN_WIDTH_FOR_TEXT_PX = 5;
 const SLICE_MIN_WIDTH_PX = 1 / BUCKETS_PER_PIXEL;
 const CHEVRON_WIDTH_PX = 10;
-const DEFAULT_SLICE_COLOR = UNEXPECTED_PINK_COLOR;
+const DEFAULT_SLICE_COLOR = UNEXPECTED_PINK;
 
 // Exposed and standalone to allow for testing without making this
 // visible to subclasses.
@@ -423,13 +415,15 @@
 
     // Second pass: fill slices by color.
     const vizSlicesByColor = vizSlices.slice();
-    vizSlicesByColor.sort((a, b) => colorCompare(a.color, b.color));
+    vizSlicesByColor.sort(
+        (a, b) => colorCompare(a.colorScheme.base, b.colorScheme.base));
     let lastColor = undefined;
     for (const slice of vizSlicesByColor) {
-      if (slice.color !== lastColor) {
-        lastColor = slice.color;
-        const {h, s, l} = slice.color;
-        ctx.fillStyle = cachedHsluvToHex(h, s, l);
+      const color = slice.isHighlighted ? slice.colorScheme.variant.cssString :
+                                          slice.colorScheme.base.cssString;
+      if (color !== lastColor) {
+        lastColor = color;
+        ctx.fillStyle = color;
       }
       const y = padding + slice.depth * (sliceHeight + rowSpacing);
       if (slice.flags & SLICE_FLAGS_INSTANT) {
@@ -444,7 +438,7 @@
     }
 
     // Pass 2.5: Draw fillRatio light section.
-    let prevColor: Color|undefined;
+    ctx.fillStyle = `#FFFFFF50`;
     for (const slice of vizSlicesByColor) {
       // Can't draw fill ratio on incomplete or instant slices.
       if (slice.flags & (SLICE_FLAGS_INCOMPLETE | SLICE_FLAGS_INSTANT)) {
@@ -468,17 +462,6 @@
         continue;
       }
 
-      // Lighten and desaturate the slice color
-      const color = getFillRatioLightColor(slice.color);
-
-      // Set color if not set previously
-      // Slices are sorted by color and light tint is a pure function of slice
-      // color so we should be able to re-use colors quite frequently
-      if (prevColor === undefined || !colorsEqual(color, prevColor)) {
-        ctx.fillStyle = cachedHsluvToHex(color.h, color.s, color.l);
-        prevColor = color;
-      }
-
       const y = padding + slice.depth * (sliceHeight + rowSpacing);
       const x = slice.x + (sliceDrawWidth - lightSectionDrawWidth);
       ctx.fillRect(x, y, lightSectionDrawWidth, sliceHeight);
@@ -495,7 +478,9 @@
       }
 
       // Change the title color dynamically depending on contrast.
-      ctx.fillStyle = colorIsLight(slice.color) ? 'black' : 'white';
+      const textColor = slice.isHighlighted ? slice.colorScheme.textVariant :
+                                              slice.colorScheme.textBase;
+      ctx.fillStyle = textColor.cssString;
       const title = cropText(slice.title, charWidth, slice.w);
       const rectXCenter = slice.x + slice.w / 2;
       const y = padding + slice.depth * (sliceHeight + rowSpacing);
@@ -527,9 +512,9 @@
 
       // Draw a thicker border around the selected slice (or chevron).
       const slice = discoveredSelection;
-      const color = slice.color;
+      const color = slice.colorScheme;
       const y = padding + slice.depth * (sliceHeight + rowSpacing);
-      ctx.strokeStyle = cachedHsluvToHex(color.h, 100, 10);
+      ctx.strokeStyle = color.base.setHSL({s: 100, l: 10}).cssString;
       ctx.beginPath();
       const THICKNESS = 3;
       ctx.lineWidth = THICKNESS;
@@ -786,8 +771,8 @@
       // The derived class doesn't need to initialize these. They are
       // rewritten on every renderCanvas() call. We just need to initialize
       // them to something.
-      baseColor: DEFAULT_SLICE_COLOR,
-      color: DEFAULT_SLICE_COLOR,
+      colorScheme: DEFAULT_SLICE_COLOR,
+      isHighlighted: false,
     };
   }
 
@@ -946,15 +931,7 @@
     for (const slice of slices) {
       const isHovering = globals.state.highlightedSliceId === slice.id ||
           (this.hoveredSlice && this.hoveredSlice.title === slice.title);
-      if (isHovering) {
-        slice.color = {
-          h: slice.baseColor.h,
-          s: slice.baseColor.s,
-          l: 30,
-        };
-      } else {
-        slice.color = slice.baseColor;
-      }
+      slice.isHighlighted = !!isHovering;
     }
   }
 
@@ -1007,7 +984,3 @@
   // Input args (BaseSliceTrack -> Impl):
   slice: S;  // The slice which is clicked.
 }
-
-function getFillRatioLightColor(color: Color): Color {
-  return colorLighten(colorDesaturate(color, 15), 15);
-}
diff --git a/ui/src/frontend/base_slice_track_unittest.ts b/ui/src/frontend/base_slice_track_unittest.ts
index 207105f..73fca19 100644
--- a/ui/src/frontend/base_slice_track_unittest.ts
+++ b/ui/src/frontend/base_slice_track_unittest.ts
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import {Time} from '../base/time';
-import {GRAY_COLOR} from '../common/colorizer';
+import {UNEXPECTED_PINK} from '../common/colorizer';
 import {Slice} from '../public';
 
 import {
@@ -35,9 +35,9 @@
     flags: 0,
     title: '',
     subTitle: '',
-    baseColor: GRAY_COLOR,
-    color: GRAY_COLOR,
+    colorScheme: UNEXPECTED_PINK,
     fillRatio: 1,
+    isHighlighted: false,
   };
 }
 
diff --git a/ui/src/frontend/flow_events_renderer.ts b/ui/src/frontend/flow_events_renderer.ts
index ed47282..876c73d 100644
--- a/ui/src/frontend/flow_events_renderer.ts
+++ b/ui/src/frontend/flow_events_renderer.ts
@@ -15,12 +15,12 @@
 import {time} from '../base/time';
 import {pluginManager} from '../common/plugins';
 import {TrackState} from '../common/state';
+import {SliceRect} from '../public';
 
 import {TRACK_SHELL_WIDTH} from './css_constants';
 import {ALL_CATEGORIES, getFlowCategories} from './flow_events_panel';
 import {Flow, FlowPoint, globals} from './globals';
 import {PanelVNode} from './panel';
-import {SliceRect} from './track';
 import {TrackGroupPanel} from './track_group_panel';
 import {TrackPanel} from './track_panel';
 
diff --git a/ui/src/frontend/ftrace_panel.ts b/ui/src/frontend/ftrace_panel.ts
index c313726..a77d31b 100644
--- a/ui/src/frontend/ftrace_panel.ts
+++ b/ui/src/frontend/ftrace_panel.ts
@@ -16,7 +16,7 @@
 
 import {time, Time} from '../base/time';
 import {Actions} from '../common/actions';
-import {colorForString} from '../common/colorizer';
+import {colorForFtrace} from '../common/colorizer';
 import {StringListPatch} from '../common/state';
 import {DetailsShell} from '../widgets/details_shell';
 import {
@@ -177,12 +177,7 @@
 
         const rank = i + offset;
 
-        const color = colorForString(name);
-        const hsl = `hsl(
-          ${color.h},
-          ${color.s - 20}%,
-          ${Math.min(color.l + 10, 60)}%
-        )`;
+        const color = colorForFtrace(name).base.cssString;
 
         rows.push(m(
             `.row`,
@@ -192,7 +187,7 @@
               onmouseout: this.onRowOut.bind(this),
             },
             m('.cell', timestamp),
-            m('.cell', m('span.colour', {style: {background: hsl}}), name),
+            m('.cell', m('span.colour', {style: {background: color}}), name),
             m('.cell', cpu),
             m('.cell', process),
             m('.cell', args),
diff --git a/ui/src/frontend/hsluv_cache.ts b/ui/src/frontend/hsluv_cache.ts
deleted file mode 100644
index 44cafa6..0000000
--- a/ui/src/frontend/hsluv_cache.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2022 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import {hsluvToHex} from 'hsluv';
-
-class HsluvCache {
-  storage = new Map<number, string>();
-
-  get(hue: number, saturation: number, lightness: number): string {
-    const key = hue * 1e6 + saturation * 1e3 + lightness;
-    const value = this.storage.get(key);
-
-    if (value === undefined) {
-      const computed = hsluvToHex([hue, saturation, lightness]);
-      this.storage.set(key, computed);
-      return computed;
-    }
-
-    return value;
-  }
-}
-
-const cache = new HsluvCache();
-
-export function cachedHsluvToHex(
-    hue: number, saturation: number, lightness: number): string {
-  return cache.get(hue, saturation, lightness);
-}
diff --git a/ui/src/frontend/named_slice_track.ts b/ui/src/frontend/named_slice_track.ts
index b5ab169..d8c3514 100644
--- a/ui/src/frontend/named_slice_track.ts
+++ b/ui/src/frontend/named_slice_track.ts
@@ -58,8 +58,8 @@
     const baseSlice = super.rowToSlice(row);
     // Ignore PIDs or numeric arguments when hashing.
     const name = row.name || '';
-    const baseColor = getColorForSlice(name, false);
-    return {...baseSlice, title: name, baseColor};
+    const colorScheme = getColorForSlice(name);
+    return {...baseSlice, title: name, colorScheme};
   }
 
   onSliceOver(args: OnSliceOverArgs<T['slice']>) {
diff --git a/ui/src/frontend/overview_timeline_panel.ts b/ui/src/frontend/overview_timeline_panel.ts
index 7ad81ff..edda1d3 100644
--- a/ui/src/frontend/overview_timeline_panel.ts
+++ b/ui/src/frontend/overview_timeline_panel.ts
@@ -20,7 +20,7 @@
   Time,
   time,
 } from '../base/time';
-import {hueForCpu} from '../common/colorizer';
+import {colorForCpu} from '../common/colorizer';
 import {timestampFormat, TimestampFormat} from '../common/timestamp_format';
 
 import {
@@ -135,7 +135,8 @@
           const xEnd = Math.ceil(this.timeScale.timeToPx(loads[i].end));
           const yOff = Math.floor(headerHeight + y * trackHeight);
           const lightness = Math.ceil((1 - loads[i].load * 0.7) * 100);
-          ctx.fillStyle = `hsl(${hueForCpu(y)}, 50%, ${lightness}%)`;
+          const color = colorForCpu(y).setHSL({s: 50, l: lightness});
+          ctx.fillStyle = color.cssString;
           ctx.fillRect(xStart, yOff, xEnd - xStart, Math.ceil(trackHeight));
         }
         y++;
diff --git a/ui/src/frontend/slice.ts b/ui/src/frontend/slice.ts
deleted file mode 100644
index 7e82268..0000000
--- a/ui/src/frontend/slice.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import {duration, time} from '../base/time';
-import {Color} from '../common/colorizer';
-
-export interface Slice {
-  // These properties are updated only once per query result when the Slice
-  // object is created and don't change afterwards.
-  readonly id: number;
-  readonly startNsQ: time;
-  readonly endNsQ: time;
-  readonly durNsQ: duration;
-  readonly ts: time;
-  readonly dur: duration;
-  readonly depth: number;
-  readonly flags: number;
-
-  // Each slice can represent some extra numerical information by rendering a
-  // portion of the slice with a lighter tint.
-  // |fillRatio\ describes the ratio of the normal area to the tinted area
-  // width of the slice, normalized between 0.0 -> 1.0.
-  // 0.0 means the whole slice is tinted.
-  // 1.0 means none of the slice is tinted.
-  // E.g. If |fillRatio| = 0.65 the slice will be rendered like this:
-  // [############|*******]
-  // ^------------^-------^
-  //     Normal     Light
-  readonly fillRatio: number;
-
-  // These can be changed by the Impl.
-  title: string;
-  subTitle: string;
-  baseColor: Color;
-  color: Color;
-}
diff --git a/ui/src/frontend/slice_track_base.ts b/ui/src/frontend/slice_track_base.ts
index 37c2ec8..bf66782 100644
--- a/ui/src/frontend/slice_track_base.ts
+++ b/ui/src/frontend/slice_track_base.ts
@@ -16,18 +16,14 @@
 import {Actions} from '../common/actions';
 import {BasicAsyncTrack} from '../common/basic_async_track';
 import {cropText, drawIncompleteSlice} from '../common/canvas_utils';
-import {
-  colorForThreadIdleSlice,
-  getColorForSlice,
-} from '../common/colorizer';
+import {getColorForSlice} from '../common/colorizer';
 import {HighPrecisionTime} from '../common/high_precision_time';
 import {TrackData} from '../common/track_data';
+import {SliceRect} from '../public';
 
 import {checkerboardExcept} from './checkerboard';
 import {globals} from './globals';
-import {cachedHsluvToHex} from './hsluv_cache';
 import {PxSpan, TimeScale} from './time_scale';
-import {SliceRect} from './track';
 
 export const SLICE_TRACK_KIND = 'ChromeSliceTrack';
 const SLICE_HEIGHT = 18;
@@ -141,11 +137,14 @@
           globals.state.highlightedSliceId === sliceId;
 
       const hasFocus = highlighted || isSelected;
-      const colorObj = getColorForSlice(title, hasFocus);
+      const colorScheme = getColorForSlice(title);
+      const colorObj = hasFocus ? colorScheme.variant : colorScheme.base;
+      const textColor =
+          hasFocus ? colorScheme.textVariant : colorScheme.textBase;
 
       let color: string;
       if (colorOverride === undefined) {
-        color = cachedHsluvToHex(colorObj.h, colorObj.s, colorObj.l);
+        color = colorObj.cssString;
       } else {
         color = colorOverride;
       }
@@ -165,7 +164,7 @@
             ctx.translate(rect.left, rect.top);
 
             // Draw a rectangle around the selected slice
-            ctx.strokeStyle = cachedHsluvToHex(colorObj.h, 100, 10);
+            ctx.strokeStyle = colorObj.setHSL({s: 100, l: 10}).cssString;
             ctx.beginPath();
             ctx.lineWidth = 3;
             ctx.strokeRect(
@@ -196,9 +195,8 @@
         const cpuTimeRatio = data.cpuTimeRatio![i];
         const firstPartWidth = rect.width * cpuTimeRatio;
         const secondPartWidth = rect.width * (1 - cpuTimeRatio);
-        ctx.fillRect(rect.left, rect.top, firstPartWidth, SLICE_HEIGHT);
-        ctx.fillStyle = colorForThreadIdleSlice(
-            colorObj.h, colorObj.s, colorObj.l, hasFocus);
+        ctx.fillRect(rect.left, rect.top, rect.width, SLICE_HEIGHT);
+        ctx.fillStyle = '#FFFFFF50';
         ctx.fillRect(
             rect.left + firstPartWidth,
             rect.top,
@@ -211,7 +209,7 @@
       // Selected case
       if (isSelected) {
         drawRectOnSelected = () => {
-          ctx.strokeStyle = cachedHsluvToHex(colorObj.h, 100, 10);
+          ctx.strokeStyle = colorObj.setHSL({s: 100, l: 10}).cssString;
           ctx.beginPath();
           ctx.lineWidth = 3;
           ctx.strokeRect(
@@ -222,7 +220,7 @@
 
       // Don't render text when we have less than 5px to play with.
       if (rect.width >= 5) {
-        ctx.fillStyle = colorObj.l > 65 ? '#404040' : 'white';
+        ctx.fillStyle = textColor.cssString;
         const displayText = cropText(title, charWidth, rect.width);
         const rectXCenter = rect.left + rect.width / 2;
         ctx.textBaseline = 'middle';
diff --git a/ui/src/frontend/track.ts b/ui/src/frontend/track.ts
index f83c03f..88565f2 100644
--- a/ui/src/frontend/track.ts
+++ b/ui/src/frontend/track.ts
@@ -15,7 +15,7 @@
 import m from 'mithril';
 
 import {duration, Span, time} from '../base/time';
-import {Track, TrackContext} from '../public';
+import {SliceRect, Track, TrackContext} from '../public';
 import {EngineProxy} from '../trace_processor/engine';
 
 import {PxSpan, TimeScale} from './time_scale';
@@ -26,27 +26,6 @@
   engine: EngineProxy;
 }
 
-// This interface forces track implementations to have some static properties.
-// Typescript does not have abstract static members, which is why this needs to
-// be in a separate interface.
-export interface TrackCreator {
-  // Store the kind explicitly as a string as opposed to using class.kind in
-  // case we ever minify our code.
-  readonly kind: string;
-
-  // We need the |create| method because the stored value in the registry can be
-  // an abstract class, and we cannot call 'new' on an abstract class.
-  create(args: NewTrackArgs): TrackBase;
-}
-
-export interface SliceRect {
-  left: number;
-  width: number;
-  top: number;
-  height: number;
-  visible: boolean;
-}
-
 // The abstract class that needs to be implemented by all tracks.
 export abstract class TrackBase implements Track {
   protected readonly trackKey: string;
diff --git a/ui/src/frontend/track_panel.ts b/ui/src/frontend/track_panel.ts
index 90a593a..c877ad1 100644
--- a/ui/src/frontend/track_panel.ts
+++ b/ui/src/frontend/track_panel.ts
@@ -22,7 +22,7 @@
 import {pluginManager} from '../common/plugins';
 import {TrackState} from '../common/state';
 import {raf} from '../core/raf_scheduler';
-import {Migrate, Track, TrackContext} from '../public';
+import {Migrate, SliceRect, Track, TrackContext} from '../public';
 
 import {SELECTION_FILL_COLOR, TRACK_SHELL_WIDTH} from './css_constants';
 import {globals} from './globals';
@@ -30,7 +30,6 @@
 import {Panel, PanelSize} from './panel';
 import {verticalScrollToTrack} from './scroll_helper';
 import {PxSpan, TimeScale} from './time_scale';
-import {SliceRect} from './track';
 import {
   drawVerticalLineAtTime,
 } from './vertical_line_helper';
diff --git a/ui/src/frontend/track_registry.ts b/ui/src/frontend/track_registry.ts
deleted file mode 100644
index 92007d7..0000000
--- a/ui/src/frontend/track_registry.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import {Registry} from '../common/registry';
-import {TrackCreator} from './track';
-
-/**
- * Global registry that maps types to TrackCreator.
- */
-export const trackRegistry = Registry.kindRegistry<TrackCreator>();
diff --git a/ui/src/public/index.ts b/ui/src/public/index.ts
index 63750ee..480676c 100644
--- a/ui/src/public/index.ts
+++ b/ui/src/public/index.ts
@@ -16,10 +16,9 @@
 
 import {Hotkey} from '../base/hotkeys';
 import {duration, Span, time} from '../base/time';
-import {Color} from '../common/colorizer';
+import {ColorScheme} from '../common/colorizer';
 import {Store} from '../frontend/store';
 import {PxSpan, TimeScale} from '../frontend/time_scale';
-import {SliceRect} from '../frontend/track';
 import {EngineProxy} from '../trace_processor/engine';
 
 export {createStore, Store} from '../frontend/store';
@@ -60,8 +59,8 @@
   // These can be changed by the Impl.
   title: string;
   subTitle: string;
-  baseColor: Color;
-  color: Color;
+  colorScheme: ColorScheme;
+  isHighlighted: boolean;
 }
 
 export interface Command {
@@ -164,6 +163,14 @@
   mountStore<State>(migrate: Migrate<State>): Store<State>;
 }
 
+export interface SliceRect {
+  left: number;
+  width: number;
+  top: number;
+  height: number;
+  visible: boolean;
+}
+
 export interface Track {
   onCreate(ctx: TrackContext): void;
   render(ctx: CanvasRenderingContext2D): void;
diff --git a/ui/src/tracks/actual_frames/actual_frames_track.ts b/ui/src/tracks/actual_frames/actual_frames_track.ts
new file mode 100644
index 0000000..4b6c198b
--- /dev/null
+++ b/ui/src/tracks/actual_frames/actual_frames_track.ts
@@ -0,0 +1,146 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {BigintMath as BIMath} from '../../base/bigint_math';
+import {duration, time} from '../../base/time';
+import {SliceData, SliceTrackBase} from '../../frontend/slice_track_base';
+import {
+  EngineProxy,
+} from '../../public';
+import {
+  LONG,
+  LONG_NULL,
+  NUM,
+  STR,
+} from '../../trace_processor/query_result';
+
+export const ACTUAL_FRAMES_SLICE_TRACK_KIND = 'ActualFramesSliceTrack';
+
+const BLUE_COLOR = '#03A9F4';         // Blue 500
+const GREEN_COLOR = '#4CAF50';        // Green 500
+const YELLOW_COLOR = '#FFEB3B';       // Yellow 500
+const RED_COLOR = '#FF5722';          // Red 500
+const LIGHT_GREEN_COLOR = '#C0D588';  // Light Green 500
+const PINK_COLOR = '#F515E0';         // Pink 500
+
+export class ActualFramesTrack extends SliceTrackBase {
+  private maxDur = 0n;
+
+  constructor(
+      private engine: EngineProxy, maxDepth: number, trackKey: string,
+      private trackIds: number[], namespace?: string) {
+    super(maxDepth, trackKey, 'actual_frame_timeline_slice', namespace);
+  }
+
+  async onBoundsChange(start: time, end: time, resolution: duration):
+      Promise<SliceData> {
+    if (this.maxDur === 0n) {
+      const maxDurResult = await this.engine.query(`
+    select
+      max(iif(dur = -1, (SELECT end_ts FROM trace_bounds) - ts, dur))
+        as maxDur
+    from experimental_slice_layout
+    where filter_track_ids = '${this.trackIds.join(',')}'
+  `);
+      this.maxDur = maxDurResult.firstRow({maxDur: LONG_NULL}).maxDur || 0n;
+    }
+
+    const rawResult = await this.engine.query(`
+  SELECT
+    (s.ts + ${resolution / 2n}) / ${resolution} * ${resolution} as tsq,
+    s.ts as ts,
+    max(iif(s.dur = -1, (SELECT end_ts FROM trace_bounds) - s.ts, s.dur))
+        as dur,
+    s.layout_depth as layoutDepth,
+    s.name as name,
+    s.id as id,
+    s.dur = 0 as isInstant,
+    s.dur = -1 as isIncomplete,
+    CASE afs.jank_tag
+      WHEN 'Self Jank' THEN '${RED_COLOR}'
+      WHEN 'Other Jank' THEN '${YELLOW_COLOR}'
+      WHEN 'Dropped Frame' THEN '${BLUE_COLOR}'
+      WHEN 'Buffer Stuffing' THEN '${LIGHT_GREEN_COLOR}'
+      WHEN 'SurfaceFlinger Stuffing' THEN '${LIGHT_GREEN_COLOR}'
+      WHEN 'No Jank' THEN '${GREEN_COLOR}'
+      ELSE '${PINK_COLOR}'
+    END as color
+  from experimental_slice_layout s
+  join actual_frame_timeline_slice afs using(id)
+  where
+    filter_track_ids = '${this.trackIds.join(',')}' and
+    s.ts >= ${start - this.maxDur} and
+    s.ts <= ${end}
+  group by tsq, s.layout_depth
+  order by tsq, s.layout_depth
+`);
+
+    const numRows = rawResult.numRows();
+    const slices: SliceData = {
+      start,
+      end,
+      resolution,
+      length: numRows,
+      strings: [],
+      sliceIds: new Float64Array(numRows),
+      starts: new BigInt64Array(numRows),
+      ends: new BigInt64Array(numRows),
+      depths: new Uint16Array(numRows),
+      titles: new Uint16Array(numRows),
+      colors: new Uint16Array(numRows),
+      isInstant: new Uint16Array(numRows),
+      isIncomplete: new Uint16Array(numRows),
+    };
+
+    const stringIndexes = new Map<string, number>();
+    function internString(str: string) {
+      let idx = stringIndexes.get(str);
+      if (idx !== undefined) return idx;
+      idx = slices.strings.length;
+      slices.strings.push(str);
+      stringIndexes.set(str, idx);
+      return idx;
+    }
+
+    const it = rawResult.iter({
+      'tsq': LONG,
+      'ts': LONG,
+      'dur': LONG,
+      'layoutDepth': NUM,
+      'id': NUM,
+      'name': STR,
+      'isInstant': NUM,
+      'isIncomplete': NUM,
+      'color': STR,
+    });
+    for (let i = 0; it.valid(); i++, it.next()) {
+      const startQ = it.tsq;
+      const start = it.ts;
+      const dur = it.dur;
+      const end = start + dur;
+      const minEnd = startQ + resolution;
+      const endQ = BIMath.max(BIMath.quant(end, resolution), minEnd);
+
+      slices.starts[i] = startQ;
+      slices.ends[i] = endQ;
+      slices.depths[i] = it.layoutDepth;
+      slices.titles[i] = internString(it.name);
+      slices.colors![i] = internString(it.color);
+      slices.sliceIds[i] = it.id;
+      slices.isInstant[i] = it.isInstant;
+      slices.isIncomplete[i] = it.isIncomplete;
+    }
+    return slices;
+  }
+}
diff --git a/ui/src/tracks/actual_frames/actual_frames_track_v2.ts b/ui/src/tracks/actual_frames/actual_frames_track_v2.ts
new file mode 100644
index 0000000..c30e673
--- /dev/null
+++ b/ui/src/tracks/actual_frames/actual_frames_track_v2.ts
@@ -0,0 +1,96 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {HSLColor} from '../../common/color';
+import {ColorScheme, makeColorScheme} from '../../common/colorizer';
+import {
+  NAMED_ROW,
+  NamedSliceTrack,
+  NamedSliceTrackTypes,
+} from '../../frontend/named_slice_track';
+import {EngineProxy, Slice, STR_NULL} from '../../public';
+
+const BLUE = makeColorScheme(new HSLColor('#03A9F4'));    // Blue 500
+const GREEN = makeColorScheme(new HSLColor('#4CAF50'));   // Green 500
+const YELLOW = makeColorScheme(new HSLColor('#FFEB3B'));  // Yellow 500
+const RED = makeColorScheme(new HSLColor('#FF5722'));     // Red 500
+const LIGHT_GREEN =
+    makeColorScheme(new HSLColor('#C0D588'));           // Light Green 500
+const PINK = makeColorScheme(new HSLColor('#F515E0'));  // Pink 500
+
+export const ACTUAL_FRAME_ROW = {
+  // Base columns (tsq, ts, dur, id, depth).
+  ...NAMED_ROW,
+
+  // Chrome-specific columns.
+  jankTag: STR_NULL,
+};
+export type ActualFrameRow = typeof ACTUAL_FRAME_ROW;
+
+export interface ActualFrameTrackTypes extends NamedSliceTrackTypes {
+  row: ActualFrameRow;
+}
+
+export class ActualFramesTrack extends NamedSliceTrack<ActualFrameTrackTypes> {
+  constructor(
+      engine: EngineProxy, maxDepth: number, trackKey: string,
+      private trackIds: number[]) {
+    super({engine, trackKey});
+    this.sliceLayout.maxDepth = maxDepth + 1;
+  }
+
+  // This is used by the base class to call iter().
+  getRowSpec() {
+    return ACTUAL_FRAME_ROW;
+  }
+
+  getSqlSource(): string {
+    return `
+      SELECT
+        s.ts as ts,
+        s.dur as dur,
+        s.layout_depth as depth,
+        s.name as name,
+        s.id as id,
+        afs.jank_tag as jankTag
+      from experimental_slice_layout s
+      join actual_frame_timeline_slice afs using(id)
+      where
+        filter_track_ids = '${this.trackIds.join(',')}'
+    `;
+  }
+
+  rowToSlice(row: ActualFrameRow): Slice {
+    const baseSlice = super.rowToSlice(row);
+    return {...baseSlice, colorScheme: getColorSchemeForJank(row.jankTag)};
+  }
+}
+
+function getColorSchemeForJank(jankTag: string|null): ColorScheme {
+  switch (jankTag) {
+    case 'Self Jank':
+      return RED;
+    case 'Other Jank':
+      return YELLOW;
+    case 'Dropped Frame':
+      return BLUE;
+    case 'Buffer Stuffing':
+    case 'SurfaceFlinger Stuffing':
+      return LIGHT_GREEN;
+    case 'No Jank':
+      return GREEN;
+    default:
+      return PINK;
+  }
+}
diff --git a/ui/src/tracks/actual_frames/index.ts b/ui/src/tracks/actual_frames/index.ts
index fbc3cf7..8fcd597 100644
--- a/ui/src/tracks/actual_frames/index.ts
+++ b/ui/src/tracks/actual_frames/index.ts
@@ -12,11 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {BigintMath as BIMath} from '../../base/bigint_math';
-import {duration, time} from '../../base/time';
-import {SliceData, SliceTrackBase} from '../../frontend/slice_track_base';
 import {
-  EngineProxy,
   Plugin,
   PluginContext,
   PluginContextTrace,
@@ -24,134 +20,19 @@
 } from '../../public';
 import {getTrackName} from '../../public/utils';
 import {
-  LONG,
-  LONG_NULL,
   NUM,
   NUM_NULL,
   STR,
   STR_NULL,
 } from '../../trace_processor/query_result';
 
+import {ActualFramesTrack} from './actual_frames_track';
+import {
+  ActualFramesTrack as ActualFramesTrackV2,
+} from './actual_frames_track_v2';
+
 export const ACTUAL_FRAMES_SLICE_TRACK_KIND = 'ActualFramesSliceTrack';
 
-const BLUE_COLOR = '#03A9F4';         // Blue 500
-const GREEN_COLOR = '#4CAF50';        // Green 500
-const YELLOW_COLOR = '#FFEB3B';       // Yellow 500
-const RED_COLOR = '#FF5722';          // Red 500
-const LIGHT_GREEN_COLOR = '#C0D588';  // Light Green 500
-const PINK_COLOR = '#F515E0';         // Pink 500
-
-class SliceTrack extends SliceTrackBase {
-  private maxDur = 0n;
-
-  constructor(
-      private engine: EngineProxy, maxDepth: number, trackKey: string,
-      private trackIds: number[], namespace?: string) {
-    super(maxDepth, trackKey, 'actual_frame_timeline_slice', namespace);
-  }
-
-  async onBoundsChange(start: time, end: time, resolution: duration):
-      Promise<SliceData> {
-    if (this.maxDur === 0n) {
-      const maxDurResult = await this.engine.query(`
-    select
-      max(iif(dur = -1, (SELECT end_ts FROM trace_bounds) - ts, dur))
-        as maxDur
-    from experimental_slice_layout
-    where filter_track_ids = '${this.trackIds.join(',')}'
-  `);
-      this.maxDur = maxDurResult.firstRow({maxDur: LONG_NULL}).maxDur || 0n;
-    }
-
-    const rawResult = await this.engine.query(`
-  SELECT
-    (s.ts + ${resolution / 2n}) / ${resolution} * ${resolution} as tsq,
-    s.ts as ts,
-    max(iif(s.dur = -1, (SELECT end_ts FROM trace_bounds) - s.ts, s.dur))
-        as dur,
-    s.layout_depth as layoutDepth,
-    s.name as name,
-    s.id as id,
-    s.dur = 0 as isInstant,
-    s.dur = -1 as isIncomplete,
-    CASE afs.jank_tag
-      WHEN 'Self Jank' THEN '${RED_COLOR}'
-      WHEN 'Other Jank' THEN '${YELLOW_COLOR}'
-      WHEN 'Dropped Frame' THEN '${BLUE_COLOR}'
-      WHEN 'Buffer Stuffing' THEN '${LIGHT_GREEN_COLOR}'
-      WHEN 'SurfaceFlinger Stuffing' THEN '${LIGHT_GREEN_COLOR}'
-      WHEN 'No Jank' THEN '${GREEN_COLOR}'
-      ELSE '${PINK_COLOR}'
-    END as color
-  from experimental_slice_layout s
-  join actual_frame_timeline_slice afs using(id)
-  where
-    filter_track_ids = '${this.trackIds.join(',')}' and
-    s.ts >= ${start - this.maxDur} and
-    s.ts <= ${end}
-  group by tsq, s.layout_depth
-  order by tsq, s.layout_depth
-`);
-
-    const numRows = rawResult.numRows();
-    const slices: SliceData = {
-      start,
-      end,
-      resolution,
-      length: numRows,
-      strings: [],
-      sliceIds: new Float64Array(numRows),
-      starts: new BigInt64Array(numRows),
-      ends: new BigInt64Array(numRows),
-      depths: new Uint16Array(numRows),
-      titles: new Uint16Array(numRows),
-      colors: new Uint16Array(numRows),
-      isInstant: new Uint16Array(numRows),
-      isIncomplete: new Uint16Array(numRows),
-    };
-
-    const stringIndexes = new Map<string, number>();
-    function internString(str: string) {
-      let idx = stringIndexes.get(str);
-      if (idx !== undefined) return idx;
-      idx = slices.strings.length;
-      slices.strings.push(str);
-      stringIndexes.set(str, idx);
-      return idx;
-    }
-
-    const it = rawResult.iter({
-      'tsq': LONG,
-      'ts': LONG,
-      'dur': LONG,
-      'layoutDepth': NUM,
-      'id': NUM,
-      'name': STR,
-      'isInstant': NUM,
-      'isIncomplete': NUM,
-      'color': STR,
-    });
-    for (let i = 0; it.valid(); i++, it.next()) {
-      const startQ = it.tsq;
-      const start = it.ts;
-      const dur = it.dur;
-      const end = start + dur;
-      const minEnd = startQ + resolution;
-      const endQ = BIMath.max(BIMath.quant(end, resolution), minEnd);
-
-      slices.starts[i] = startQ;
-      slices.ends[i] = endQ;
-      slices.depths[i] = it.layoutDepth;
-      slices.titles[i] = internString(it.name);
-      slices.colors![i] = internString(it.color);
-      slices.sliceIds[i] = it.id;
-      slices.isInstant[i] = it.isInstant;
-      slices.isIncomplete[i] = it.isIncomplete;
-    }
-    return slices;
-  }
-}
-
 class ActualFrames implements Plugin {
   onActivate(_ctx: PluginContext): void {}
 
@@ -211,7 +92,22 @@
         trackIds,
         kind: ACTUAL_FRAMES_SLICE_TRACK_KIND,
         track: ({trackKey}) => {
-          return new SliceTrack(
+          return new ActualFramesTrack(
+              engine,
+              maxDepth,
+              trackKey,
+              trackIds,
+          );
+        },
+      });
+
+      ctx.registerStaticTrack({
+        uri: `perfetto.ActualFrames#${upid}.v2`,
+        displayName,
+        trackIds,
+        kind: ACTUAL_FRAMES_SLICE_TRACK_KIND,
+        track: ({trackKey}) => {
+          return new ActualFramesTrackV2(
               engine,
               maxDepth,
               trackKey,
diff --git a/ui/src/tracks/async_slices/async_slice_track.ts b/ui/src/tracks/async_slices/async_slice_track.ts
new file mode 100644
index 0000000..5d6e25a
--- /dev/null
+++ b/ui/src/tracks/async_slices/async_slice_track.ts
@@ -0,0 +1,119 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {BigintMath as BIMath} from '../../base/bigint_math';
+import {duration, time} from '../../base/time';
+import {SliceData, SliceTrackBase} from '../../frontend/slice_track_base';
+import {EngineProxy} from '../../public';
+import {
+  LONG,
+  LONG_NULL,
+  NUM,
+  STR,
+} from '../../trace_processor/query_result';
+
+export const ASYNC_SLICE_TRACK_KIND = 'AsyncSliceTrack';
+
+export class AsyncSliceTrack extends SliceTrackBase {
+  private maxDurNs: duration = 0n;
+
+  constructor(
+      private engine: EngineProxy, maxDepth: number, trackKey: string,
+      private trackIds: number[], namespace?: string) {
+    // TODO is 'slice' right here?
+    super(maxDepth, trackKey, 'slice', namespace);
+  }
+
+  async onBoundsChange(start: time, end: time, resolution: duration):
+      Promise<SliceData> {
+    if (this.maxDurNs === 0n) {
+      const maxDurResult = await this.engine.query(`
+        select max(iif(dur = -1, (SELECT end_ts FROM trace_bounds) - ts,
+        dur)) as maxDur from experimental_slice_layout where filter_track_ids
+        = '${this.trackIds.join(',')}'
+      `);
+      this.maxDurNs = maxDurResult.firstRow({maxDur: LONG_NULL}).maxDur || 0n;
+    }
+
+    const queryRes = await this.engine.query(`
+      SELECT
+      (ts + ${resolution / 2n}) / ${resolution} * ${resolution} as tsq,
+        ts,
+        max(iif(dur = -1, (SELECT end_ts FROM trace_bounds) - ts, dur)) as
+        dur, layout_depth as depth, ifnull(name, '[null]') as name, id, dur =
+        0 as isInstant, dur = -1 as isIncomplete
+      from experimental_slice_layout
+      where
+        filter_track_ids = '${this.trackIds.join(',')}' and
+        ts >= ${start - this.maxDurNs} and
+        ts <= ${end}
+      group by tsq, layout_depth
+      order by tsq, layout_depth
+    `);
+
+    const numRows = queryRes.numRows();
+    const slices: SliceData = {
+      start,
+      end,
+      resolution,
+      length: numRows,
+      strings: [],
+      sliceIds: new Float64Array(numRows),
+      starts: new BigInt64Array(numRows),
+      ends: new BigInt64Array(numRows),
+      depths: new Uint16Array(numRows),
+      titles: new Uint16Array(numRows),
+      isInstant: new Uint16Array(numRows),
+      isIncomplete: new Uint16Array(numRows),
+    };
+
+    const stringIndexes = new Map<string, number>();
+    function internString(str: string) {
+      let idx = stringIndexes.get(str);
+      if (idx !== undefined) return idx;
+      idx = slices.strings.length;
+      slices.strings.push(str);
+      stringIndexes.set(str, idx);
+      return idx;
+    }
+
+    const it = queryRes.iter({
+      tsq: LONG,
+      ts: LONG,
+      dur: LONG,
+      depth: NUM,
+      name: STR,
+      id: NUM,
+      isInstant: NUM,
+      isIncomplete: NUM,
+    });
+    for (let row = 0; it.valid(); it.next(), row++) {
+      const startQ = it.tsq;
+      const start = it.ts;
+      const dur = it.dur;
+      const end = start + dur;
+      const minEnd = startQ + resolution;
+      const endQ = BIMath.max(BIMath.quant(end, resolution), minEnd);
+
+      slices.starts[row] = startQ;
+      slices.ends[row] = endQ;
+      slices.depths[row] = it.depth;
+      slices.titles[row] = internString(it.name);
+      slices.sliceIds[row] = it.id;
+      slices.isInstant[row] = it.isInstant;
+      slices.isIncomplete[row] = it.isIncomplete;
+    }
+    return slices;
+  }
+}
diff --git a/ui/src/tracks/async_slices/async_slice_track_v2.ts b/ui/src/tracks/async_slices/async_slice_track_v2.ts
new file mode 100644
index 0000000..4e87c9b
--- /dev/null
+++ b/ui/src/tracks/async_slices/async_slice_track_v2.ts
@@ -0,0 +1,45 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {NamedSliceTrack} from '../../frontend/named_slice_track';
+import {NewTrackArgs} from '../../frontend/track';
+import {Slice} from '../../public';
+
+export class AsyncSliceTrackV2 extends NamedSliceTrack {
+  constructor(
+      args: NewTrackArgs, maxDepth: number, private trackIds: number[]) {
+    super(args);
+    this.sliceLayout.maxDepth = maxDepth + 1;
+  }
+
+  getSqlSource(): string {
+    return `
+    select
+      ts,
+      dur,
+      layout_depth as depth,
+      ifnull(name, '[null]') as name,
+      id,
+      thread_dur as threadDur
+    from experimental_slice_layout
+    where filter_track_ids = '${this.trackIds.join(',')}'
+    `;
+  }
+
+  onUpdatedSlices(slices: Slice[]) {
+    for (const slice of slices) {
+      slice.isHighlighted = (slice === this.hoveredSlice);
+    }
+  }
+}
diff --git a/ui/src/tracks/async_slices/index.ts b/ui/src/tracks/async_slices/index.ts
index 97d43fa..eb3ad88 100644
--- a/ui/src/tracks/async_slices/index.ts
+++ b/ui/src/tracks/async_slices/index.ts
@@ -12,11 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {BigintMath as BIMath} from '../../base/bigint_math';
-import {duration, time} from '../../base/time';
-import {SliceData, SliceTrackBase} from '../../frontend/slice_track_base';
 import {
-  EngineProxy,
   Plugin,
   PluginContext,
   PluginContextTrace,
@@ -24,109 +20,17 @@
 } from '../../public';
 import {getTrackName} from '../../public/utils';
 import {
-  LONG,
-  LONG_NULL,
   NUM,
   NUM_NULL,
   STR,
   STR_NULL,
 } from '../../trace_processor/query_result';
 
+import {AsyncSliceTrack} from './async_slice_track';
+import {AsyncSliceTrackV2} from './async_slice_track_v2';
+
 export const ASYNC_SLICE_TRACK_KIND = 'AsyncSliceTrack';
 
-class AsyncSliceTrack extends SliceTrackBase {
-  private maxDurNs: duration = 0n;
-
-  constructor(
-      private engine: EngineProxy, maxDepth: number, trackKey: string,
-      private trackIds: number[], namespace?: string) {
-    // TODO is 'slice' right here?
-    super(maxDepth, trackKey, 'slice', namespace);
-  }
-
-  async onBoundsChange(start: time, end: time, resolution: duration):
-      Promise<SliceData> {
-    if (this.maxDurNs === 0n) {
-      const maxDurResult = await this.engine.query(`
-        select max(iif(dur = -1, (SELECT end_ts FROM trace_bounds) - ts,
-        dur)) as maxDur from experimental_slice_layout where filter_track_ids
-        = '${this.trackIds.join(',')}'
-      `);
-      this.maxDurNs = maxDurResult.firstRow({maxDur: LONG_NULL}).maxDur || 0n;
-    }
-
-    const queryRes = await this.engine.query(`
-      SELECT
-      (ts + ${resolution / 2n}) / ${resolution} * ${resolution} as tsq,
-        ts,
-        max(iif(dur = -1, (SELECT end_ts FROM trace_bounds) - ts, dur)) as
-        dur, layout_depth as depth, ifnull(name, '[null]') as name, id, dur =
-        0 as isInstant, dur = -1 as isIncomplete
-      from experimental_slice_layout
-      where
-        filter_track_ids = '${this.trackIds.join(',')}' and
-        ts >= ${start - this.maxDurNs} and
-        ts <= ${end}
-      group by tsq, layout_depth
-      order by tsq, layout_depth
-    `);
-
-    const numRows = queryRes.numRows();
-    const slices: SliceData = {
-      start,
-      end,
-      resolution,
-      length: numRows,
-      strings: [],
-      sliceIds: new Float64Array(numRows),
-      starts: new BigInt64Array(numRows),
-      ends: new BigInt64Array(numRows),
-      depths: new Uint16Array(numRows),
-      titles: new Uint16Array(numRows),
-      isInstant: new Uint16Array(numRows),
-      isIncomplete: new Uint16Array(numRows),
-    };
-
-    const stringIndexes = new Map<string, number>();
-    function internString(str: string) {
-      let idx = stringIndexes.get(str);
-      if (idx !== undefined) return idx;
-      idx = slices.strings.length;
-      slices.strings.push(str);
-      stringIndexes.set(str, idx);
-      return idx;
-    }
-
-    const it = queryRes.iter({
-      tsq: LONG,
-      ts: LONG,
-      dur: LONG,
-      depth: NUM,
-      name: STR,
-      id: NUM,
-      isInstant: NUM,
-      isIncomplete: NUM,
-    });
-    for (let row = 0; it.valid(); it.next(), row++) {
-      const startQ = it.tsq;
-      const start = it.ts;
-      const dur = it.dur;
-      const end = start + dur;
-      const minEnd = startQ + resolution;
-      const endQ = BIMath.max(BIMath.quant(end, resolution), minEnd);
-
-      slices.starts[row] = startQ;
-      slices.ends[row] = endQ;
-      slices.depths[row] = it.depth;
-      slices.titles[row] = internString(it.name);
-      slices.sliceIds[row] = it.id;
-      slices.isInstant[row] = it.isInstant;
-      slices.isIncomplete[row] = it.isIncomplete;
-    }
-    return slices;
-  }
-}
-
 class AsyncSlicePlugin implements Plugin {
   onActivate(_ctx: PluginContext) {}
 
@@ -220,6 +124,20 @@
           );
         },
       });
+
+      ctx.registerStaticTrack({
+        uri: `perfetto.AsyncSlices#${rawName}.v2`,
+        displayName,
+        trackIds,
+        kind: ASYNC_SLICE_TRACK_KIND,
+        track: ({trackKey}) => {
+          return new AsyncSliceTrackV2(
+              {engine, trackKey},
+              maxDepth,
+              trackIds,
+          );
+        },
+      });
     }
   }
 
@@ -288,6 +206,20 @@
           );
         },
       });
+
+      ctx.registerStaticTrack({
+        uri: `perfetto.AsyncSlices#process.${pid}${rawTrackIds}.v2`,
+        displayName,
+        trackIds,
+        kind: ASYNC_SLICE_TRACK_KIND,
+        track: ({trackKey}) => {
+          return new AsyncSliceTrackV2(
+              {engine: ctx.engine, trackKey},
+              maxDepth,
+              trackIds,
+          );
+        },
+      });
     }
   }
 }
diff --git a/ui/src/tracks/chrome_scroll_jank/event_latency_track.ts b/ui/src/tracks/chrome_scroll_jank/event_latency_track.ts
index 85650ed..b85116f 100644
--- a/ui/src/tracks/chrome_scroll_jank/event_latency_track.ts
+++ b/ui/src/tracks/chrome_scroll_jank/event_latency_track.ts
@@ -12,15 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {
-  getColorForSlice,
-} from '../../common/colorizer';
 import {globals} from '../../frontend/globals';
-import {
-  NamedSliceTrackTypes,
-} from '../../frontend/named_slice_track';
+import {NamedRow, NamedSliceTrackTypes} from '../../frontend/named_slice_track';
 import {NewTrackArgs} from '../../frontend/track';
-import {PrimaryTrackSortKey} from '../../public';
+import {PrimaryTrackSortKey, Slice} from '../../public';
 import {
   CustomSqlDetailsPanelConfig,
   CustomSqlTableDefConfig,
@@ -33,7 +28,7 @@
   ScrollJankPluginState,
   ScrollJankTracks as DecideTracksResult,
 } from './index';
-import {DEEP_RED_COLOR, RED_COLOR} from './jank_colors';
+import {JANK_COLOR} from './jank_colors';
 
 export const JANKY_LATENCY_NAME = 'Janky EventLatency';
 
@@ -80,17 +75,14 @@
     };
   }
 
-  // TODO(stevegolton): The janky event color should be assigned in rowToSlice.
-  // For example:
-
-  // rowToSlice(row: NamedSliceRow): Slice {
-  //   const baseSlice = super.rowToSlice(row);
-  //   if (baseSlice.title === JANKY_LATENCY_NAME) {
-  //     return {...baseSlice, baseColor: RED_COLOR};
-  //   } else {
-  //     return baseSlice;
-  //   }
-  // }
+  rowToSlice(row: NamedRow): Slice {
+    const baseSlice = super.rowToSlice(row);
+    if (baseSlice.title === JANKY_LATENCY_NAME) {
+      return {...baseSlice, colorScheme: JANK_COLOR};
+    } else {
+      return baseSlice;
+    }
+  }
 
   onUpdatedSlices(slices: EventLatencyTrackTypes['slice'][]) {
     for (const slice of slices) {
@@ -101,16 +93,7 @@
 
       const highlighted = globals.state.highlightedSliceId === slice.id;
       const hasFocus = highlighted || isSelected;
-
-      if (slice.title === JANKY_LATENCY_NAME) {
-        if (hasFocus) {
-          slice.baseColor = DEEP_RED_COLOR;
-        } else {
-          slice.baseColor = RED_COLOR;
-        }
-      } else {
-        slice.baseColor = getColorForSlice(slice.title, hasFocus);
-      }
+      slice.isHighlighted = !!hasFocus;
     }
     super.onUpdatedSlices(slices);
   }
diff --git a/ui/src/tracks/chrome_scroll_jank/jank_colors.ts b/ui/src/tracks/chrome_scroll_jank/jank_colors.ts
index f556f7d..22d508e 100644
--- a/ui/src/tracks/chrome_scroll_jank/jank_colors.ts
+++ b/ui/src/tracks/chrome_scroll_jank/jank_colors.ts
@@ -12,16 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {Color} from '../../common/colorizer';
+import {HSLColor} from '../../common/color';
+import {makeColorScheme} from '../../common/colorizer';
 
-export const RED_COLOR: Color = {
-  h: 7,
-  s: 100,
-  l: 46,
-};
-
-export const DEEP_RED_COLOR: Color = {
-  h: 11,
-  s: 100,
-  l: 32,
-};
+export const JANK_COLOR = makeColorScheme(new HSLColor([343, 100, 43]));
diff --git a/ui/src/tracks/chrome_scroll_jank/scroll_jank_v3_track.ts b/ui/src/tracks/chrome_scroll_jank/scroll_jank_v3_track.ts
index 70f6f0a..a5bf0aa 100644
--- a/ui/src/tracks/chrome_scroll_jank/scroll_jank_v3_track.ts
+++ b/ui/src/tracks/chrome_scroll_jank/scroll_jank_v3_track.ts
@@ -12,13 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {
-  getColorForSlice,
-} from '../../common/colorizer';
 import {globals} from '../../frontend/globals';
-import {NamedSliceTrackTypes} from '../../frontend/named_slice_track';
+import {NamedRow, NamedSliceTrackTypes} from '../../frontend/named_slice_track';
 import {NewTrackArgs, TrackBase} from '../../frontend/track';
-import {PrimaryTrackSortKey} from '../../public';
+import {PrimaryTrackSortKey, Slice} from '../../public';
 import {
   CustomSqlDetailsPanelConfig,
   CustomSqlTableDefConfig,
@@ -31,7 +28,7 @@
   ScrollJankPluginState,
   ScrollJankTracks as DecideTracksResult,
 } from './index';
-import {DEEP_RED_COLOR, RED_COLOR} from './jank_colors';
+import {JANK_COLOR} from './jank_colors';
 import {ScrollJankV3DetailsPanel} from './scroll_jank_v3_details_panel';
 
 const UNKNOWN_SLICE_NAME = 'Unknown';
@@ -87,6 +84,24 @@
     ScrollJankPluginState.getInstance().unregisterTrack(ScrollJankV3Track.kind);
   }
 
+  rowToSlice(row: NamedRow): Slice {
+    const slice = super.rowToSlice(row);
+
+    let stage = slice.title.substring(0, slice.title.indexOf(JANK_SLICE_NAME));
+    // Stage may include substage, in which case we use the substage for
+    // color selection.
+    const separator = '::';
+    if (stage.indexOf(separator) != -1) {
+      stage = stage.substring(stage.indexOf(separator) + separator.length);
+    }
+
+    if (stage == UNKNOWN_SLICE_NAME) {
+      return {...slice, colorScheme: JANK_COLOR};
+    } else {
+      return slice;
+    }
+  }
+
   onUpdatedSlices(slices: EventLatencyTrackTypes['slice'][]) {
     for (const slice of slices) {
       const currentSelection = globals.state.currentSelection;
@@ -96,25 +111,7 @@
 
       const highlighted = globals.state.highlightedSliceId === slice.id;
       const hasFocus = highlighted || isSelected;
-
-      let stage =
-          slice.title.substring(0, slice.title.indexOf(JANK_SLICE_NAME));
-      // Stage may include substage, in which case we use the substage for
-      // color selection.
-      const separator = '::';
-      if (stage.indexOf(separator) != -1) {
-        stage = stage.substring(stage.indexOf(separator) + separator.length);
-      }
-
-      if (stage == UNKNOWN_SLICE_NAME) {
-        if (hasFocus) {
-          slice.baseColor = DEEP_RED_COLOR;
-        } else {
-          slice.baseColor = RED_COLOR;
-        }
-      } else {
-        slice.baseColor = getColorForSlice(stage, hasFocus);
-      }
+      slice.isHighlighted = !!hasFocus;
     }
     super.onUpdatedSlices(slices);
   }
diff --git a/ui/src/tracks/chrome_slices/index.ts b/ui/src/tracks/chrome_slices/index.ts
index 615771f..ccf318b 100644
--- a/ui/src/tracks/chrome_slices/index.ts
+++ b/ui/src/tracks/chrome_slices/index.ts
@@ -201,14 +201,7 @@
 
   onUpdatedSlices(slices: ChromeSliceTrackTypes['slice'][]) {
     for (const slice of slices) {
-      if (slice === this.hoveredSlice) {
-        slice.color = {
-          ...slice.baseColor,
-          l: 30,
-        };
-      } else {
-        slice.color = slice.baseColor;
-      }
+      slice.isHighlighted = (slice === this.hoveredSlice);
     }
   }
 }
diff --git a/ui/src/tracks/cpu_freq/index.ts b/ui/src/tracks/cpu_freq/index.ts
index 83bc30b..c42b480 100644
--- a/ui/src/tracks/cpu_freq/index.ts
+++ b/ui/src/tracks/cpu_freq/index.ts
@@ -18,7 +18,7 @@
 import {duration, time, Time} from '../../base/time';
 import {calcCachedBucketSize} from '../../common/cache_utils';
 import {drawTrackHoverTooltip} from '../../common/canvas_utils';
-import {hueForCpu} from '../../common/colorizer';
+import {colorForCpu} from '../../common/colorizer';
 import {
   TrackAdapter,
   TrackControllerAdapter,
@@ -326,13 +326,14 @@
     const yLabel = `${num} ${kUnits[unitGroup + 1]}Hz`;
 
     // Draw the CPU frequency graph.
-    const hue = hueForCpu(this.config.cpu);
+    const color = colorForCpu(this.config.cpu);
     let saturation = 45;
     if (globals.state.hoveredUtid !== -1) {
       saturation = 0;
     }
-    ctx.fillStyle = `hsl(${hue}, ${saturation}%, 70%)`;
-    ctx.strokeStyle = `hsl(${hue}, ${saturation}%, 55%)`;
+
+    ctx.fillStyle = color.setHSL({s: saturation, l: 70}).cssString;
+    ctx.strokeStyle = color.setHSL({s: saturation, l: 55}).cssString;
 
     const calculateX = (timestamp: time) => {
       return Math.floor(visibleTimeScale.timeToPx(timestamp));
@@ -412,8 +413,8 @@
     if (this.hoveredValue !== undefined && this.hoveredTs !== undefined) {
       let text = `${this.hoveredValue.toLocaleString()}kHz`;
 
-      ctx.fillStyle = `hsl(${hue}, 45%, 75%)`;
-      ctx.strokeStyle = `hsl(${hue}, 45%, 45%)`;
+      ctx.fillStyle = color.setHSL({s: 45, l: 75}).cssString;
+      ctx.strokeStyle = color.setHSL({s: 45, l: 45}).cssString;
 
       const xStart = Math.floor(visibleTimeScale.timeToPx(this.hoveredTs));
       const xEnd = this.hoveredTsEnd === undefined ?
diff --git a/ui/src/tracks/cpu_profile/index.ts b/ui/src/tracks/cpu_profile/index.ts
index d03a842..143e057 100644
--- a/ui/src/tracks/cpu_profile/index.ts
+++ b/ui/src/tracks/cpu_profile/index.ts
@@ -16,7 +16,7 @@
 import {searchSegment} from '../../base/binary_search';
 import {duration, Time, time} from '../../base/time';
 import {Actions} from '../../common/actions';
-import {hslForSlice} from '../../common/colorizer';
+import {colorForSample} from '../../common/colorizer';
 import {
   TrackAdapter,
   TrackControllerAdapter,
@@ -24,7 +24,6 @@
 } from '../../common/track_adapter';
 import {TrackData} from '../../common/track_data';
 import {globals} from '../../frontend/globals';
-import {cachedHsluvToHex} from '../../frontend/hsluv_cache';
 import {TimeScale} from '../../frontend/time_scale';
 import {NewTrackArgs} from '../../frontend/track';
 import {
@@ -90,12 +89,6 @@
   }
 }
 
-function colorForSample(callsiteId: number, isHovered: boolean): string {
-  const [hue, saturation, lightness] =
-      hslForSlice(String(callsiteId), isHovered);
-  return cachedHsluvToHex(hue, saturation, lightness);
-}
-
 class CpuProfileTrack extends TrackAdapter<Config, Data> {
   static create(args: NewTrackArgs): CpuProfileTrack {
     return new CpuProfileTrack(args);
diff --git a/ui/src/tracks/cpu_slices/index.ts b/ui/src/tracks/cpu_slices/index.ts
index 45babfd..28497a2 100644
--- a/ui/src/tracks/cpu_slices/index.ts
+++ b/ui/src/tracks/cpu_slices/index.ts
@@ -24,6 +24,7 @@
   drawIncompleteSlice,
   drawTrackHoverTooltip,
 } from '../../common/canvas_utils';
+import {Color} from '../../common/color';
 import {colorForThread} from '../../common/colorizer';
 import {
   TrackAdapter,
@@ -285,20 +286,22 @@
       const isHovering = globals.state.hoveredUtid !== -1;
       const isThreadHovered = globals.state.hoveredUtid === utid;
       const isProcessHovered = globals.state.hoveredPid === pid;
-      const color = colorForThread(threadInfo);
+      const colorScheme = colorForThread(threadInfo);
+      let color: Color;
+      let textColor: Color;
       if (isHovering && !isThreadHovered) {
         if (!isProcessHovered) {
-          color.l = 90;
-          color.s = 0;
+          color = colorScheme.disabled;
+          textColor = colorScheme.textDisabled;
         } else {
-          color.l = Math.min(color.l + 30, 80);
-          color.s -= 20;
+          color = colorScheme.variant;
+          textColor = colorScheme.textVariant;
         }
       } else {
-        color.l = Math.min(color.l + 10, 60);
-        color.s -= 20;
+        color = colorScheme.base;
+        textColor = colorScheme.textBase;
       }
-      ctx.fillStyle = `hsl(${color.h}, ${color.s}%, ${color.l}%)`;
+      ctx.fillStyle = color.cssString;
       if (data.isIncomplete[i]) {
         drawIncompleteSlice(ctx, rectStart, MARGIN_TOP, rectWidth, RECT_HEIGHT);
       } else {
@@ -330,10 +333,10 @@
       title = cropText(title, charWidth, visibleWidth);
       subTitle = cropText(subTitle, charWidth, visibleWidth);
       const rectXCenter = left + visibleWidth / 2;
-      ctx.fillStyle = '#fff';
+      ctx.fillStyle = textColor.cssString;
       ctx.font = '12px Roboto Condensed';
       ctx.fillText(title, rectXCenter, MARGIN_TOP + RECT_HEIGHT / 2 - 1);
-      ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
+      ctx.fillStyle = textColor.setAlpha(0.6).cssString;
       ctx.font = '10px Roboto Condensed';
       ctx.fillText(subTitle, rectXCenter, MARGIN_TOP + RECT_HEIGHT / 2 + 9);
     }
@@ -352,7 +355,7 @@
         const rectWidth = Math.max(1, rectEnd - rectStart);
 
         // Draw a rectangle around the slice that is currently selected.
-        ctx.strokeStyle = `hsl(${color.h}, ${color.s}%, 30%)`;
+        ctx.strokeStyle = color.base.setHSL({l: 30}).cssString;
         ctx.beginPath();
         ctx.lineWidth = 3;
         ctx.strokeRect(rectStart, MARGIN_TOP - 1.5, rectWidth, RECT_HEIGHT + 3);
diff --git a/ui/src/tracks/expected_frames/expected_frames_track.ts b/ui/src/tracks/expected_frames/expected_frames_track.ts
new file mode 100644
index 0000000..665cfc5
--- /dev/null
+++ b/ui/src/tracks/expected_frames/expected_frames_track.ts
@@ -0,0 +1,123 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {BigintMath as BIMath} from '../../base/bigint_math';
+import {Duration, duration, time} from '../../base/time';
+import {SliceData, SliceTrackBase} from '../../frontend/slice_track_base';
+import {EngineProxy} from '../../public';
+import {
+  LONG,
+  LONG_NULL,
+  NUM,
+  STR,
+} from '../../trace_processor/query_result';
+
+export class ExpectedFramesTrack extends SliceTrackBase {
+  private maxDur = Duration.ZERO;
+
+  constructor(
+      private engine: EngineProxy, maxDepth: number, trackKey: string,
+      private trackIds: number[], namespace?: string) {
+    super(maxDepth, trackKey, '', namespace);
+  }
+
+  async onBoundsChange(start: time, end: time, resolution: duration):
+      Promise<SliceData> {
+    if (this.maxDur === Duration.ZERO) {
+      const maxDurResult = await this.engine.query(`
+        select max(iif(dur = -1, (SELECT end_ts FROM trace_bounds) - ts, dur))
+          as maxDur
+        from experimental_slice_layout
+        where filter_track_ids = '${this.trackIds.join(',')}'
+      `);
+      this.maxDur = maxDurResult.firstRow({maxDur: LONG_NULL}).maxDur || 0n;
+    }
+
+    const queryRes = await this.engine.query(`
+      SELECT
+        (ts + ${resolution / 2n}) / ${resolution} * ${resolution} as tsq,
+        ts,
+        max(iif(dur = -1, (SELECT end_ts FROM trace_bounds) - ts, dur)) as dur,
+        layout_depth as layoutDepth,
+        name,
+        id,
+        dur = 0 as isInstant,
+        dur = -1 as isIncomplete
+      from experimental_slice_layout
+      where
+        filter_track_ids = '${this.trackIds.join(',')}' and
+        ts >= ${start - this.maxDur} and
+        ts <= ${end}
+      group by tsq, layout_depth
+      order by tsq, layout_depth
+    `);
+
+    const numRows = queryRes.numRows();
+    const slices: SliceData = {
+      start,
+      end,
+      resolution,
+      length: numRows,
+      strings: [],
+      sliceIds: new Float64Array(numRows),
+      starts: new BigInt64Array(numRows),
+      ends: new BigInt64Array(numRows),
+      depths: new Uint16Array(numRows),
+      titles: new Uint16Array(numRows),
+      colors: new Uint16Array(numRows),
+      isInstant: new Uint16Array(numRows),
+      isIncomplete: new Uint16Array(numRows),
+    };
+
+    const stringIndexes = new Map<string, number>();
+    function internString(str: string) {
+      let idx = stringIndexes.get(str);
+      if (idx !== undefined) return idx;
+      idx = slices.strings.length;
+      slices.strings.push(str);
+      stringIndexes.set(str, idx);
+      return idx;
+    }
+    const greenIndex = internString('#4CAF50');
+
+    const it = queryRes.iter({
+      tsq: LONG,
+      ts: LONG,
+      dur: LONG,
+      layoutDepth: NUM,
+      id: NUM,
+      name: STR,
+      isInstant: NUM,
+      isIncomplete: NUM,
+    });
+    for (let row = 0; it.valid(); it.next(), ++row) {
+      const startQ = it.tsq;
+      const start = it.ts;
+      const dur = it.dur;
+      const end = start + dur;
+      const minEnd = startQ + resolution;
+      const endQ = BIMath.max(BIMath.quant(end, resolution), minEnd);
+
+      slices.starts[row] = startQ;
+      slices.ends[row] = endQ;
+      slices.depths[row] = it.layoutDepth;
+      slices.titles[row] = internString(it.name);
+      slices.sliceIds[row] = it.id;
+      slices.isInstant[row] = it.isInstant;
+      slices.isIncomplete[row] = it.isIncomplete;
+      slices.colors![row] = greenIndex;
+    }
+    return slices;
+  }
+}
diff --git a/ui/src/tracks/expected_frames/expected_frames_track_v2.ts b/ui/src/tracks/expected_frames/expected_frames_track_v2.ts
new file mode 100644
index 0000000..268b4b6
--- /dev/null
+++ b/ui/src/tracks/expected_frames/expected_frames_track_v2.ts
@@ -0,0 +1,48 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {HSLColor} from '../../common/color';
+import {makeColorScheme} from '../../common/colorizer';
+import {NamedRow, NamedSliceTrack} from '../../frontend/named_slice_track';
+import {EngineProxy, Slice} from '../../public';
+
+const GREEN = makeColorScheme(new HSLColor('#4CAF50'));  // Green 500
+
+export class ExpectedFramesTrack extends NamedSliceTrack {
+  constructor(
+      engine: EngineProxy, maxDepth: number, trackKey: string,
+      private trackIds: number[]) {
+    super({engine, trackKey});
+    this.sliceLayout.maxDepth = maxDepth + 1;
+  }
+
+  getSqlSource(): string {
+    return `
+      SELECT
+        ts,
+        dur,
+        layout_depth as depth,
+        name,
+        id
+      from experimental_slice_layout
+      where
+        filter_track_ids = '${this.trackIds.join(',')}'
+    `;
+  }
+
+  rowToSlice(row: NamedRow): Slice {
+    const baseSlice = super.rowToSlice(row);
+    return {...baseSlice, colorScheme: GREEN};
+  }
+}
diff --git a/ui/src/tracks/expected_frames/index.ts b/ui/src/tracks/expected_frames/index.ts
index 8cc1833..10b3f44 100644
--- a/ui/src/tracks/expected_frames/index.ts
+++ b/ui/src/tracks/expected_frames/index.ts
@@ -12,14 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {BigintMath as BIMath} from '../../base/bigint_math';
-import {Duration, duration, time} from '../../base/time';
+// import { NamedSliceTrack } from 'src/frontend/named_slice_track';
 import {
-  SliceData,
-  SliceTrackBase,
-} from '../../frontend/slice_track_base';
-import {
-  EngineProxy,
   Plugin,
   PluginContext,
   PluginContextTrace,
@@ -27,115 +21,19 @@
 } from '../../public';
 import {getTrackName} from '../../public/utils';
 import {
-  LONG,
-  LONG_NULL,
   NUM,
   NUM_NULL,
   STR,
   STR_NULL,
 } from '../../trace_processor/query_result';
 
+import {ExpectedFramesTrack} from './expected_frames_track';
+import {
+  ExpectedFramesTrack as ExpectedFramesTrackV2,
+} from './expected_frames_track_v2';
+
 export const EXPECTED_FRAMES_SLICE_TRACK_KIND = 'ExpectedFramesSliceTrack';
 
-class SliceTrack extends SliceTrackBase {
-  private maxDur = Duration.ZERO;
-
-  constructor(
-      private engine: EngineProxy, maxDepth: number, trackKey: string,
-      private trackIds: number[], namespace?: string) {
-    super(maxDepth, trackKey, '', namespace);
-  }
-
-  async onBoundsChange(start: time, end: time, resolution: duration):
-      Promise<SliceData> {
-    if (this.maxDur === Duration.ZERO) {
-      const maxDurResult = await this.engine.query(`
-        select max(iif(dur = -1, (SELECT end_ts FROM trace_bounds) - ts, dur))
-          as maxDur
-        from experimental_slice_layout
-        where filter_track_ids = '${this.trackIds.join(',')}'
-      `);
-      this.maxDur = maxDurResult.firstRow({maxDur: LONG_NULL}).maxDur || 0n;
-    }
-
-    const queryRes = await this.engine.query(`
-      SELECT
-        (ts + ${resolution / 2n}) / ${resolution} * ${resolution} as tsq,
-        ts,
-        max(iif(dur = -1, (SELECT end_ts FROM trace_bounds) - ts, dur)) as dur,
-        layout_depth as layoutDepth,
-        name,
-        id,
-        dur = 0 as isInstant,
-        dur = -1 as isIncomplete
-      from experimental_slice_layout
-      where
-        filter_track_ids = '${this.trackIds.join(',')}' and
-        ts >= ${start - this.maxDur} and
-        ts <= ${end}
-      group by tsq, layout_depth
-      order by tsq, layout_depth
-    `);
-
-    const numRows = queryRes.numRows();
-    const slices: SliceData = {
-      start,
-      end,
-      resolution,
-      length: numRows,
-      strings: [],
-      sliceIds: new Float64Array(numRows),
-      starts: new BigInt64Array(numRows),
-      ends: new BigInt64Array(numRows),
-      depths: new Uint16Array(numRows),
-      titles: new Uint16Array(numRows),
-      colors: new Uint16Array(numRows),
-      isInstant: new Uint16Array(numRows),
-      isIncomplete: new Uint16Array(numRows),
-    };
-
-    const stringIndexes = new Map<string, number>();
-    function internString(str: string) {
-      let idx = stringIndexes.get(str);
-      if (idx !== undefined) return idx;
-      idx = slices.strings.length;
-      slices.strings.push(str);
-      stringIndexes.set(str, idx);
-      return idx;
-    }
-    const greenIndex = internString('#4CAF50');
-
-    const it = queryRes.iter({
-      tsq: LONG,
-      ts: LONG,
-      dur: LONG,
-      layoutDepth: NUM,
-      id: NUM,
-      name: STR,
-      isInstant: NUM,
-      isIncomplete: NUM,
-    });
-    for (let row = 0; it.valid(); it.next(), ++row) {
-      const startQ = it.tsq;
-      const start = it.ts;
-      const dur = it.dur;
-      const end = start + dur;
-      const minEnd = startQ + resolution;
-      const endQ = BIMath.max(BIMath.quant(end, resolution), minEnd);
-
-      slices.starts[row] = startQ;
-      slices.ends[row] = endQ;
-      slices.depths[row] = it.layoutDepth;
-      slices.titles[row] = internString(it.name);
-      slices.sliceIds[row] = it.id;
-      slices.isInstant[row] = it.isInstant;
-      slices.isIncomplete[row] = it.isIncomplete;
-      slices.colors![row] = greenIndex;
-    }
-    return slices;
-  }
-}
-
 class ExpectedFramesPlugin implements Plugin {
   onActivate(_ctx: PluginContext): void {}
 
@@ -195,7 +93,22 @@
         trackIds,
         kind: EXPECTED_FRAMES_SLICE_TRACK_KIND,
         track: ({trackKey}) => {
-          return new SliceTrack(
+          return new ExpectedFramesTrack(
+              engine,
+              maxDepth,
+              trackKey,
+              trackIds,
+          );
+        },
+      });
+
+      ctx.registerStaticTrack({
+        uri: `perfetto.ExpectedFrames#${upid}.v2`,
+        displayName,
+        trackIds,
+        kind: EXPECTED_FRAMES_SLICE_TRACK_KIND,
+        track: ({trackKey}) => {
+          return new ExpectedFramesTrackV2(
               engine,
               maxDepth,
               trackKey,
diff --git a/ui/src/tracks/ftrace/index.ts b/ui/src/tracks/ftrace/index.ts
index 5dd646c..6a2a966 100644
--- a/ui/src/tracks/ftrace/index.ts
+++ b/ui/src/tracks/ftrace/index.ts
@@ -14,7 +14,7 @@
 
 import {duration, Time, time} from '../../base/time';
 import {BasicAsyncTrack} from '../../common/basic_async_track';
-import {colorForString} from '../../common/colorizer';
+import {colorForFtrace} from '../../common/colorizer';
 import {LIMIT, TrackData} from '../../common/track_data';
 import {checkerboardExcept} from '../../frontend/checkerboard';
 import {globals} from '../../frontend/globals';
@@ -116,13 +116,7 @@
 
     for (let i = 0; i < data.timestamps.length; i++) {
       const name = data.names[i];
-      const color = colorForString(name);
-      const hsl = `hsl(
-        ${color.h},
-        ${color.s - 20}%,
-        ${Math.min(color.l + 10, 60)}%
-      )`;
-      ctx.fillStyle = hsl;
+      ctx.fillStyle = colorForFtrace(name).base.cssString;
       const timestamp = Time.fromRaw(data.timestamps[i]);
       const xPos = Math.floor(visibleTimeScale.timeToPx(timestamp));
 
diff --git a/ui/src/tracks/process_summary/process_scheduling_track.ts b/ui/src/tracks/process_summary/process_scheduling_track.ts
index 0d65539..f7ad4c1 100644
--- a/ui/src/tracks/process_summary/process_scheduling_track.ts
+++ b/ui/src/tracks/process_summary/process_scheduling_track.ts
@@ -19,6 +19,7 @@
 import {Actions} from '../../common/actions';
 import {calcCachedBucketSize} from '../../common/cache_utils';
 import {drawTrackHoverTooltip} from '../../common/canvas_utils';
+import {Color} from '../../common/color';
 import {colorForThread} from '../../common/colorizer';
 import {
   TrackAdapter,
@@ -250,20 +251,18 @@
       const isHovering = globals.state.hoveredUtid !== -1;
       const isThreadHovered = globals.state.hoveredUtid === utid;
       const isProcessHovered = globals.state.hoveredPid === pid;
-      const color = colorForThread(threadInfo);
+      const colorScheme = colorForThread(threadInfo);
+      let color: Color;
       if (isHovering && !isThreadHovered) {
         if (!isProcessHovered) {
-          color.l = 90;
-          color.s = 0;
+          color = colorScheme.disabled;
         } else {
-          color.l = Math.min(color.l + 30, 80);
-          color.s -= 20;
+          color = colorScheme.variant;
         }
       } else {
-        color.l = Math.min(color.l + 10, 60);
-        color.s -= 20;
+        color = colorScheme.base;
       }
-      ctx.fillStyle = `hsl(${color.h}, ${color.s}%, ${color.l}%)`;
+      ctx.fillStyle = color.cssString;
       const y = MARGIN_TOP + cpuTrackHeight * cpu + cpu;
       ctx.fillRect(rectStart, y, rectEnd - rectStart, cpuTrackHeight);
     }
diff --git a/ui/src/tracks/process_summary/process_summary_track.ts b/ui/src/tracks/process_summary/process_summary_track.ts
index 0b29eea..a1b32c7 100644
--- a/ui/src/tracks/process_summary/process_summary_track.ts
+++ b/ui/src/tracks/process_summary/process_summary_track.ts
@@ -184,10 +184,7 @@
 
     // TODO(hjd): Dedupe this math.
     const color = colorForTid(this.config.pidForColor);
-    color.l = Math.min(color.l + 10, 60);
-    color.s -= 20;
-
-    ctx.fillStyle = `hsl(${color.h}, ${color.s}%, ${color.l}%)`;
+    ctx.fillStyle = color.base.cssString;
     ctx.beginPath();
     ctx.moveTo(lastX, lastY);
     for (let i = 0; i < data.utilizations.length; i++) {
diff --git a/ui/src/tracks/thread_state/index.ts b/ui/src/tracks/thread_state/index.ts
index 2293f98..457ef28 100644
--- a/ui/src/tracks/thread_state/index.ts
+++ b/ui/src/tracks/thread_state/index.ts
@@ -246,21 +246,15 @@
           currentSelection.kind === 'THREAD_STATE' &&
           currentSelection.id === data.ids[i];
 
-      const color = colorForState(state);
-
-      let colorStr = `hsl(${color.h},${color.s}%,${color.l}%)`;
-      if (color.a) {
-        colorStr = `hsla(${color.h},${color.s}%,${color.l}%, ${color.a})`;
-      }
-      ctx.fillStyle = colorStr;
-
+      const colorScheme = colorForState(state);
+      ctx.fillStyle = colorScheme.base.cssString;
       ctx.fillRect(rectStart, MARGIN_TOP, rectWidth, RECT_HEIGHT);
 
       // Don't render text when we have less than 10px to play with.
       if (rectWidth < 10 || state === 'Sleeping') continue;
       const title = cropText(state, charWidth, rectWidth);
       const rectXCenter = rectStart + rectWidth / 2;
-      ctx.fillStyle = color.l > 80 ? '#404040' : '#fff';
+      ctx.fillStyle = colorScheme.textBase.cssString;
       ctx.fillText(title, rectXCenter, MARGIN_TOP + RECT_HEIGHT / 2 + 3);
 
       if (isSelected) {
@@ -269,8 +263,7 @@
               Math.max(0 - EXCESS_WIDTH, timeScale.timeToPx(tStart));
           const rectEnd =
               Math.min(windowSpan.end + EXCESS_WIDTH, timeScale.timeToPx(tEnd));
-          const color = colorForState(state);
-          ctx.strokeStyle = `hsl(${color.h},${color.s}%,${color.l * 0.7}%)`;
+          ctx.strokeStyle = colorScheme.base.cssString;
           ctx.beginPath();
           ctx.lineWidth = 3;
           ctx.strokeRect(
diff --git a/ui/src/tracks/thread_state/thread_state_track_v2.ts b/ui/src/tracks/thread_state/thread_state_track_v2.ts
deleted file mode 100644
index f6216a6..0000000
--- a/ui/src/tracks/thread_state/thread_state_track_v2.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-// Copyright (C) 2021 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import {Actions} from '../../common/actions';
-import {Color, colorForState} from '../../common/colorizer';
-import {Selection} from '../../common/state';
-import {translateState} from '../../common/thread_state';
-import {
-  BASE_ROW,
-  BaseSliceTrack,
-  BaseSliceTrackTypes,
-  OnSliceClickArgs,
-} from '../../frontend/base_slice_track';
-import {globals} from '../../frontend/globals';
-import {
-  SLICE_LAYOUT_FLAT_DEFAULTS,
-  SliceLayout,
-} from '../../frontend/slice_layout';
-import {NewTrackArgs} from '../../frontend/track';
-import {NUM_NULL, STR} from '../../trace_processor/query_result';
-
-export const THREAD_STATE_ROW = {
-  ...BASE_ROW,
-  state: STR,
-  ioWait: NUM_NULL,
-};
-
-export type ThreadStateRow = typeof THREAD_STATE_ROW;
-
-export interface ThreadStateTrackTypes extends BaseSliceTrackTypes {
-  row: ThreadStateRow;
-}
-
-export class ThreadStateTrack extends BaseSliceTrack<ThreadStateTrackTypes> {
-  protected sliceLayout: SliceLayout = {...SLICE_LAYOUT_FLAT_DEFAULTS};
-
-  constructor(args: NewTrackArgs, private utid: number) {
-    super(args);
-  }
-
-  // This is used by the base class to call iter().
-  getRowSpec(): ThreadStateTrackTypes['row'] {
-    return THREAD_STATE_ROW;
-  }
-
-  getSqlSource(): string {
-    // Do not display states 'x' and 'S' (dead & sleeping).
-    const sql = `
-      select
-        id,
-        ts,
-        dur,
-        cpu,
-        state,
-        io_wait as ioWait,
-        0 as depth
-      from thread_state
-      where
-        utid = ${this.utid} and
-        state != 'x' and
-        state != 'S'
-    `;
-    return sql;
-  }
-
-  rowToSlice(row: ThreadStateTrackTypes['row']):
-      ThreadStateTrackTypes['slice'] {
-    const baseSlice = super.rowToSlice(row);
-    const ioWait = row.ioWait === null ? undefined : !!row.ioWait;
-    const title = translateState(row.state, ioWait);
-    const baseColor: Color = colorForState(title);
-    return {...baseSlice, title, baseColor};
-  }
-
-  onUpdatedSlices(slices: ThreadStateTrackTypes['slice'][]) {
-    for (const slice of slices) {
-      if (slice === this.hoveredSlice) {
-        slice.color = {
-          h: slice.baseColor.h,
-          s: slice.baseColor.s,
-          l: 30,
-        };
-      } else {
-        slice.color = slice.baseColor;
-      }
-    }
-  }
-
-  onSliceClick(args: OnSliceClickArgs<ThreadStateTrackTypes['slice']>) {
-    globals.makeSelection(Actions.selectThreadState({
-      id: args.slice.id,
-      trackKey: this.trackKey,
-    }));
-  }
-
-  protected isSelectionHandled(selection: Selection): boolean {
-    return selection.kind === 'THREAD_STATE';
-  }
-}
diff --git a/ui/src/tracks/thread_state/thread_state_v2.ts b/ui/src/tracks/thread_state/thread_state_v2.ts
index 2481dc9..c35ed5c 100644
--- a/ui/src/tracks/thread_state/thread_state_v2.ts
+++ b/ui/src/tracks/thread_state/thread_state_v2.ts
@@ -13,7 +13,7 @@
 // limitations under the License.
 
 import {Actions} from '../../common/actions';
-import {Color, colorForState} from '../../common/colorizer';
+import {colorForState} from '../../common/colorizer';
 import {Selection} from '../../common/state';
 import {translateState} from '../../common/thread_state';
 import {
@@ -35,19 +35,13 @@
   state: STR,
   ioWait: NUM_NULL,
 };
+
 export type ThreadStateRow = typeof THREAD_STATE_ROW;
 
-
-export interface ThreadStateTrackConfig {
-  utid: number;
-}
-
 export interface ThreadStateTrackTypes extends BaseSliceTrackTypes {
   row: ThreadStateRow;
 }
 
-export const THREAD_STATE_TRACK_V2_KIND = 'ThreadStateTrackV2';
-
 export class ThreadStateTrack extends BaseSliceTrack<ThreadStateTrackTypes> {
   protected sliceLayout: SliceLayout = {...SLICE_LAYOUT_FLAT_DEFAULTS};
 
@@ -62,11 +56,15 @@
 
   getSqlSource(): string {
     // Do not display states 'x' and 'S' (dead & sleeping).
+    // Note: Thread state tracks V1 basically ignores incomplete slices, faking
+    // their duration as 1 instead. Let's just do this here as well for now to
+    // achieve feature parity with tracks V1 and tackle the issue of overlapping
+    // incomplete slices later.
     return `
       select
         id,
         ts,
-        dur,
+        max(dur, 1) as dur,
         cpu,
         state,
         io_wait as ioWait,
@@ -84,21 +82,13 @@
     const baseSlice = super.rowToSlice(row);
     const ioWait = row.ioWait === null ? undefined : !!row.ioWait;
     const title = translateState(row.state, ioWait);
-    const baseColor: Color = colorForState(title);
-    return {...baseSlice, title, baseColor};
+    const color = colorForState(title);
+    return {...baseSlice, title, colorScheme: color};
   }
 
   onUpdatedSlices(slices: ThreadStateTrackTypes['slice'][]) {
     for (const slice of slices) {
-      if (slice === this.hoveredSlice) {
-        slice.color = {
-          h: slice.baseColor.h,
-          s: slice.baseColor.s,
-          l: 30,
-        };
-      } else {
-        slice.color = slice.baseColor;
-      }
+      slice.isHighlighted = (slice === this.hoveredSlice);
     }
   }