Merge "Add lit element based gr-diff elements"
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index db0308f..3fa84b1 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -300,6 +300,10 @@
 
 Matches if the diff between two patch sets was of a certain change kind.
 
+`REWORK` matches all kind of change kinds because any other change kind
+is just a more trivial version of a rework. This means setting
+`changekind:REWORK` is equivalent to setting `is:ANY`.
+
 `NO_CHANGE` is more trivial than a trivial rebase, no code change and
 a first parent update, hence this change kind is also matched by
 `changekind:TRIVIAL_REBASE`, `changekind:NO_CODE_CHANGE` and
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index ea8f6d2..9fd5b1b 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -142,7 +142,7 @@
 [[receive.requireContributorAgreement]]receive.requireContributorAgreement::
 +
 Controls whether or not a user must complete a contributor agreement before
-they can upload changes. Default is `INHERIT`. If `All-Project` enables this
+they can upload changes. Default is `INHERIT`. If `All-Projects` enables this
 option then the dependent project must set it to false if users are not
 required to sign a contributor agreement prior to submitting changes for that
 specific project. To use that feature the global option in `gerrit.config`
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
index 3e740a4..2686f39 100644
--- a/Documentation/config-submit-requirements.txt
+++ b/Documentation/config-submit-requirements.txt
@@ -259,6 +259,47 @@
   canOverrideInChildProjects = true
 ----
 
+
+[[test-submit-requirements]]
+== Testing Submit Requirements
+
+The link:rest-api-changes.html#check-submit-requirement[Check Submit Requirement]
+change endpoint can be used to test submit requirements on any change. Users
+are encouraged to test submit requirements before adding them to the project
+to ensure they work as intended.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/check.submit_requirement HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+    {
+      "name": "Code-Review",
+      "submittability_expression": "label:Code-Review=+2"
+    }
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "name": "Code-Review",
+    "status": "SATISFIED",
+    "submittability_expression_result": {
+      "expression": "label:Code-Review=+2",
+      "fulfilled": true,
+      "passingAtoms": [
+        "label:Code-Review=+2"
+      ]
+    },
+    "is_legacy": false
+  }
+----
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-e2e-tests.txt b/Documentation/dev-e2e-tests.txt
index 802b484..8e5463d 100644
--- a/Documentation/dev-e2e-tests.txt
+++ b/Documentation/dev-e2e-tests.txt
@@ -173,6 +173,9 @@
 * `-Dcom.google.gerrit.scenarios.ssh_port=29418`
 * `-Dcom.google.gerrit.scenarios.http_port=8080`
 * `-Dcom.google.gerrit.scenarios.http_scheme=http`
+* `-Dcom.google.gerrit.scenarios.username=admin`
+* `-Dcom.google.gerrit.scenarios.replica_hostname=localhost`
+* `-Dcom.google.gerrit.scenarios.project_prefix=`
 
 Above, the properties can be set with values matching specific deployment topologies under test.
 The name of the property corresponds to the uppercase keyword found in the json file. For example,
@@ -194,6 +197,13 @@
 That whole replication time depends on the system under test. Therefore, this property here should
 be set to a value high enough, so that the test checks for a done replication at the right time.
 
+==== Context path
+
+The `context_path` property allows test scenarios to send Gerrit REST requests to Gerrit instances
+that use a context path in the URL. Its default is no context path and can be set using another value:
+
+* `-Dcom.google.gerrit.scenarios.context_path=/context`
+
 ==== Automatic properties
 
 The link:#_input_file[example keywords,role=external,window=_blank] also include `_PROJECT`,
diff --git a/Documentation/pg-plugin-checks-api.txt b/Documentation/pg-plugin-checks-api.txt
index 4e93da1..8786cc4 100644
--- a/Documentation/pg-plugin-checks-api.txt
+++ b/Documentation/pg-plugin-checks-api.txt
@@ -24,8 +24,8 @@
 Here are some examples of open source plugins that make use of the Checks API:
 
 * link:https://gerrit.googlesource.com/plugins/checks/+/master/gr-checks/plugin.js[Gerrit Checks Plugin]
-* link:https://chromium.googlesource.com/infra/gerrit-plugins/buildbucket/+/master/src/main/resources/static/buildbucket.js[Chromium Buildbucket Plugin]
-* link:https://chromium.googlesource.com/infra/gerrit-plugins/code-coverage/+/master/src/main/resources/static/chromium-coverage.js[Chromium Coverage Plugin]
+* link:https://chromium.googlesource.com/infra/gerrit-plugins/buildbucket/+/main/web/plugin.ts[Chromium Buildbucket Plugin]
+* link:https://chromium.googlesource.com/infra/gerrit-plugins/code-coverage/+/main/web/plugin.ts[Chromium Coverage Plugin]
 
 [[register]]
 == register
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index 45a39d8..991f36c 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -388,7 +388,7 @@
 |`revision`           ||
 The revision of the `refs/meta/config` branch from which the access
 rights were loaded.
-|`inherits_from`      |not set for the `All-Project` project|
+|`inherits_from`      |not set for the `All-Projects` project|
 The parent project from which permissions are inherited as a
 link:rest-api-projects.html#project-info[ProjectInfo] entity.
 |`local`              ||
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/AbandonChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/AbandonChange.json
index 665cc4d..9d24b5b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/AbandonChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/AbandonChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "number": "NUMBER"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json
index 665cc4d..9d24b5b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ApproveChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "number": "NUMBER"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json
index 5b892aa..594903a 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckMasterBranchReplica1.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT1/a/projects/PROJECT/branches/master"
+    "url": "HTTP_SCHEME://REPLICA_HOSTNAME:HTTP_PORT1/a/projects/PROJECT/branches/master"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json
index f15ddae..5221d95 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT1/_PROJECT",
+    "url": "HTTP_SCHEME://REPLICA_HOSTNAME:HTTP_PORT1/_PROJECT",
     "cmd": "clone"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json
index 467661b..75e895e 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/config/server/caches/projects",
     "entries": "PROJECTS_ENTRIES"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
index 30f5f23..195ed09 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CloneUsingBothProtocols.json
@@ -1,10 +1,10 @@
 [
   {
-    "url": "ssh://admin@HOSTNAME:SSH_PORT/_PROJECT",
+    "url": "ssh://USERNAME@HOSTNAME:SSH_PORT/_PROJECT",
     "cmd": "clone"
   },
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/_PROJECT",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/_PROJECT",
     "cmd": "clone"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateBranch.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateBranch.json
index 5459f11..487cf02 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateBranch.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateBranch.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT/branches/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/PROJECT/branches/",
     "project": "PROJECT"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json
index 70e79ca..8b8a163 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "project": "PROJECT"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
index c141bb8..756313a 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CreateProject.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/PROJECT",
     "parent": "PARENT"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json
index 665cc4d..9d24b5b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "number": "NUMBER"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
index 5720f53..8bec9de 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/DeleteProject.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT/delete-project~delete"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/PROJECT/delete-project~delete"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json
index e30a2cf..c1dc674 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCache.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects/flush"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/config/server/caches/projects/flush"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json
index e30a2cf..c1dc674 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects/flush"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/config/server/caches/projects/flush"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json
index 86a3c28..4f6a104 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetMasterBranchRevision.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/PROJECT/branches/master"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/PROJECT/branches/master"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json
index e4e2643..e77d83b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/GetProjectsCacheEntries.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/config/server/caches/projects"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json
index f6350be..528ef3e 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/projects/"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json
index 30f5f23..195ed09 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.json
@@ -1,10 +1,10 @@
 [
   {
-    "url": "ssh://admin@HOSTNAME:SSH_PORT/_PROJECT",
+    "url": "ssh://USERNAME@HOSTNAME:SSH_PORT/_PROJECT",
     "cmd": "clone"
   },
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/_PROJECT",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/_PROJECT",
     "cmd": "clone"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/RestoreChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/RestoreChange.json
index 665cc4d..9d24b5b 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/RestoreChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/RestoreChange.json
@@ -1,6 +1,6 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/",
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/",
     "number": "NUMBER"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json
index 301c65b..13d5802 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChange.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/"
   }
 ]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChangeInBranch.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChangeInBranch.json
index 301c65b..13d5802 100644
--- a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChangeInBranch.json
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/SubmitChangeInBranch.json
@@ -1,5 +1,5 @@
 [
   {
-    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/changes/"
+    "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORTCONTEXT_PATH/a/changes/"
   }
 ]
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/AbandonChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/AbandonChange.scala
index d387a3e..d8ebf7f 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/AbandonChange.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/AbandonChange.scala
@@ -24,7 +24,6 @@
 
 class AbandonChange extends GerritSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
-  private val projectName = className
   private var numbersCopy: mutable.Queue[Int] = mutable.Queue[Int]()
   private var createChange: Option[CreateChange] = Some(new CreateChange(projectName))
 
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala
index ae4fa80..692d576 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala
@@ -22,7 +22,6 @@
 
 class CheckNewProjectReplica1 extends GitSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
-  private val projectName = className
 
   private lazy val replicationDuration = replicationDelay + SecondsPerWeightUnit
 
@@ -30,7 +29,6 @@
 
   override def replaceOverride(in: String): String = {
     var next = replaceProperty("http_port1", 8081, in)
-    next = replaceKeyWith("_project", projectName, next)
     super.replaceOverride(next)
   }
 
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
index c283861..d1d9c88 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
@@ -22,12 +22,8 @@
 
 class CloneUsingBothProtocols extends GitSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
-  private val projectName = className
   private val duration = 2 * numberOfUsers
 
-  override def replaceOverride(in: String): String = {
-    replaceKeyWith("_project", projectName, in)
-  }
 
   private val test: ScenarioBuilder = scenario(uniqueName)
       .feed(data)
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala
index 9fef2cf..a7bda3c 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala
@@ -22,7 +22,6 @@
 
 class FlushProjectsCache extends CacheFlushSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
-  private val projectName = className
 
   override def relativeRuntimeWeight = 2
 
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
index c199dd9..7946f05 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GerritSimulation.scala
@@ -23,6 +23,7 @@
 class GerritSimulation extends Simulation {
   implicit val conf: GatlingGitConfiguration = GatlingGitConfiguration()
 
+  private val defaultHostname: String = "localhost"
   protected val numberKey: String = "number"
 
   private val packageName = getClass.getPackage.getName
@@ -42,7 +43,7 @@
   protected val SecondsPerWeightUnit = 2
   val maxExecutionTime: Int = (SecondsPerWeightUnit * relativeRuntimeWeight * powerFactor).toInt
   private var cumulativeWaitTime = 0
-
+  protected var projectName: String = className
   /**
    * How long a scenario step should wait before starting to execute.
    * This is also registering that step's resulting wait time, so that time
@@ -73,16 +74,23 @@
       replaceProperty("parent", "All-Projects", parent.toString)
     case ("project", project) =>
       var precedes = replaceKeyWith("_project", className, project.toString)
-      precedes = replaceOverride(precedes)
+      precedes = replaceProperty("project", getFullProjectName(projectName), precedes)
       replaceProperty("project", precedes)
     case ("url", url) =>
       var in = replaceOverride(url.toString)
-      in = replaceProperty("hostname", "localhost", in)
+      in = replaceProperty("replica_hostname", getProperty("hostname", defaultHostname), in)
+      in = replaceProperty("hostname", defaultHostname, in)
       in = replaceProperty("http_port", 8080, in)
       in = replaceProperty("http_scheme", "http", in)
+      in = replaceProperty("username", "admin", in)
+      in = replaceProperty("context_path", "", in)
       replaceProperty("ssh_port", 29418, in)
   }
 
+  protected def getFullProjectName(projectName: String) {
+    getProperty("project_prefix", "") + projectName
+  }
+
   private def replaceProperty(term: String, in: String): String = {
     replaceProperty(term, term, in)
   }
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
index e2f13a4..5d5f5d5 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
@@ -15,6 +15,7 @@
 package com.google.gerrit.scenarios
 
 import java.io.{File, IOException}
+import java.net.URLEncoder
 
 import com.github.barbasa.gatling.git.GitRequestSession
 import com.github.barbasa.gatling.git.protocol.GitProtocol
@@ -29,6 +30,11 @@
   protected val gitRequest = new GitRequestBuilder(GitRequestSession("${cmd}", "${url}"))
   protected val gitProtocol: GitProtocol = GitProtocol()
 
+  override def replaceOverride(in: String): String = {
+    var next = replaceKeyWith("_project", URLEncoder.encode(getFullProjectName(projectName), "UTF-8"), in)
+    super.replaceOverride(next)
+  }
+
   after {
     Thread.sleep(5000)
     val path = conf.tmpBasePath
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala
index d6cb937..7d7bed7 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.scenarios
 
+import java.net.URLEncoder
+
 class ProjectSimulation extends GerritSimulation {
-  protected var projectName: String = "defaultTestProject"
+  projectName = "defaultTestProject"
 
   override def replaceOverride(in: String): String = {
-    replaceProperty("project", projectName, in)
+    replaceProperty("project", URLEncoder.encode(getFullProjectName(projectName), "UTF-8"), in)
   }
 }
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
index 0bd9e4a..c049f09 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
@@ -22,13 +22,9 @@
 
 class ReplayRecordsFromFeeder extends GitSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
-  private val projectName = className
 
   override def relativeRuntimeWeight = 30
 
-  override def replaceOverride(in: String): String = {
-    replaceKeyWith("_project", projectName, in)
-  }
 
   private val test: ScenarioBuilder = scenario(uniqueName)
       .repeat(10) {
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/RestoreChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/RestoreChange.scala
index 81096b0..756a239 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/RestoreChange.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/RestoreChange.scala
@@ -24,7 +24,6 @@
 
 class RestoreChange extends GerritSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
-  private val projectName = className
   private var numbersCopy: mutable.Queue[Int] = mutable.Queue[Int]()
 
   override def relativeRuntimeWeight = 10
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala
index 20be28a..49f0c4b 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala
@@ -23,7 +23,6 @@
 
 class SubmitChange extends GerritSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
-  private val projectName = className
   private var createChange = new CreateChange(projectName)
 
   override def relativeRuntimeWeight = 10
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChangeInBranch.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChangeInBranch.scala
index 9e1431b..ca618c3 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChangeInBranch.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChangeInBranch.scala
@@ -25,7 +25,6 @@
 class SubmitChangeInBranch extends GerritSimulation {
   private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
   private var changesCopy: mutable.Queue[Int] = mutable.Queue[Int]()
-  private val projectName = className
 
   override def relativeRuntimeWeight = 10
 
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 123a14c..d816d84 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -522,12 +522,6 @@
     return sort(contributorAgreements.values());
   }
 
-  public void remove(ContributorAgreement section) {
-    if (section != null) {
-      accessSections.remove(section.getName());
-    }
-  }
-
   public void replace(ContributorAgreement section) {
     ContributorAgreement.Builder ca = section.toBuilder();
     ca.setAutoVerify(resolve(section.getAutoVerify()));
@@ -1179,6 +1173,10 @@
               LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE));
       Set<Short> copyValues = new HashSet<>();
       for (String value : rc.getStringList(LABEL, name, KEY_COPY_VALUE)) {
+        if (value == null) {
+          // value is null if copyValue in project.config is set to an empty string
+          continue;
+        }
         try {
           short copyValue = Shorts.checkedCast(PermissionRule.parseInt(value));
           if (!copyValues.add(copyValue)) {
diff --git a/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java b/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
index 6866983..f519b16 100644
--- a/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
+++ b/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
@@ -38,6 +38,12 @@
       return true;
     }
 
+    // If the configured change kind (changeKind) is REWORK it means that all kind of change kinds
+    // should be matched, since any other change kind is just a more trivial version of a rework.
+    if (changeKind == ChangeKind.REWORK) {
+      return true;
+    }
+
     // If the actual change kind (ctx.changeKind()) is NO_CHANGE it is also matched if the
     // configured change kind (changeKind) is:
     // * TRIVIAL_REBASE: since NO_CHANGE is a special kind of a trivial rebase
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index edc1dc4..8dbef88 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -158,6 +158,16 @@
     testStickyOnAnyScore();
   }
 
+  @Test
+  public void stickyOnRework() throws Exception {
+    updateCodeReviewLabel(b -> b.setCopyCondition("changekind:REWORK"));
+
+    // changekind:REWORK should match all kind of changes so that approvals are always copied.
+    // This means setting changekind:REWORK is equivalent to setting is:ANY and we can do the same
+    // assertions for both cases.
+    testStickyOnAnyScore();
+  }
+
   private void testStickyOnAnyScore() throws Exception {
     for (ChangeKind changeKind :
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index e98b6bf..2b7d7af 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -1057,6 +1057,24 @@
             });
   }
 
+  @Test
+  public void readCopyValues_emptyValueIsIgnored() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add(
+                "project.config",
+                "[label \"CustomLabel\"]\n"
+                    + "  copyValue = 1\n"
+                    + "  copyValue = 2\n"
+                    + "  copyValue = \n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    Map<String, LabelType> labels = cfg.getLabelSections();
+    assertThat(labels.entrySet().iterator().next().getValue().getCopyValues())
+        .containsExactly((short) 1, (short) 2);
+  }
+
   private Path writeDefaultAllProjectsConfig(String... lines) throws IOException {
     Path dir = sitePaths.etc_dir.resolve(ALL_PROJECTS.get());
     Files.createDirectories(dir);
diff --git a/plugins/gitiles b/plugins/gitiles
index b62b109..557cca1 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit b62b1098cfc566f5edb9e9a3fed8be20210675f5
+Subproject commit 557cca12c1d39fc27cf8c6ce764c0ee091632b90
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index f1bbe90..8eaff5c 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -436,15 +436,17 @@
         'lit/attribute-value-entities': 'error',
         'lit/binding-positions': 'error',
         'lit/no-duplicate-template-bindings': 'error',
+        'lit/no-invalid-escape-sequences': 'error',
         'lit/no-invalid-html': 'error',
         'lit/no-legacy-template-syntax': 'error',
-        'lit/no-property-change-update': 'error',
-        'lit/no-invalid-escape-sequences': 'error',
         'lit/no-legacy-imports': 'error',
         'lit/no-private-properties': 'error',
+        'lit/no-property-change-update': 'error',
+        'lit/no-template-bind': 'error',
         'lit/no-useless-template-literals': 'error',
         'lit/no-value-attribute': 'error',
         'lit/prefer-static-styles': 'error',
+        'lit/quoted-expressions': ['error', 'never'],
       },
     },
   ],
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index aa50388..08e2e66 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -253,6 +253,7 @@
    * be a temporary setting until the experiment is concluded.
    */
   use_lit_components?: boolean;
+  show_sign_col?: boolean;
 }
 
 /**
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 01ff6ce..8cdd765 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -260,14 +260,19 @@
   NONE = 'NONE',
 }
 
-// TODO(TS): Many properties are omitted here, but they are required.
-// Add default values for missing properties.
-export function createDefaultPreferences() {
+export function createDefaultPreferences(): PreferencesInfo {
   return {
     changes_per_page: 25,
     diff_view: DiffViewMode.SIDE_BY_SIDE,
     size_bar_in_change_table: true,
-  } as PreferencesInfo;
+    my: [],
+    theme: AppTheme.LIGHT,
+    date_format: DateFormat.EURO,
+    time_format: TimeFormat.HHMM_24,
+    change_table: [],
+    email_strategy: EmailStrategy.ATTENTION_SET_ONLY,
+    default_base_for_merges: DefaultBase.AUTO_MERGE,
+  };
 }
 
 // These defaults should match the defaults in
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index 10ca808..fab8f68 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -187,7 +187,7 @@
   private renderAdminNav(item: NavLink) {
     return html`
       <li class="sectionTitle ${this.computeSelectedClass(item.view)}">
-        <a class="title" href="${this.computeLinkURL(item)}" rel="noopener"
+        <a class="title" href=${this.computeLinkURL(item)} rel="noopener"
           >${item.name}</a
         >
       </li>
@@ -198,8 +198,8 @@
 
   private renderAdminNavChild(child: SubsectionInterface) {
     return html`
-      <li class="${this.computeSelectedClass(child.view)}">
-        <a href="${this.computeLinkURL(child)}" rel="noopener">${child.name}</a>
+      <li class=${this.computeSelectedClass(child.view)}>
+        <a href=${this.computeLinkURL(child)} rel="noopener">${child.name}</a>
       </li>
     `;
   }
@@ -209,7 +209,7 @@
 
     return html`
       <!--If a section has a subsection, render that.-->
-      <li class="${this.computeSelectedClass(item.subsection.view)}">
+      <li class=${this.computeSelectedClass(item.subsection.view)}>
         ${this.renderAdminNavSubsectionUrl(item.subsection)}
       </li>
       <!--Loop through the links in the sub-section.-->
@@ -223,7 +223,7 @@
     if (!subsection!.url) return html`${subsection!.name}`;
 
     return html`
-      <a class="title" href="${this.computeLinkURL(subsection)}" rel="noopener">
+      <a class="title" href=${this.computeLinkURL(subsection)} rel="noopener">
         ${subsection!.name}</a
       >
     `;
@@ -237,7 +237,7 @@
           child.detailType
         )}"
       >
-        <a href="${this.computeLinkURL(child)}">${child.name}</a>
+        <a href=${this.computeLinkURL(child)}>${child.name}</a>
       </li>
     `;
   }
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 19e7aa4..c4ef8e6 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -136,8 +136,8 @@
   override render() {
     return html`
       <div class="main gr-form-styles read-only">
-        <div id="loading" class="${this.computeLoadingClass()}">Loading...</div>
-        <div id="loadedContent" class="${this.computeLoadingClass()}">
+        <div id="loading" class=${this.computeLoadingClass()}>Loading...</div>
+        <div id="loadedContent" class=${this.computeLoadingClass()}>
           <h1 id="Title" class="heading-1">${this.originalName}</h1>
           <h2 id="configurations" class="heading-2">General</h2>
           <div id="form">
@@ -287,9 +287,9 @@
           <span class="value">
             <gr-select
               id="visibleToAll"
-              .bindValue="${convertToString(
+              .bindValue=${convertToString(
                 Boolean(this.groupConfig?.options?.visible_to_all)
-              )}"
+              )}
               @bind-value-changed=${this.handleOptionsBindValueChanged}
             >
               <select ?disabled=${this.computeGroupDisabled()}>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
index 990b34d..f7ad3b8 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
@@ -108,10 +108,10 @@
     return html`
       <div class="main gr-form-styles read-only">
         <h1 id="Title" class="heading-1">Repository Commands</h1>
-        <div id="loading" class="${this.loading ? 'loading' : ''}">
+        <div id="loading" class=${this.loading ? 'loading' : ''}>
           Loading...
         </div>
-        <div id="loadedContent" class="${this.loading ? 'loading' : ''}">
+        <div id="loadedContent" class=${this.loading ? 'loading' : ''}>
           <h2 id="options" class="heading-2">Command</h2>
           <div id="form">
             <h3 class="heading-3">Create change</h3>
@@ -137,7 +137,7 @@
             <gr-endpoint-decorator name="repo-command">
               <gr-endpoint-param name="config" .value=${this.repoConfig}>
               </gr-endpoint-param>
-              <gr-endpoint-param name="repoName" .value="${this.repo}">
+              <gr-endpoint-param name="repoName" .value=${this.repo}>
               </gr-endpoint-param>
             </gr-endpoint-decorator>
           </div>
@@ -159,8 +159,8 @@
           <div class="main" slot="main">
             <gr-create-change-dialog
               id="createNewChangeModal"
-              .repoName="${this.repo}"
-              .privateByDefault="${this.repoConfig?.private_by_default}"
+              .repoName=${this.repo}
+              .privateByDefault=${this.repoConfig?.private_by_default}
               @can-create-change=${() => {
                 this.handleCanCreateChange();
               }}
@@ -178,7 +178,7 @@
     return html`
       <h3 class="heading-3">${this.repoConfig?.actions['gc']?.label}</h3>
       <gr-button
-        title="${this.repoConfig?.actions['gc']?.title || ''}"
+        title=${this.repoConfig?.actions['gc']?.title || ''}
         ?loading=${this.runningGC}
         @click=${() => this.handleRunningGC()}
       >
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
index 3a37971..c2d7615 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
@@ -90,7 +90,7 @@
               info => html`
                 <tr class="table">
                   <td class="name">
-                    <a href="${this._getUrl(info.project, info.id)}"
+                    <a href=${this._getUrl(info.project, info.id)}
                       >${info.path}</a
                     >
                   </td>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index 48983d7..07e89a4 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -133,7 +133,7 @@
               <td>Loading...</td>
             </tr>
           </tbody>
-          <tbody class="${this.computeLoadingClass(this.loading)}">
+          <tbody class=${this.computeLoadingClass(this.loading)}>
             ${this.renderRepoList()}
           </tbody>
         </table>
@@ -168,11 +168,11 @@
     return html`
       <tr class="table">
         <td class="name">
-          <a href="${this.computeRepoUrl(item.name)}">${item.name}</a>
+          <a href=${this.computeRepoUrl(item.name)}>${item.name}</a>
         </td>
         <td class="repositoryBrowser">${this.renderWebLinks(item)}</td>
         <td class="changesLink">
-          <a href="${this.computeChangesLink(item.name)}">view all</a>
+          <a href=${this.computeChangesLink(item.name)}>view all</a>
         </td>
         <td class="readOnly">
           ${item.state === ProjectState.READ_ONLY ? 'Y' : ''}
@@ -189,7 +189,7 @@
 
   private renderWebLink(link: WebLinkInfo) {
     return html`
-      <a href="${link.url}" class="webLink" rel="noopener" target="_blank">
+      <a href=${link.url} class="webLink" rel="noopener" target="_blank">
         ${link.name}
       </a>
     `;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
index f9f760c..d5e5027 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
@@ -136,7 +136,7 @@
     return html` <gr-tooltip-content
       has-tooltip
       show-icon
-      title="${option.info.description}"
+      title=${option.info.description}
     >
       ${titleName}
     </gr-tooltip-content>`;
@@ -147,7 +147,7 @@
       return html`
         <gr-plugin-config-array-editor
           @plugin-config-option-changed=${this._handleArrayChange}
-          .pluginOption="${option}"
+          .pluginOption=${option}
           ?disabled=${this.disabled || !option.info.editable}
         ></gr-plugin-config-array-editor>
       `;
@@ -172,7 +172,7 @@
             ?disabled=${this.disabled || !option.info.editable}
           >
             ${(option.info.permitted_values || []).map(
-              value => html`<option value="${value}">${value}</option>`
+              value => html`<option value=${value}>${value}</option>`
             )}
           </select>
         </gr-select>
@@ -185,13 +185,13 @@
       return html`
         <iron-input
           @input=${this._handleStringChange}
-          data-option-key="${option._key}"
+          data-option-key=${option._key}
         >
           <input
             is="iron-input"
-            .value="${option.info.value ?? ''}"
+            .value=${option.info.value ?? ''}
             @input=${this._handleStringChange}
-            data-option-key="${option._key}"
+            data-option-key=${option._key}
             ?disabled=${this.disabled || !option.info.editable}
           />
         </iron-input>
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
index bb885dd..1685ca4 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -220,15 +220,13 @@
           ${this.renderMinAndMaxLabel()} ${this.renderMinAndMaxInput()}
           <a
             class="groupPath"
-            href="${ifDefined(this.computeGroupPath(this.groupId))}"
+            href=${ifDefined(this.computeGroupPath(this.groupId))}
           >
             ${this.groupName}
           </a>
           <gr-select
             id="force"
-            class="${this.computeForce(this.rule?.value?.action)
-              ? 'force'
-              : ''}"
+            class=${this.computeForce(this.rule?.value?.action) ? 'force' : ''}
             .bindValue=${this.rule?.value?.force}
             @bind-value-changed=${(e: BindValueChangeEvent) => {
               this.handleForceBindValueChanged(e);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
index d804997..6790b15 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
@@ -5,23 +5,36 @@
  */
 
 import {customElement, query, state} from 'lit/decorators';
-import {LitElement, html, css} from 'lit';
+import {LitElement, html, css, nothing} from 'lit';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {resolve} from '../../../models/dependency';
 import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
 import {subscribe} from '../../lit/subscription-controller';
-import {ChangeInfo, AccountInfo} from '../../../api/rest-api';
+import {ChangeInfo, AccountInfo, NumericChangeId} from '../../../api/rest-api';
 import {
   getTriggerVotes,
   computeLabels,
-  mergeLabelMaps,
   computeOrderedLabelValues,
   mergeLabelInfoMaps,
+  getDefaultValue,
+  mergeLabelMaps,
+  Label,
+  StandardLabels,
 } from '../../../utils/label-util';
 import {getAppContext} from '../../../services/app-context';
 import {fontStyles} from '../../../styles/gr-font-styles';
+import {queryAndAssert} from '../../../utils/common-util';
+import {
+  LabelNameToValuesMap,
+  ReviewInput,
+  LabelNameToValueMap,
+} from '../../../types/common';
+import {GrLabelScoreRow} from '../../change/gr-label-score-row/gr-label-score-row';
+import {ProgressStatus} from '../../../constants/constants';
+import {fireAlert, fireReload} from '../../../utils/event-util';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../change/gr-label-score-row/gr-label-score-row';
+import {getOverallStatus} from '../../../utils/bulk-flow-util';
 
 @customElement('gr-change-list-bulk-vote-flow')
 export class GrChangeListBulkVoteFlow extends LitElement {
@@ -31,6 +44,8 @@
 
   @state() selectedChanges: ChangeInfo[] = [];
 
+  @state() progressByChange: Map<NumericChangeId, ProgressStatus> = new Map();
+
   @query('#actionOverlay') actionOverlay!: GrOverlay;
 
   @state() account?: AccountInfo;
@@ -69,7 +84,10 @@
     subscribe(
       this,
       this.getBulkActionsModel().selectedChanges$,
-      selectedChanges => (this.selectedChanges = selectedChanges)
+      selectedChanges => {
+        this.selectedChanges = selectedChanges;
+        this.resetFlow();
+      }
     );
     subscribe(
       this,
@@ -80,51 +98,167 @@
 
   override render() {
     const permittedLabels = this.computePermittedLabels();
-    const labels = this.computeCommonLabels().filter(
-      label =>
-        permittedLabels?.[label.name] &&
-        permittedLabels?.[label.name].length > 0
-    );
-    // TODO: disable button if no label can be voted upon
+    const triggerLabels = this.computeCommonTriggerLabels(permittedLabels);
+    const nonTriggerLabels = this.computeCommonPermittedLabels(
+      permittedLabels
+    ).filter(label => !triggerLabels.some(l => l.name === label.name));
     return html`
-      <gr-button flatten @click=${() => this.actionOverlay.open()}
+      <gr-button
+        .disabled=${triggerLabels.length === 0 && nonTriggerLabels.length === 0}
+        id="voteFlowButton"
+        flatten
+        @click=${() => this.actionOverlay.open()}
         >Vote</gr-button
       >
       <gr-overlay id="actionOverlay" with-backdrop="">
         <gr-dialog
-          @cancel=${() => this.actionOverlay.close()}
+          .disableCancel=${!this.isCancelEnabled()}
+          .disabled=${!this.isConfirmEnabled()}
+          @confirm=${() => this.handleConfirm()}
+          @cancel=${() => this.handleClose()}
           .cancelLabel=${'Close'}
         >
           <div slot="main">
-            <div class="scoresTable newSubmitRequirements">
-              <h3 class="heading-3">Submit requirements votes</h3>
-              ${labels.map(
-                label => html`<gr-label-score-row
-                  .label="${label}"
-                  .name="${label.name}"
-                  .labels="${this.computeLabelNameToInfoMap()}"
-                  .permittedLabels="${permittedLabels}"
-                  .orderedLabelValues="${computeOrderedLabelValues(
-                    permittedLabels
-                  )}"
-                ></gr-label-score-row>`
-              )}
-            </div>
-            <!-- TODO: Add section for trigger votes -->
+            ${this.renderLabels(
+              nonTriggerLabels,
+              'Submit requirements votes',
+              permittedLabels
+            )}
+            ${this.renderLabels(
+              triggerLabels,
+              'Trigger Votes',
+              permittedLabels
+            )}
           </div>
+          <!-- TODO: Add error handling status if something fails -->
         </gr-dialog>
       </gr-overlay>
     `;
   }
 
+  private renderLabels(
+    labels: Label[],
+    heading: string,
+    permittedLabels?: LabelNameToValuesMap
+  ) {
+    return html` <div class="scoresTable newSubmitRequirements">
+      <h3 class="heading-3">${labels.length ? heading : nothing}</h3>
+      ${labels
+        .filter(
+          label =>
+            permittedLabels?.[label.name] &&
+            permittedLabels?.[label.name].length > 0
+        )
+        .map(
+          label => html`<gr-label-score-row
+            .label=${label}
+            .name=${label.name}
+            .labels=${this.computeLabelNameToInfoMap()}
+            .permittedLabels=${permittedLabels}
+            .orderedLabelValues=${computeOrderedLabelValues(permittedLabels)}
+          ></gr-label-score-row>`
+        )}
+    </div>`;
+  }
+
+  private resetFlow() {
+    this.progressByChange = new Map(
+      this.selectedChanges.map(change => [
+        change._number,
+        ProgressStatus.NOT_STARTED,
+      ])
+    );
+  }
+
+  private isConfirmEnabled() {
+    // Action is allowed if none of the changes have any bulk action performed
+    // on them. In case an error happens then we keep the button disabled.
+    return (
+      getOverallStatus(this.progressByChange) === ProgressStatus.NOT_STARTED
+    );
+  }
+
+  private isCancelEnabled() {
+    return getOverallStatus(this.progressByChange) !== ProgressStatus.RUNNING;
+  }
+
+  private handleClose() {
+    this.actionOverlay.close();
+    if (getOverallStatus(this.progressByChange) === ProgressStatus.NOT_STARTED)
+      return;
+    fireAlert(this, 'Reloading page..');
+    fireReload(this, true);
+  }
+
+  private handleConfirm() {
+    this.progressByChange.clear();
+    const reviewInput: ReviewInput = {
+      labels: this.getLabelValues(
+        this.computeCommonPermittedLabels(this.computePermittedLabels())
+      ),
+    };
+    for (const change of this.selectedChanges) {
+      this.progressByChange.set(change._number, ProgressStatus.RUNNING);
+    }
+    this.requestUpdate();
+    const promises = this.getBulkActionsModel().voteChanges(reviewInput);
+    for (let index = 0; index < promises.length; index++) {
+      const changeNum = this.selectedChanges[index]._number;
+      promises[index]
+        .then(() => {
+          this.progressByChange.set(changeNum, ProgressStatus.SUCCESSFUL);
+        })
+        .catch(() => {
+          this.progressByChange.set(changeNum, ProgressStatus.FAILED);
+        })
+        .finally(() => {
+          this.requestUpdate();
+        });
+    }
+  }
+
+  // private but used in tests
+  getLabelValues(commonPermittedLabels: Label[]): LabelNameToValueMap {
+    const labels: LabelNameToValueMap = {};
+
+    for (const label of commonPermittedLabels) {
+      const selectorEl = queryAndAssert<GrLabelScoreRow>(
+        this,
+        `gr-label-score-row[name="${label.name}"]`
+      );
+      if (!selectorEl?.selectedItem) continue;
+
+      const selectedVal =
+        typeof selectorEl.selectedValue === 'string'
+          ? Number(selectorEl.selectedValue)
+          : selectorEl.selectedValue;
+
+      if (selectedVal === undefined) continue;
+
+      const defValNum = getDefaultValue(
+        this.selectedChanges[0].labels,
+        label.name
+      );
+      if (selectedVal !== defValNum) {
+        labels[label.name] = selectedVal;
+      }
+    }
+    return labels;
+  }
+
   // private but used in tests
   computePermittedLabels() {
     // Reduce method for empty array throws error if no initial value specified
     if (this.selectedChanges.length === 0) return {};
 
-    return this.selectedChanges
+    const permittedLabels = this.selectedChanges
       .map(changes => changes.permitted_labels)
       .reduce(mergeLabelMaps);
+    // TODO: show a warning to the user that Code Review cannot be voted upon
+    if (permittedLabels?.[StandardLabels.CODE_REVIEW]) {
+      delete permittedLabels[StandardLabels.CODE_REVIEW];
+    }
+    return permittedLabels;
   }
 
   private computeLabelNameToInfoMap() {
@@ -137,23 +271,31 @@
   }
 
   // private but used in tests
-  computeNonTriggerLabels(change: ChangeInfo) {
-    const triggerVotes = getTriggerVotes(change);
-    const labels = computeLabels(this.account, change).filter(
-      label => !triggerVotes.includes(label.name)
+  computeCommonTriggerLabels(permittedLabels?: LabelNameToValuesMap) {
+    if (this.selectedChanges.length === 0) return [];
+    const triggerVotes = this.selectedChanges
+      .map(change => getTriggerVotes(change))
+      .reduce((prev, current) =>
+        current.filter(label => prev.some(l => l === label))
+      );
+    return this.computeCommonPermittedLabels(permittedLabels).filter(label =>
+      triggerVotes.includes(label.name)
     );
-    return labels;
   }
 
   // private but used in tests
-  // TODO: Remove Code Review label explicitly
-  computeCommonLabels() {
+  computeCommonPermittedLabels(permittedLabels?: LabelNameToValuesMap) {
     // Reduce method for empty array throws error if no initial value specified
     if (this.selectedChanges.length === 0) return [];
     return this.selectedChanges
-      .map(change => this.computeNonTriggerLabels(change))
+      .map(change => computeLabels(this.account, change))
       .reduce((prev, current) =>
         current.filter(label => prev.some(l => l.name === label.name))
+      )
+      .filter(
+        label =>
+          permittedLabels?.[label.name] &&
+          permittedLabels?.[label.name].length > 0
       );
   }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
index 55b1ac3..5a965d9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
@@ -11,10 +11,17 @@
   bulkActionsModelToken,
   LoadingState,
 } from '../../../models/bulk-actions/bulk-actions-model';
-import {waitUntilObserved, stubRestApi} from '../../../test/test-utils';
+import {
+  waitUntilObserved,
+  stubRestApi,
+  queryAndAssert,
+  query,
+  mockPromise,
+  queryAll,
+} from '../../../test/test-utils';
 import {ChangeInfo, NumericChangeId, LabelInfo} from '../../../api/rest-api';
 import {getAppContext} from '../../../services/app-context';
-import {fixture} from '@open-wc/testing-helpers';
+import {fixture, waitUntil} from '@open-wc/testing-helpers';
 import {wrapInProvider} from '../../../models/di-provider-element';
 import {html} from 'lit';
 import {SinonStubbedMember} from 'sinon';
@@ -24,25 +31,62 @@
   createSubmitRequirementResultInfo,
 } from '../../../test/test-data-generators';
 import './gr-change-list-bulk-vote-flow';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {ProgressStatus} from '../../../constants/constants';
+import {StandardLabels} from '../../../utils/label-util';
 
 const change1: ChangeInfo = {
   ...createChange(),
   _number: 1 as NumericChangeId,
   permitted_labels: {
+    [StandardLabels.CODE_REVIEW]: ['-1', '0', '+1', '+2'],
     A: ['-1', '0', '+1', '+2'],
     B: ['-1', '0'],
     C: ['-1', '0'],
-    D: ['0'], // Does not exist on change2
+    change1OnlyLabelD: ['0'], // Does not exist on change2
+    change1OnlyTriggerLabelE: ['0'], // Does not exist on change2
   },
+  labels: {
+    [StandardLabels.CODE_REVIEW]: {value: null} as LabelInfo,
+    A: {value: null} as LabelInfo,
+    B: {value: null} as LabelInfo,
+    C: {value: null} as LabelInfo,
+    change1OnlyLabelD: {value: null} as LabelInfo,
+    change1OnlyTriggerLabelE: {value: null} as LabelInfo,
+  },
+  submit_requirements: [
+    createSubmitRequirementResultInfo(
+      `label:${StandardLabels.CODE_REVIEW}=MAX`
+    ),
+    createSubmitRequirementResultInfo('label:A=MAX'),
+    createSubmitRequirementResultInfo('label:B=MAX'),
+    createSubmitRequirementResultInfo('label:C=MAX'),
+    createSubmitRequirementResultInfo('label:change1OnlyLabelD=MAX'),
+  ],
 };
 const change2: ChangeInfo = {
   ...createChange(),
   _number: 2 as NumericChangeId,
   permitted_labels: {
+    [StandardLabels.CODE_REVIEW]: ['-1', '0', '+1', '+2'],
     A: ['-1', '0', '+1', '+2'], // Intersects fully with change1
     B: ['0', ' +1'], // Intersects with change1 on 0
     C: ['+1', '+2'], // Does not intersect with change1 at all
   },
+  labels: {
+    [StandardLabels.CODE_REVIEW]: {value: null} as LabelInfo,
+    A: {value: null} as LabelInfo,
+    B: {value: null} as LabelInfo,
+    C: {value: null} as LabelInfo,
+  },
+  submit_requirements: [
+    createSubmitRequirementResultInfo(
+      `label:${StandardLabels.CODE_REVIEW}=MAX`
+    ),
+    createSubmitRequirementResultInfo('label:A=MAX'),
+    createSubmitRequirementResultInfo('label:B=MAX'),
+    createSubmitRequirementResultInfo('label:C=MAX'),
+  ],
 };
 
 suite('gr-change-list-bulk-vote-flow tests', () => {
@@ -77,16 +121,6 @@
   });
 
   test('renders', async () => {
-    change1.labels = {
-      a: {value: null} as LabelInfo,
-      b: {value: null} as LabelInfo,
-      c: {value: null} as LabelInfo,
-    };
-    change1.submit_requirements = [
-      createSubmitRequirementResultInfo('label:a=MAX'),
-      createSubmitRequirementResultInfo('label:b=MAX'),
-      createSubmitRequirementResultInfo('label:c=MAX'),
-    ];
     const changes: ChangeInfo[] = [change1];
     getChangesStub.returns(Promise.resolve(changes));
     model.sync(changes);
@@ -99,6 +133,7 @@
     expect(element).shadowDom.to.equal(/* HTML */ `<gr-button
         aria-disabled="false"
         flatten=""
+        id="voteFlowButton"
         role="button"
         tabindex="0"
       >
@@ -115,12 +150,197 @@
           <div slot="main">
             <div class="newSubmitRequirements scoresTable">
               <h3 class="heading-3">Submit requirements votes</h3>
+              <gr-label-score-row name="A"> </gr-label-score-row>
+              <gr-label-score-row name="B"> </gr-label-score-row>
+              <gr-label-score-row name="C"> </gr-label-score-row>
+              <gr-label-score-row name="change1OnlyLabelD">
+              </gr-label-score-row>
+            </div>
+            <div class="newSubmitRequirements scoresTable">
+              <h3 class="heading-3">Trigger Votes</h3>
+              <gr-label-score-row name="change1OnlyTriggerLabelE">
+              </gr-label-score-row>
             </div>
           </div>
         </gr-dialog>
       </gr-overlay> `);
   });
 
+  test('button state updates as changes are updated', async () => {
+    const changes: ChangeInfo[] = [change1];
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+    await flush();
+
+    assert.isFalse(
+      queryAndAssert<GrButton>(element, '#voteFlowButton').disabled
+    );
+
+    // No common label with change1 so button is disabled
+    change2.labels = {
+      x: {value: null} as LabelInfo,
+      y: {value: null} as LabelInfo,
+      z: {value: null} as LabelInfo,
+    };
+    change2.submit_requirements = [
+      createSubmitRequirementResultInfo('label:x=MAX'),
+      createSubmitRequirementResultInfo('label:y=MAX'),
+      createSubmitRequirementResultInfo('label:z=MAX'),
+    ];
+    changes.push({...change2});
+    getChangesStub.restore();
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change2);
+    await element.updateComplete;
+
+    assert.isTrue(
+      queryAndAssert<GrButton>(element, '#voteFlowButton').disabled
+    );
+  });
+
+  test('progress updates as request is resolved', async () => {
+    const changes: ChangeInfo[] = [{...change1}];
+    getChangesStub.returns(Promise.resolve(changes));
+    model.sync(changes);
+    await waitUntilObserved(
+      model.loadingState$,
+      state => state === LoadingState.LOADED
+    );
+    await selectChange(change1);
+    await element.updateComplete;
+    const saveChangeReview = mockPromise<Response>();
+    stubRestApi('saveChangeReview').returns(saveChangeReview);
+
+    assert.isNotOk(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').disabled
+    );
+    assert.isNotOk(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
+    );
+
+    const scores = queryAll(element, 'gr-label-score-row');
+    queryAndAssert<GrButton>(scores[0], 'gr-button[data-value="+1"]').click();
+    queryAndAssert<GrButton>(scores[1], 'gr-button[data-value="-1"]').click();
+
+    await element.updateComplete;
+
+    assert.deepEqual(
+      element.getLabelValues(
+        element.computeCommonPermittedLabels(element.computePermittedLabels())
+      ),
+      {
+        A: 1,
+        B: -1,
+      }
+    );
+
+    queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
+    await element.updateComplete;
+
+    assert.isTrue(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').disabled
+    );
+    assert.isTrue(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
+    );
+
+    assert.equal(
+      element.progressByChange.get(1 as NumericChangeId),
+      ProgressStatus.RUNNING
+    );
+
+    saveChangeReview.resolve({...new Response(), status: 200});
+    await waitUntil(
+      () =>
+        element.progressByChange.get(1 as NumericChangeId) ===
+        ProgressStatus.SUCCESSFUL
+    );
+
+    assert.isTrue(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').disabled
+    );
+    assert.isNotOk(
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').disabled
+    );
+
+    assert.equal(
+      element.progressByChange.get(1 as NumericChangeId),
+      ProgressStatus.SUCCESSFUL
+    );
+  });
+
+  suite('closing dialog triggers reloads', () => {
+    test('closing dialog triggers a reload', async () => {
+      const changes: ChangeInfo[] = [change1, change2];
+      getChangesStub.returns(Promise.resolve(changes));
+
+      const fireStub = sinon.stub(element, 'dispatchEvent');
+
+      stubRestApi('saveChangeReview').callsFake(
+        (_changeNum, _patchNum, _review, errFn) =>
+          Promise.resolve(new Response()).then(res => {
+            errFn && errFn();
+            return res;
+          })
+      );
+
+      model.sync(changes);
+      await waitUntilObserved(
+        model.loadingState$,
+        state => state === LoadingState.LOADED
+      );
+      await selectChange(change1);
+      await selectChange(change2);
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm').click();
+
+      await waitUntil(
+        () =>
+          element.progressByChange.get(2 as NumericChangeId) ===
+          ProgressStatus.FAILED
+      );
+
+      assert.isFalse(fireStub.called);
+
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').click();
+
+      await waitUntil(() => fireStub.called);
+      assert.equal(fireStub.lastCall.args[0].type, 'reload');
+    });
+
+    test('closing dialog does not trigger reload if no request made', async () => {
+      const changes: ChangeInfo[] = [change1, change2];
+      getChangesStub.returns(Promise.resolve(changes));
+
+      const fireStub = sinon.stub(element, 'dispatchEvent');
+
+      model.sync(changes);
+      await waitUntilObserved(
+        model.loadingState$,
+        state => state === LoadingState.LOADED
+      );
+      await selectChange(change1);
+      await selectChange(change2);
+      await element.updateComplete;
+
+      queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#cancel').click();
+
+      assert.isFalse(fireStub.called);
+    });
+  });
+
   test('computePermittedLabels', async () => {
     // {} if no change is selected
     assert.deepEqual(element.computePermittedLabels(), {});
@@ -139,7 +359,8 @@
       A: ['-1', '0', '+1', '+2'],
       B: ['-1', '0'],
       C: ['-1', '0'],
-      D: ['0'],
+      change1OnlyLabelD: ['0'],
+      change1OnlyTriggerLabelE: ['0'],
     });
 
     changes.push(change2);
@@ -159,61 +380,43 @@
     });
   });
 
-  test('computeCommonLabels', async () => {
-    const change3: ChangeInfo = {
-      ...createChange(),
-      _number: 3 as NumericChangeId,
-    };
-    const change4: ChangeInfo = {
-      ...createChange(),
-      _number: 4 as NumericChangeId,
+  test('computeCommonPermittedLabels', async () => {
+    const createChangeWithLabels = (
+      num: NumericChangeId,
+      labelNames: string[],
+      triggerLabels?: string[]
+    ) => {
+      const change = createChange();
+      change._number = num;
+      change.submit_requirements = [];
+      change.labels = {};
+      change.permitted_labels = {};
+      for (const label of labelNames) {
+        change.labels[label] = {value: null} as LabelInfo;
+        if (!triggerLabels?.includes(label)) {
+          change.submit_requirements.push(
+            createSubmitRequirementResultInfo(`label:${label}=MAX`)
+          );
+        }
+        change.permitted_labels[label] = ['0'];
+      }
+      return change;
     };
 
-    change1.labels = {
-      a: {value: null} as LabelInfo,
-      b: {value: null} as LabelInfo,
-      c: {value: null} as LabelInfo,
-    };
-    change1.submit_requirements = [
-      createSubmitRequirementResultInfo('label:a=MAX'),
-      createSubmitRequirementResultInfo('label:b=MAX'),
-      createSubmitRequirementResultInfo('label:c=MAX'),
+    const changes: ChangeInfo[] = [
+      createChangeWithLabels(
+        1 as NumericChangeId,
+        ['a', 'triggerLabelB', 'c'],
+        ['triggerLabelB']
+      ),
+      createChangeWithLabels(
+        2 as NumericChangeId,
+        ['triggerLabelB', 'c', 'd'],
+        ['triggerLabelB']
+      ),
+      createChangeWithLabels(3 as NumericChangeId, ['c', 'd', 'e']),
+      createChangeWithLabels(4 as NumericChangeId, ['x', 'y', 'z']),
     ];
-
-    change2.labels = {
-      b: {value: null} as LabelInfo,
-      c: {value: null} as LabelInfo,
-      d: {value: null} as LabelInfo,
-    };
-    change2.submit_requirements = [
-      createSubmitRequirementResultInfo('label:b=MAX'),
-      createSubmitRequirementResultInfo('label:c=MAX'),
-      createSubmitRequirementResultInfo('label:d=MAX'),
-    ];
-
-    change3.labels = {
-      c: {value: null} as LabelInfo,
-      d: {value: null} as LabelInfo,
-      e: {value: null} as LabelInfo,
-    };
-    change3.submit_requirements = [
-      createSubmitRequirementResultInfo('label:c=MAX'),
-      createSubmitRequirementResultInfo('label:d=MAX'),
-      createSubmitRequirementResultInfo('label:e=MAX'),
-    ];
-
-    change4.labels = {
-      x: {value: null} as LabelInfo,
-      y: {value: null} as LabelInfo,
-      z: {value: null} as LabelInfo,
-    };
-    change4.submit_requirements = [
-      createSubmitRequirementResultInfo('label:x=MAX'),
-      createSubmitRequirementResultInfo('label:y=MAX'),
-      createSubmitRequirementResultInfo('label:z=MAX'),
-    ];
-
-    const changes: ChangeInfo[] = [change1, change2, change3, change4];
     // Labels for each change are [a,b,c] [b,c,d] [c,d,e] [x,y,z]
     getChangesStub.returns(Promise.resolve(changes));
     model.sync(changes);
@@ -222,34 +425,77 @@
       model.loadingState$,
       state => state === LoadingState.LOADED
     );
-    await selectChange(change1);
+    await selectChange(
+      createChangeWithLabels(1 as NumericChangeId, ['a', 'triggerLabelB', 'c'])
+    );
     await element.updateComplete;
 
-    assert.deepEqual(element.computeCommonLabels(), [
-      {name: 'a', value: null},
-      {name: 'b', value: null},
-      {name: 'c', value: null},
-    ]);
+    // Code-Review is not a common permitted label
+    assert.deepEqual(
+      element.computeCommonPermittedLabels(element.computePermittedLabels()),
+      [
+        {name: 'a', value: null},
+        {name: 'c', value: null},
+        {name: 'triggerLabelB', value: null},
+      ]
+    );
 
-    await selectChange(change2);
+    await selectChange(
+      createChangeWithLabels(2 as NumericChangeId, ['triggerLabelB', 'c', 'd'])
+    );
+    assert.deepEqual(
+      element.computeCommonTriggerLabels(element.computePermittedLabels()),
+      [{name: 'triggerLabelB', value: null}]
+    );
+
     await element.updateComplete;
 
-    // Intersection of [a,b,c] [b,c,d] is [b,c]
-    assert.deepEqual(element.computeCommonLabels(), [
-      {name: 'b', value: null},
-      {name: 'c', value: null},
-    ]);
+    // Intersection of [CR, 'a', 'triggerLabelB', 'c']
+    // [CR, 'triggerLabelB', 'c', 'd'] is [triggerLabelB,c]
+    // Code-Review is not a common permitted label
+    assert.deepEqual(
+      element.computeCommonPermittedLabels(element.computePermittedLabels()),
+      [
+        {name: 'c', value: null},
+        {name: 'triggerLabelB', value: null},
+      ]
+    );
+    assert.deepEqual(
+      element.computeCommonTriggerLabels(element.computePermittedLabels()),
+      [{name: 'triggerLabelB', value: null}]
+    );
 
-    await selectChange(change3);
+    await selectChange(
+      createChangeWithLabels(3 as NumericChangeId, ['c', 'd', 'e'])
+    );
+
     await element.updateComplete;
 
-    // Intersection of [a,b,c] [b,c,d] [c,d,e] is [c]
-    assert.deepEqual(element.computeCommonLabels(), [{name: 'c', value: null}]);
+    // Intersection of [a,triggerLabelB,c] [triggerLabelB,c,d] [c,d,e] is [c]
+    assert.deepEqual(
+      element.computeCommonPermittedLabels(element.computePermittedLabels()),
+      [{name: 'c', value: null}]
+    );
+    assert.deepEqual(
+      element.computeCommonTriggerLabels(element.computePermittedLabels()),
+      []
+    );
 
-    await selectChange(change4);
+    await selectChange(
+      createChangeWithLabels(4 as NumericChangeId, ['x', 'y', 'z'])
+    );
+    assert.deepEqual(
+      element.computeCommonTriggerLabels(element.computePermittedLabels()),
+      []
+    );
+
     await element.updateComplete;
 
-    // Intersection of [a,b,c] [b,c,d] [c,d,e] [x,y,z] is []
-    assert.deepEqual(element.computeCommonLabels(), []);
+    // Intersection of [a,triggerLabelB,c] [triggerLabelB,c,d] [c,d,e] [x,y,z]
+    // is []
+    assert.deepEqual(
+      element.computeCommonPermittedLabels(element.computePermittedLabels()),
+      []
+    );
   });
 });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
index 1b8921e..09b5cc9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirement/gr-change-list-column-requirement.ts
@@ -74,7 +74,7 @@
   override render() {
     return html`<div
       class="container ${this.computeClass()}"
-      title="${ifDefined(this.computeLabelTitle())}"
+      title=${ifDefined(this.computeLabelTitle())}
     >
       ${this.renderContent()}
     </div>`;
@@ -105,14 +105,14 @@
       if (votes.length > 0) {
         const bestVote = votes[0];
         return html`<gr-vote-chip
-          .vote="${bestVote}"
-          .label="${labelInfo}"
+          .vote=${bestVote}
+          .label=${labelInfo}
           tooltip-with-who-voted
         ></gr-vote-chip>`;
       }
     }
     if (isQuickLabelInfo(labelInfo)) {
-      return html`<gr-vote-chip .label="${labelInfo}"></gr-vote-chip>`;
+      return html`<gr-vote-chip .label=${labelInfo}></gr-vote-chip>`;
     }
     return;
   }
@@ -144,8 +144,8 @@
       return this.renderStatusIcon(requirement.status);
     } else {
       return html`<gr-vote-chip
-        .vote="${worstVote}"
-        .label="${labelInfo}"
+        .vote=${worstVote}
+        .label=${labelInfo}
         tooltip-with-who-voted
       ></gr-vote-chip>`;
     }
@@ -153,10 +153,7 @@
 
   private renderStatusIcon(status: SubmitRequirementStatus) {
     const icon = iconForStatus(status ?? SubmitRequirementStatus.ERROR);
-    return html`<iron-icon
-      class="${icon}"
-      icon="gr-icons:${icon}"
-    ></iron-icon>`;
+    return html`<iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>`;
   }
 
   private computeClass(): string {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
index 2272133..1f8bf51 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column-requirements-summary/gr-change-list-column-requirements-summary.ts
@@ -122,10 +122,10 @@
   }
 
   renderState(icon: string, aggregation: string | TemplateResult) {
-    return html`<span class="${icon}" role="button" tabindex="0">
+    return html`<span class=${icon} role="button" tabindex="0">
       <gr-submit-requirement-dashboard-hovercard .change=${this.change}>
       </gr-submit-requirement-dashboard-hovercard>
-      <iron-icon class="${icon}" icon="gr-icons:${icon}" role="img"></iron-icon
+      <iron-icon class=${icon} icon="gr-icons:${icon}" role="img"></iron-icon
       >${aggregation}</span
     >`;
   }
@@ -142,10 +142,10 @@
     return html`<iron-icon
       icon="gr-icons:comment"
       class="commentIcon"
-      .title="${pluralize(
+      .title=${pluralize(
         this.change?.unresolved_comment_count,
         'unresolved comment'
-      )}"
+      )}
     ></iron-icon>`;
   }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 77602d0..15532d2 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -340,7 +340,7 @@
 
     return html`
       <td class="cell number">
-        <a href="${changeUrl}">${this.change?._number}</a>
+        <a href=${changeUrl}>${this.change?._number}</a>
       </td>
     `;
   }
@@ -352,8 +352,8 @@
     return html`
       <td class="cell subject">
         <a
-          title="${ifDefined(this.change?.subject)}"
-          href="${changeUrl}"
+          title=${ifDefined(this.change?.subject)}
+          href=${changeUrl}
           @click=${() => this.handleChangeClick()}
         >
           <div class="container">
@@ -413,7 +413,7 @@
             this.renderChangeReviewers(reviewer, index)
           )}
           ${this.computeAdditionalReviewersCount()
-            ? html`<span title="${this.computeAdditionalReviewersTitle()}"
+            ? html`<span title=${this.computeAdditionalReviewersTitle()}
                 >+${this.computeAdditionalReviewersCount()}</span
               >`
             : ''}
@@ -460,13 +460,13 @@
 
     return html`
       <td class="cell repo">
-        <a class="fullRepo" href="${this.computeRepoUrl()}">
+        <a class="fullRepo" href=${this.computeRepoUrl()}>
           ${this.computeRepoDisplay()}
         </a>
         <a
           class="truncatedRepo"
-          href="${this.computeRepoUrl()}"
-          title="${this.computeRepoDisplay()}"
+          href=${this.computeRepoUrl()}
+          title=${this.computeRepoDisplay()}
         >
           ${this.computeTruncatedRepoDisplay()}
         </a>
@@ -480,7 +480,7 @@
 
     return html`
       <td class="cell branch">
-        <a href="${this.computeRepoBranchURL()}"> ${this.change?.branch} </a>
+        <a href=${this.computeRepoBranchURL()}> ${this.change?.branch} </a>
         ${this.renderChangeBranch()}
       </td>
     `;
@@ -490,7 +490,7 @@
     if (!this.change?.topic) return;
 
     return html`
-      (<a href="${this.computeTopicURL()}"
+      (<a href=${this.computeTopicURL()}
         ><!--
       --><gr-limited-text .limit=${50} .text=${this.change.topic}>
         </gr-limited-text
@@ -550,7 +550,7 @@
 
     return html`
       <td class="cell size">
-        <gr-tooltip-content has-tooltip title="${this.computeSizeTooltip()}">
+        <gr-tooltip-content has-tooltip title=${this.computeSizeTooltip()}>
           ${this.renderChangeSize()}
         </gr-tooltip-content>
       </td>
@@ -590,8 +590,8 @@
     }
     return html`
       <td
-        title="${this.computeLabelTitle(labelName)}"
-        class="${this.computeLabelClass(labelName)}"
+        title=${this.computeLabelTitle(labelName)}
+        class=${this.computeLabelClass(labelName)}
       >
         ${this.renderChangeHasLabelIcon(labelName)}
       </td>
@@ -610,7 +610,7 @@
   private renderChangePluginEndpoint(pluginEndpointName: string) {
     return html`
       <td class="cell endpoint">
-        <gr-endpoint-decorator name="${pluginEndpointName}">
+        <gr-endpoint-decorator name=${pluginEndpointName}>
           <gr-endpoint-param name="change" .value=${this.change}>
           </gr-endpoint-param>
         </gr-endpoint-decorator>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
index 6fc8fd5..458fe9a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
@@ -8,7 +8,7 @@
 import {ProgressStatus, ReviewerState} from '../../../constants/constants';
 import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
 import {resolve} from '../../../models/dependency';
-import {AccountInfo, ChangeInfo} from '../../../types/common';
+import {AccountInfo, ChangeInfo, NumericChangeId} from '../../../types/common';
 import {subscribe} from '../../lit/subscription-controller';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-dialog/gr-dialog';
@@ -20,6 +20,7 @@
   SUGGESTIONS_PROVIDERS_USERS_TYPES,
 } from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import '../../shared/gr-account-list/gr-account-list';
+import {getOverallStatus} from '../../../utils/bulk-flow-util';
 
 const SUGGESTIONS_PROVIDERS_USERS_TYPES_BY_REVIEWER_STATE: Record<
   ReviewerState,
@@ -45,7 +46,10 @@
     ReviewerSuggestionsProvider
   > = new Map();
 
-  @state() private progressByChange = new Map<ChangeInfo, ProgressStatus>();
+  @state() private progressByChangeNum = new Map<
+    NumericChangeId,
+    ProgressStatus
+  >();
 
   @state() private isOverlayOpen = false;
 
@@ -100,7 +104,7 @@
   }
 
   private renderDialog() {
-    const overallStatus = this.getOverallStatus();
+    const overallStatus = getOverallStatus(this.progressByChangeNum);
     return html`
       <gr-dialog
         @cancel=${() => this.closeOverlay()}
@@ -159,8 +163,11 @@
   }
 
   private resetFlow() {
-    this.progressByChange = new Map(
-      this.selectedChanges.map(change => [change, ProgressStatus.NOT_STARTED])
+    this.progressByChangeNum = new Map(
+      this.selectedChanges.map(change => [
+        change._number,
+        ProgressStatus.NOT_STARTED,
+      ])
     );
     for (const state of [ReviewerState.REVIEWER, ReviewerState.CC]) {
       this.updatedAccountsByReviewerState.set(
@@ -180,17 +187,23 @@
   private onConfirm(overallStatus: ProgressStatus) {
     switch (overallStatus) {
       case ProgressStatus.NOT_STARTED:
-        this.saveChanges();
+        this.saveReviewers();
         break;
       case ProgressStatus.SUCCESSFUL:
         this.overlay.close();
         break;
+      case ProgressStatus.FAILED:
+        this.overlay.close();
+        break;
     }
   }
 
-  private saveChanges() {
-    this.progressByChange = new Map(
-      this.selectedChanges.map(change => [change, ProgressStatus.RUNNING])
+  private saveReviewers() {
+    this.progressByChangeNum = new Map(
+      this.selectedChanges.map(change => [
+        change._number,
+        ProgressStatus.RUNNING,
+      ])
     );
     const inFlightActions = this.getBulkActionsModel().addReviewers(
       this.updatedAccountsByReviewerState
@@ -199,11 +212,14 @@
       const change = this.selectedChanges[index];
       inFlightActions[index]
         .then(() => {
-          this.progressByChange.set(change, ProgressStatus.SUCCESSFUL);
+          this.progressByChangeNum.set(
+            change._number,
+            ProgressStatus.SUCCESSFUL
+          );
           this.requestUpdate();
         })
         .catch(() => {
-          this.progressByChange.set(change, ProgressStatus.FAILED);
+          this.progressByChangeNum.set(change._number, ProgressStatus.FAILED);
           this.requestUpdate();
         });
     }
@@ -248,17 +264,6 @@
     suggestionsProvider.init();
     return suggestionsProvider;
   }
-
-  private getOverallStatus() {
-    const statuses = Array.from(this.progressByChange.values());
-    if (statuses.every(s => s === ProgressStatus.NOT_STARTED)) {
-      return ProgressStatus.NOT_STARTED;
-    }
-    if (statuses.some(s => s === ProgressStatus.RUNNING)) {
-      return ProgressStatus.RUNNING;
-    }
-    return ProgressStatus.SUCCESSFUL;
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
index 26ee1e1..85ea644 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
@@ -166,11 +166,9 @@
           ?aria-hidden=${!this.showStar}
           ?hidden=${!this.showStar}
         ></td>
-        <td class="cell" colspan="${colSpan}">
+        <td class="cell" colspan=${colSpan}>
           ${this.changeSection.emptyStateSlotName
-            ? html`<slot
-                name="${this.changeSection.emptyStateSlotName}"
-              ></slot>`
+            ? html`<slot name=${this.changeSection.emptyStateSlotName}></slot>`
             : 'No changes'}
         </td>
       </tr>
@@ -191,10 +189,10 @@
           <td aria-hidden="true" class="leftPadding"></td>
           ${this.renderSelectionHeader()}
           <td aria-hidden="true" class="star" ?hidden=${!this.showStar}></td>
-          <td class="cell" colspan="${colSpan}">
+          <td class="cell" colspan=${colSpan}>
             <h2 class="heading-3">
               <a
-                href="${this.sectionHref(this.changeSection.query)}"
+                href=${this.sectionHref(this.changeSection.query)}
                 class="section-title"
               >
                 <span class="section-name">${this.changeSection.name}</span>
@@ -240,12 +238,12 @@
   }
 
   private renderHeaderCell(item: string) {
-    return html`<td class="${item.toLowerCase()}">${item}</td>`;
+    return html`<td class=${item.toLowerCase()}>${item}</td>`;
   }
 
   private renderLabelHeader(labelName: string) {
     return html`
-      <td class="label" title="${labelName}">
+      <td class="label" title=${labelName}>
         ${computeLabelShortcut(labelName)}
       </td>
     `;
@@ -254,7 +252,7 @@
   private renderEndpointHeader(pluginHeader: string) {
     return html`
       <td class="endpoint">
-        <gr-endpoint-decorator .name="${pluginHeader}"></gr-endpoint-decorator>
+        <gr-endpoint-decorator .name=${pluginHeader}></gr-endpoint-decorator>
       </td>
     `;
   }
@@ -279,7 +277,7 @@
         ?showStar=${this.showStar}
         tabindex=${ifDefined(tabindex)}
         .labelNames=${this.labelNames}
-        aria-label="${ariaLabel}"
+        aria-label=${ariaLabel}
       ></gr-change-list-item>
     `;
   }
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 7424cf6..85d53d7 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -248,7 +248,7 @@
     if (this.offset === 0) return;
 
     return html`
-      <a id="prevArrow" href="${this.computeNavLink(-1)}">
+      <a id="prevArrow" href=${this.computeNavLink(-1)}>
         <iron-icon icon="gr-icons:chevron-left" aria-label="Older"> </iron-icon>
       </a>
     `;
@@ -264,7 +264,7 @@
       return;
 
     return html`
-      <a id="nextArrow" href="${this.computeNavLink(1)}">
+      <a id="nextArrow" href=${this.computeNavLink(1)}>
         <iron-icon icon="gr-icons:chevron-right" aria-label="Newer">
         </iron-icon>
       </a>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 4cfd2d4..2c10c1e 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -274,8 +274,8 @@
       >
         ${changeSection.emptyStateSlotName
           ? html`<slot
-              slot="${changeSection.emptyStateSlotName}"
-              name="${changeSection.emptyStateSlotName}"
+              slot=${changeSection.emptyStateSlotName}
+              name=${changeSection.emptyStateSlotName}
             ></slot>`
           : nothing}
       </gr-change-list-section>
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
index 6c9fa68..c41ad70 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
@@ -78,11 +78,9 @@
             </li>
             <li>
               <p>If you are making a new commit use</p>
-              <gr-shell-command
-                .command="${Commands.CREATE}"
-              ></gr-shell-command>
+              <gr-shell-command .command=${Commands.CREATE}></gr-shell-command>
               <p>Or to amend an existing commit use</p>
-              <gr-shell-command .command="${Commands.AMEND}"></gr-shell-command>
+              <gr-shell-command .command=${Commands.AMEND}></gr-shell-command>
               <p>
                 Please make sure you add a commit message as it becomes the
                 description for your change.
@@ -91,7 +89,7 @@
             <li>
               <p>Push the change for code review</p>
               <gr-shell-command
-                .command="${Commands.PUSH_PREFIX + (this.branch ?? '[BRANCH]')}"
+                .command=${Commands.PUSH_PREFIX + (this.branch ?? '[BRANCH]')}
               ></gr-shell-command>
             </li>
             <li>
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index e6bae6e..e8b25dd 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -232,7 +232,7 @@
       <div class="banner">
         <div>
           You have draft comments on closed changes.
-          <a href="${this.computeDraftsLink()}" target="_blank">(view all)</a>
+          <a href=${this.computeDraftsLink()} target="_blank">(view all)</a>
         </div>
         <div>
           <gr-button
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
index d82ff7f..dec1656 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
@@ -62,7 +62,7 @@
     return html`<div>
       <span class="browse">Browse:</span>
       ${webLinks.map(
-        link => html`<a target="_blank" href="${link.url}">${link.name}</a> `
+        link => html`<a target="_blank" href=${link.url}>${link.name}</a> `
       )}
     </div> `;
   }
@@ -72,7 +72,7 @@
       <h1 class="heading-1">${this.repo}</h1>
       <hr />
       <div>
-        <span>Detail:</span> <a href="${this._repoUrl!}">Repo settings</a>
+        <span>Detail:</span> <a href=${this._repoUrl!}>Repo settings</a>
       </div>
       ${this._renderLinks(this._webLinks)}
     </div>`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
index 7265a7f..72f2a44 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
@@ -65,7 +65,7 @@
 
   override render() {
     return html`<gr-avatar
-        .account="${this._accountDetails}"
+        .account=${this._accountDetails}
         .imageSize=${100}
         aria-label="Account avatar"
       ></gr-avatar>
@@ -85,31 +85,31 @@
         <div>
           <span>Joined:</span>
           <gr-date-formatter
-            dateStr="${this._computeDetail(
+            dateStr=${this._computeDetail(
               this._accountDetails,
               'registered_on'
-            )}"
+            )}
           >
           </gr-date-formatter>
         </div>
         <gr-endpoint-decorator name="user-header">
           <gr-endpoint-param
             name="accountDetails"
-            .value="${this._accountDetails}"
+            .value=${this._accountDetails}
           >
           </gr-endpoint-param>
-          <gr-endpoint-param name="loggedIn" .value="${this.loggedIn}">
+          <gr-endpoint-param name="loggedIn" .value=${this.loggedIn}>
           </gr-endpoint-param>
         </gr-endpoint-decorator>
       </div>
       <div class="info">
         <div
-          class="${this._computeDashboardLinkClass(
+          class=${this._computeDashboardLinkClass(
             this.showDashboardLink,
             this.loggedIn
-          )}"
+          )}
         >
-          <a href="${this._computeDashboardUrl(this._accountDetails)}"
+          <a href=${this._computeDashboardUrl(this._accountDetails)}
             >View dashboard</a
           >
         </div>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 458d043..3ca7b0c 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -1220,7 +1220,7 @@
       test('revert change with plugin hook', async () => {
         const newRevertMsg = 'Modified revert msg';
         sinon
-          .stub(element.$.confirmRevertDialog, '_modifyRevertMsg')
+          .stub(element.$.confirmRevertDialog, 'modifyRevertMsg')
           .callsFake(() => newRevertMsg);
         element.change = {
           ...createChangeViewChange(),
@@ -1245,7 +1245,7 @@
         sinon
           .stub(
             element.$.confirmRevertDialog,
-            '_populateRevertSubmissionMessage'
+            'populateRevertSubmissionMessage'
           )
           .callsFake(() => 'original msg');
         await flush();
@@ -1255,7 +1255,7 @@
         );
         tap(revertButton);
         await flush();
-        assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
+        assert.equal(element.$.confirmRevertDialog.message, newRevertMsg);
       });
 
       suite('revert change submitted together', () => {
@@ -1319,7 +1319,7 @@
             '\n' +
             '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
             '\n';
-          assert.equal(confirmRevertDialog._message, expectedMsg);
+          assert.equal(confirmRevertDialog.message, expectedMsg);
           const radioInputs = queryAll(
             confirmRevertDialog,
             'input[name="revertOptions"]'
@@ -1330,7 +1330,7 @@
             'Revert "random commit message"\n\nThis reverts ' +
             'commit 2000.\n\nReason' +
             ' for revert: <INSERT REASONING HERE>\n';
-          assert.equal(confirmRevertDialog._message, expectedMsg);
+          assert.equal(confirmRevertDialog.message, expectedMsg);
         });
 
         test('submit fails if message is not edited', async () => {
@@ -1348,7 +1348,7 @@
           );
           tap(confirmButton);
           await flush();
-          assert.isTrue(confirmRevertDialog._showErrorMessage);
+          assert.isTrue(confirmRevertDialog.showErrorMessage);
           assert.isFalse(fireStub.called);
         });
 
@@ -1379,20 +1379,20 @@
             'Revert "random commit message"\n\nThis reverts ' +
             'commit 2000.\n\nReason' +
             ' for revert: <INSERT REASONING HERE>\n';
-          assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
+          assert.equal(confirmRevertDialog.message, revertSubmissionMsg);
           const newRevertMsg = revertSubmissionMsg + 'random';
           const newSingleChangeMsg = singleChangeMsg + 'random';
-          confirmRevertDialog._message = newRevertMsg;
+          confirmRevertDialog.message = newRevertMsg;
           tap(radioInputs[0]);
           await flush();
-          assert.equal(confirmRevertDialog._message, singleChangeMsg);
-          confirmRevertDialog._message = newSingleChangeMsg;
+          assert.equal(confirmRevertDialog.message, singleChangeMsg);
+          confirmRevertDialog.message = newSingleChangeMsg;
           tap(radioInputs[1]);
           await flush();
-          assert.equal(confirmRevertDialog._message, newRevertMsg);
+          assert.equal(confirmRevertDialog.message, newRevertMsg);
           tap(radioInputs[0]);
           await flush();
-          assert.equal(confirmRevertDialog._message, newSingleChangeMsg);
+          assert.equal(confirmRevertDialog.message, newSingleChangeMsg);
         });
       });
 
@@ -1430,7 +1430,7 @@
           );
           tap(confirmButton);
           await flush();
-          assert.isTrue(confirmRevertDialog._showErrorMessage);
+          assert.isTrue(confirmRevertDialog.showErrorMessage);
           assert.isFalse(fireStub.called);
         });
 
@@ -1451,9 +1451,9 @@
             'Revert "random commit message"\n\n' +
             'This reverts commit 2000.\n\nReason ' +
             'for revert: <INSERT REASONING HERE>\n';
-          assert.equal(confirmRevertDialog._message, msg);
+          assert.equal(confirmRevertDialog.message, msg);
           let editedMsg = msg + 'hello';
-          confirmRevertDialog._message += 'hello';
+          confirmRevertDialog.message += 'hello';
           const confirmButton = queryAndAssert(
             queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
             '#confirm'
@@ -1462,7 +1462,7 @@
           await flush();
           // Contains generic template reason so doesn't submit
           assert.isFalse(fireActionStub.called);
-          confirmRevertDialog._message = confirmRevertDialog._message.replace(
+          confirmRevertDialog.message = confirmRevertDialog.message.replace(
             '<INSERT REASONING HERE>',
             ''
           );
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index 2305a74..cd51627 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -201,9 +201,6 @@
   @property({type: Boolean, computed: '_computeIsWip(change)'})
   _isWip = false;
 
-  @property({type: String})
-  _newHashtag?: Hashtag;
-
   @property({type: Boolean})
   _settingTopic = false;
 
@@ -338,17 +335,16 @@
     return hasCherryPickOf;
   }
 
-  _handleHashtagChanged() {
+  _handleHashtagChanged(e: CustomEvent<string>) {
     if (!this.change) {
       throw new Error('change must be set');
     }
-    if (!this._newHashtag?.length) {
+    const newHashtag = e.detail.length ? e.detail : undefined;
+    if (!newHashtag?.length) {
       return;
     }
-    const newHashtag = this._newHashtag;
-    this._newHashtag = '' as Hashtag;
     this.restApiService
-      .setChangeHashtag(this.change._number, {add: [newHashtag]})
+      .setChangeHashtag(this.change._number, {add: [newHashtag as Hashtag]})
       .then(newHashtag => {
         this.set(['change', 'hashtags'], newHashtag);
         fireEvent(this, 'hashtag-changed');
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
index 054f3f7..012c0d5 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
@@ -300,7 +300,6 @@
           mutable="[[_mutable]]"
           reviewers-only=""
           account="[[account]]"
-          server-config="[[serverConfig]]"
         ></gr-reviewer-list>
       </span>
     </section>
@@ -314,7 +313,6 @@
           mutable="[[_mutable]]"
           ccs-only=""
           account="[[account]]"
-          server-config="[[serverConfig]]"
         ></gr-reviewer-list>
       </span>
     </section>
@@ -448,13 +446,13 @@
           >
             <gr-editable-label
               class="topicEditableLabel"
-              label-text="Add a topic"
+              labelText="Add a topic"
               value="[[change.topic]]"
-              max-length="1024"
+              maxLength="1024"
               placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
               read-only="[[_topicReadOnly]]"
               on-changed="_handleTopicChanged"
-              show-as-edit-pencil="true"
+              showAsEditPencil
               autocomplete="true"
               query="[[queryTopic]]"
             ></gr-editable-label>
@@ -506,12 +504,11 @@
         <template is="dom-if" if="[[!_hashtagReadOnly]]">
           <gr-editable-label
             uppercase=""
-            label-text="Add a hashtag"
-            value="{{_newHashtag}}"
+            labelText="Add a hashtag"
             placeholder="[[_computeHashtagPlaceholder(_hashtagReadOnly)]]"
             read-only="[[_hashtagReadOnly]]"
             on-changed="_handleHashtagChanged"
-            show-as-edit-pencil="true"
+            showAsEditPencil
           ></gr-editable-label>
         </template>
       </span>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index 54c709a..37d5cb0 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -846,12 +846,13 @@
 
     test('changing hashtag', async () => {
       await flush();
-      element._newHashtag = 'new hashtag' as Hashtag;
       const newHashtag: Hashtag[] = ['new hashtag' as Hashtag];
       const setChangeHashtagStub = stubRestApi('setChangeHashtag').returns(
         Promise.resolve(newHashtag)
       );
-      element._handleHashtagChanged();
+      element._handleHashtagChanged(
+        new CustomEvent('test', {detail: 'new hashtag'})
+      );
       assert.isTrue(
         setChangeHashtagStub.calledWith(42 as NumericChangeId, {
           add: ['new hashtag' as Hashtag],
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index 72a6b9c..f5893ac 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -155,8 +155,8 @@
   override render() {
     const chipClass = `summaryChip font-small ${this.styleType}`;
     const grIcon = this.icon ? `gr-icons:${this.icon}` : '';
-    return html`<button class="${chipClass}" @click="${this.handleClick}">
-      ${this.icon && html`<iron-icon icon="${grIcon}"></iron-icon>`}
+    return html`<button class=${chipClass} @click=${this.handleClick}>
+      ${this.icon && html`<iron-icon icon=${grIcon}></iron-icon>`}
       <slot></slot>
     </button>`;
   }
@@ -342,8 +342,8 @@
 
   private renderChip(clazz: string, ariaLabel: string, icon: string) {
     return html`
-      <div class="${clazz}" role="link" tabindex="0" aria-label="${ariaLabel}">
-        <iron-icon icon="${icon}"></iron-icon>
+      <div class=${clazz} role="link" tabindex="0" aria-label=${ariaLabel}>
+        <iron-icon icon=${icon}></iron-icon>
         ${this.renderLinks()}
         <div class="text">${this.text}</div>
       </div>
@@ -354,10 +354,10 @@
     return this.links.map(
       link => html`
         <a
-          href="${link}"
+          href=${link}
           target="_blank"
-          @click="${this.onLinkClick}"
-          @keydown="${this.onLinkKeyDown}"
+          @click=${this.onLinkClick}
+          @keydown=${this.onLinkKeyDown}
           aria-label="Link to check details"
           ><iron-icon class="launch" icon="gr-icons:launch"></iron-icon
         ></a>
@@ -614,7 +614,7 @@
     if (!action) return;
     return html`<gr-checks-action
       context="summary"
-      .action="${action}"
+      .action=${action}
     ></gr-checks-action>`;
   }
 
@@ -634,9 +634,9 @@
         link=""
         vertical-offset="32"
         horizontal-align="right"
-        @tap-item="${this.handleAction}"
-        .items="${items}"
-        .disabledIds="${disabledIds}"
+        @tap-item=${this.handleAction}
+        .items=${items}
+        .disabledIds=${disabledIds}
       >
         <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
         </iron-icon>
@@ -654,7 +654,7 @@
               <iron-icon icon="gr-icons:error"></iron-icon>
             </div>
             <div class="right">
-              <div class="message" title="${message}">
+              <div class="message" title=${message}>
                 Error while fetching results for ${plugin}: ${message}
               </div>
             </div>
@@ -675,7 +675,7 @@
           Not logged in
         </div>
         <div class="right">
-          <gr-button @click="${this.loginCallback}" link>Sign in</gr-button>
+          <gr-button @click=${this.loginCallback} link>Sign in</gr-button>
         </div>
       </div>
     `;
@@ -736,10 +736,10 @@
     if (count === 0) return;
     const handler = () => this.onChipClick({statusOrCategory});
     return html`<gr-checks-chip
-      .statusOrCategory="${statusOrCategory}"
-      .text="${`${count}`}"
-      @click="${handler}"
-      @keydown="${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}"
+      .statusOrCategory=${statusOrCategory}
+      .text=${`${count}`}
+      @click=${handler}
+      @keydown=${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}
     ></gr-checks-chip>`;
   }
 
@@ -754,10 +754,10 @@
       this.requestUpdate();
     };
     return html`<gr-checks-chip
-      .statusOrCategory="${statusOrCategory}"
+      .statusOrCategory=${statusOrCategory}
       .text="+ ${count} more"
-      @click="${handler}"
-      @keydown="${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}"
+      @click=${handler}
+      @keydown=${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}
     ></gr-checks-chip>`;
   }
 
@@ -782,11 +782,11 @@
     }
     const handler = () => this.onChipClick(tabState);
     return html`<gr-checks-chip
-      .statusOrCategory="${statusOrCategory}"
-      .text="${text}"
-      .links="${links}"
-      @click="${handler}"
-      @keydown="${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}"
+      .statusOrCategory=${statusOrCategory}
+      .text=${text}
+      .links=${links}
+      @click=${handler}
+      @keydown=${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}
     ></gr-checks-chip>`;
   }
 
@@ -834,7 +834,7 @@
                   : ''}${this.renderChecksChipRunning()}
                 <span
                   class="loadingSpin"
-                  ?hidden="${!this.someProvidersAreLoading}"
+                  ?hidden=${!this.someProvidersAreLoading}
                 ></span>
                 ${this.renderErrorMessages()} ${this.renderChecksLogin()}
                 ${this.renderSummaryMessage()} ${this.renderActions()}
@@ -866,7 +866,7 @@
                 ${unresolvedAuthors.map(
                   account =>
                     html`<gr-avatar
-                      .account="${account}"
+                      .account=${account}
                       imageSize="32"
                     ></gr-avatar>`
                 )}
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 456ecf0..2dc401f 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -168,6 +168,7 @@
   SwitchTabEvent,
   SwitchTabEventDetail,
   TabState,
+  ValueChangedEvent,
 } from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrMessagesList} from '../gr-messages-list/gr-messages-list';
@@ -439,10 +440,6 @@
   })
   _changeStatuses?: ChangeStates[];
 
-  /** If false, then the "Show more" button was used to expand. */
-  @property({type: Boolean})
-  _commitCollapsed = true;
-
   /** Is the "Show more/less" button visible? */
   @property({
     type: Boolean,
@@ -934,6 +931,14 @@
     });
   }
 
+  handleEditingChanged(e: ValueChangedEvent<boolean>) {
+    this._editingCommitMessage = e.detail.value;
+  }
+
+  handleContentChanged(e: ValueChangedEvent) {
+    this._latestCommitMessage = e.detail.value;
+  }
+
   _handleCommitMessageSave(e: EditableContentSaveEvent) {
     assertIsDefined(this._change, '_change');
     if (!this._changeNum)
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index b78fca0..72021e5 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -420,8 +420,10 @@
               <div id="commitMessage" class="commitMessage">
                 <gr-editable-content
                   id="commitMessageEditor"
-                  editing="{{_editingCommitMessage}}"
-                  content="{{_latestCommitMessage}}"
+                  editing="[[_editingCommitMessage]]"
+                  content="[[_latestCommitMessage]]"
+                  on-editing-changed="handleEditingChanged"
+                  on-content-changed="handleContentChanged"
                   storage-key="[[_computeCommitMessageKey(_change._number, _change.current_revision)]]"
                   hide-edit-commit-message="[[_hideEditCommitMessage]]"
                   commit-collapsible="[[_commitCollapsible]]"
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
index 0ce3b07..100b88e 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
@@ -60,12 +60,12 @@
       <a
         target="_blank"
         rel="noopener"
-        href="${this.computeCommitLink(
+        href=${this.computeCommitLink(
           this._webLink,
           this.change,
           this.commitInfo,
           this.serverConfig
-        )}"
+        )}
         >${this._computeShortHash(
           this.change,
           this.commitInfo,
@@ -74,9 +74,9 @@
       >
       <gr-copy-clipboard
         hastooltip
-        .buttonTitle="${'Copy full SHA to clipboard'}"
+        .buttonTitle=${'Copy full SHA to clipboard'}
         hideinput
-        .text="${this.commitInfo?.commit}"
+        .text=${this.commitInfo?.commit}
       >
       </gr-copy-clipboard>
     </div>`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
index c34c577..482efd6 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
@@ -14,33 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import '../../shared/gr-autocomplete/gr-autocomplete';
-import '../../shared/gr-dialog/gr-dialog';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-move-dialog_html';
-import {customElement, property} from '@polymer/decorators';
+import {css, html, LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
 import {BranchName, RepoName} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
-import {GrTypedAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-dialog/gr-dialog';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
 
 const SUGGESTIONS_LIMIT = 15;
 
-// This is used to make sure 'branch'
-// can be typed as BranchName.
-export interface GrConfirmMoveDialog {
-  $: {
-    branchInput: GrTypedAutocomplete<BranchName>;
-  };
-}
-
 @customElement('gr-confirm-move-dialog')
-export class GrConfirmMoveDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrConfirmMoveDialog extends LitElement {
   /**
    * Fired when the confirm button is pressed.
    *
@@ -62,9 +49,6 @@
   @property({type: String})
   project?: RepoName;
 
-  @property({type: Object})
-  _query?: (input: string) => Promise<{name: BranchName}[]>;
-
   /** Called in disconnectedCallback. */
   private cleanups: (() => void)[] = [];
 
@@ -78,24 +62,88 @@
     super.connectedCallback();
     this.cleanups.push(
       addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, e =>
-        this._handleConfirmTap(e)
+        this.handleConfirmTap(e)
       )
     );
     this.cleanups.push(
       addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, e =>
-        this._handleConfirmTap(e)
+        this.handleConfirmTap(e)
       )
     );
   }
 
   private readonly restApiService = getAppContext().restApiService;
 
-  constructor() {
-    super();
-    this._query = (text: string) => this._getProjectBranchesSuggestions(text);
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          width: 30em;
+        }
+        :host([disabled]) {
+          opacity: 0.5;
+          pointer-events: none;
+        }
+        label {
+          cursor: pointer;
+        }
+        .main {
+          display: flex;
+          flex-direction: column;
+          width: 100%;
+        }
+        .main label,
+        .main input[type='text'] {
+          display: block;
+          width: 100%;
+        }
+        .main .message {
+          width: 100%;
+        }
+        .warning {
+          color: var(--error-text-color);
+        }
+      `,
+    ];
   }
 
-  _handleConfirmTap(e: Event) {
+  override render() {
+    return html`
+      <gr-dialog
+        confirm-label="Move Change"
+        @confirm=${(e: Event) => this.handleConfirmTap(e)}
+        @cancel=${(e: Event) => this.handleCancelTap(e)}
+      >
+        <div class="header" slot="header">Move Change to Another Branch</div>
+        <div class="main" slot="main">
+          <p class="warning">
+            Warning: moving a change will not change its parents.
+          </p>
+          <label for="branchInput"> Move change to branch </label>
+          <gr-autocomplete
+            id="branchInput"
+            .text=${this.branch}
+            .query=${(text: string) => this.getProjectBranchesSuggestions(text)}
+            placeholder="Destination branch"
+          >
+          </gr-autocomplete>
+          <label for="messageInput"> Move Change Message </label>
+          <iron-autogrow-textarea
+            id="messageInput"
+            class="message"
+            autocomplete="on"
+            .rows=${4}
+            .maxRows=${15}
+            .bindValue=${this.message}
+          ></iron-autogrow-textarea>
+        </div>
+      </gr-dialog>
+    `;
+  }
+
+  private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -106,7 +154,7 @@
     );
   }
 
-  _handleCancelTap(e: Event) {
+  private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -117,7 +165,7 @@
     );
   }
 
-  _getProjectBranchesSuggestions(input: string) {
+  private getProjectBranchesSuggestions(input: string) {
     if (!this.project) return Promise.reject(new Error('Missing project'));
     if (input.startsWith('refs/heads/')) {
       input = input.substring('refs/heads/'.length);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts
deleted file mode 100644
index 7c3c719..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      width: 30em;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    label {
-      cursor: pointer;
-    }
-    .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-    .main label,
-    .main input[type='text'] {
-      display: block;
-      width: 100%;
-    }
-    .main .message {
-      width: 100%;
-    }
-    .warning {
-      color: var(--error-text-color);
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Move Change"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Move Change to Another Branch</div>
-    <div class="main" slot="main">
-      <p class="warning">
-        Warning: moving a change will not change its parents.
-      </p>
-      <label for="branchInput"> Move change to branch </label>
-      <gr-autocomplete
-        id="branchInput"
-        text="{{branch}}"
-        query="[[_query]]"
-        placeholder="Destination branch"
-      >
-      </gr-autocomplete>
-      <label for="messageInput"> Move Change Message </label>
-      <iron-autogrow-textarea
-        id="messageInput"
-        class="message"
-        autocomplete="on"
-        rows="4"
-        max-rows="15"
-        bind-value="{{message}}"
-      ></iron-autogrow-textarea>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.ts
index ea5d320..af75b48 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.ts
@@ -18,15 +18,15 @@
 import '../../../test/common-test-setup-karma';
 import './gr-confirm-move-dialog';
 import {GrConfirmMoveDialog} from './gr-confirm-move-dialog';
-import {stubRestApi} from '../../../test/test-utils';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {BranchName, GitRef, RepoName} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-confirm-move-dialog');
+import {fixture, html} from '@open-wc/testing-helpers';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 
 suite('gr-confirm-move-dialog tests', () => {
   let element: GrConfirmMoveDialog;
 
-  setup(() => {
+  setup(async () => {
     stubRestApi('getRepoBranches').callsFake((input: string) => {
       if (input.startsWith('test')) {
         return Promise.resolve([
@@ -40,35 +40,70 @@
         return Promise.resolve([]);
       }
     });
-    element = basicFixture.instantiate();
-    element.project = 'test-repo' as RepoName;
+    element = await fixture(
+      html`<gr-confirm-move-dialog
+        .project=${'test-repo' as RepoName}
+      ></gr-confirm-move-dialog>`
+    );
   });
 
-  test('with updated commit message', () => {
+  test('render', async () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <gr-dialog confirm-label="Move Change" role="dialog">
+        <div class="header" slot="header">Move Change to Another Branch</div>
+        <div class="main" slot="main">
+          <p class="warning">
+            Warning: moving a change will not change its parents.
+          </p>
+          <label for="branchInput"> Move change to branch </label>
+          <gr-autocomplete id="branchInput" placeholder="Destination branch">
+          </gr-autocomplete>
+          <label for="messageInput"> Move Change Message </label>
+          <iron-autogrow-textarea
+            aria-disabled="false"
+            id="messageInput"
+            class="message"
+            autocomplete="on"
+          ></iron-autogrow-textarea>
+        </div>
+      </gr-dialog>
+    `);
+  });
+
+  test('with updated commit message', async () => {
     element.branch = 'master' as BranchName;
     const myNewMessage = 'updated commit message';
     element.message = myNewMessage;
-    flush();
+    await element.updateComplete;
+
     assert.equal(element.message, myNewMessage);
   });
 
-  test('_getProjectBranchesSuggestions empty', async () => {
-    const branches = await element._getProjectBranchesSuggestions(
-      'nonexistent'
+  test('suggestions empty', async () => {
+    const autoComplete = queryAndAssert<GrAutocomplete>(
+      element,
+      'gr-autocomplete'
     );
+    const branches = await autoComplete.query!('nonexistent');
     assert.equal(branches.length, 0);
   });
 
-  test('_getProjectBranchesSuggestions non-empty', async () => {
-    const branches = await element._getProjectBranchesSuggestions(
-      'test-branch'
+  test('suggestions non-empty', async () => {
+    const autoComplete = queryAndAssert<GrAutocomplete>(
+      element,
+      'gr-autocomplete'
     );
+    const branches = await autoComplete.query!('test-branch');
     assert.equal(branches.length, 1);
     assert.equal(branches[0].name, 'test-branch');
   });
 
-  test('_getProjectBranchesSuggestions input empty string', async () => {
-    const branches = await element._getProjectBranchesSuggestions('');
+  test('suggestions input empty string', async () => {
+    const autoComplete = queryAndAssert<GrAutocomplete>(
+      element,
+      'gr-autocomplete'
+    );
+    const branches = await autoComplete.query!('');
     assert.equal(branches.length, 0);
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index 00f65b0..193e136 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -14,20 +14,18 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-autocomplete/gr-autocomplete';
-import '../../shared/gr-dialog/gr-dialog';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-rebase-dialog_html';
-import {customElement, property, observe} from '@polymer/decorators';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
 import {NumericChangeId, BranchName} from '../../../types/common';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-autocomplete/gr-autocomplete';
 import {
   GrAutocomplete,
   AutocompleteQuery,
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getAppContext} from '../../../services/app-context';
-import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {sharedStyles} from '../../../styles/shared-styles';
 
 export interface RebaseChange {
   name: string;
@@ -38,26 +36,8 @@
   base: string | null;
 }
 
-export interface GrConfirmRebaseDialog {
-  $: {
-    confirmDialog: GrDialog;
-    parentInput: GrAutocomplete;
-    parentUpToDateMsg: HTMLDivElement;
-    rebaseOnParent: HTMLDivElement;
-    rebaseOnParentInput: HTMLInputElement;
-    rebaseOnOtherInput: HTMLInputElement;
-    rebaseOnTip: HTMLDivElement;
-    rebaseOnTipInput: HTMLInputElement;
-    tipUpToDateMsg: HTMLDivElement;
-  };
-}
-
 @customElement('gr-confirm-rebase-dialog')
-export class GrConfirmRebaseDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrConfirmRebaseDialog extends LitElement {
   /**
    * Fired when the confirm button is pressed.
    *
@@ -82,20 +62,154 @@
   @property({type: Boolean})
   rebaseOnCurrent?: boolean;
 
-  @property({type: String})
-  _text = '';
+  @state()
+  text = '';
 
-  @property({type: Object})
-  _query: AutocompleteQuery = () => Promise.resolve([]);
+  @state()
+  private query: AutocompleteQuery;
 
-  @property({type: Array})
-  _recentChanges?: RebaseChange[];
+  @state()
+  recentChanges?: RebaseChange[];
+
+  @query('#rebaseOnParentInput')
+  private rebaseOnParentInput!: HTMLInputElement;
+
+  @query('#rebaseOnTipInput')
+  private rebaseOnTipInput!: HTMLInputElement;
+
+  @query('#rebaseOnOtherInput')
+  rebaseOnOtherInput!: HTMLInputElement;
+
+  @query('#parentInput')
+  parentInput!: GrAutocomplete;
 
   private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
-    this._query = input => this._getChangeSuggestions(input);
+    this.query = input => this.getChangeSuggestions(input);
+  }
+
+  override willUpdate(changedProperties: PropertyValues): void {
+    if (
+      changedProperties.has('rebaseOnCurrent') ||
+      changedProperties.has('hasParent')
+    ) {
+      this.updateSelectedOption();
+    }
+  }
+
+  static override styles = [
+    sharedStyles,
+    css`
+      :host {
+        display: block;
+        width: 30em;
+      }
+      :host([disabled]) {
+        opacity: 0.5;
+        pointer-events: none;
+      }
+      label {
+        cursor: pointer;
+      }
+      .message {
+        font-style: italic;
+      }
+      .parentRevisionContainer label,
+      .parentRevisionContainer input[type='text'] {
+        display: block;
+        width: 100%;
+      }
+      .parentRevisionContainer label {
+        margin-bottom: var(--spacing-xs);
+      }
+      .rebaseOption {
+        margin: var(--spacing-m) 0;
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <gr-dialog
+        id="confirmDialog"
+        confirm-label="Rebase"
+        @confirm=${this.handleConfirmTap}
+        @cancel=${this.handleCancelTap}
+      >
+        <div class="header" slot="header">Confirm rebase</div>
+        <div class="main" slot="main">
+          <div
+            id="rebaseOnParent"
+            class="rebaseOption"
+            ?hidden=${!this.displayParentOption()}
+          >
+            <input id="rebaseOnParentInput" name="rebaseOptions" type="radio" />
+            <label id="rebaseOnParentLabel" for="rebaseOnParentInput">
+              Rebase on parent change
+            </label>
+          </div>
+          <div
+            id="parentUpToDateMsg"
+            class="message"
+            ?hidden=${!this.displayParentUpToDateMsg()}
+          >
+            This change is up to date with its parent.
+          </div>
+          <div
+            id="rebaseOnTip"
+            class="rebaseOption"
+            ?hidden=${!this.displayTipOption()}
+          >
+            <input
+              id="rebaseOnTipInput"
+              name="rebaseOptions"
+              type="radio"
+              ?disabled=${!this.displayTipOption()}
+            />
+            <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
+              Rebase on top of the ${this.branch} branch<span
+                ?hidden=${!this.hasParent}
+              >
+                (breaks relation chain)
+              </span>
+            </label>
+          </div>
+          <div
+            id="tipUpToDateMsg"
+            class="message"
+            ?hidden=${this.displayTipOption()}
+          >
+            Change is up to date with the target branch already (${this.branch})
+          </div>
+          <div id="rebaseOnOther" class="rebaseOption">
+            <input
+              id="rebaseOnOtherInput"
+              name="rebaseOptions"
+              type="radio"
+              @click=${this.handleRebaseOnOther}
+            />
+            <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
+              Rebase on a specific change, ref, or commit
+              <span ?hidden=${!this.hasParent}> (breaks relation chain) </span>
+            </label>
+          </div>
+          <div class="parentRevisionContainer">
+            <gr-autocomplete
+              id="parentInput"
+              .query=${this.query}
+              no-debounce
+              text=${this.text}
+              @click=${this.handleEnterChangeNumberClick}
+              allow-non-suggested-values
+              placeholder="Change number, ref, or commit hash"
+            >
+            </gr-autocomplete>
+          </div>
+        </div>
+      </gr-dialog>
+    `;
   }
 
   // This is called by gr-change-actions every time the rebase dialog is
@@ -116,25 +230,25 @@
             value: change._number,
           });
         }
-        this._recentChanges = changes;
-        return this._recentChanges;
+        this.recentChanges = changes;
+        return this.recentChanges;
       });
   }
 
-  _getRecentChanges() {
-    if (this._recentChanges) {
-      return Promise.resolve(this._recentChanges);
+  getRecentChanges() {
+    if (this.recentChanges) {
+      return Promise.resolve(this.recentChanges);
     }
     return this.fetchRecentChanges();
   }
 
-  _getChangeSuggestions(input: string) {
-    return this._getRecentChanges().then(changes =>
-      this._filterChanges(input, changes)
+  private getChangeSuggestions(input: string) {
+    return this.getRecentChanges().then(changes =>
+      this.filterChanges(input, changes)
     );
   }
 
-  _filterChanges(
+  filterChanges(
     input: string,
     changes: RebaseChange[]
   ): AutocompleteSuggestion[] {
@@ -152,16 +266,16 @@
       );
   }
 
-  _displayParentOption(rebaseOnCurrent?: boolean, hasParent?: boolean) {
-    return hasParent && rebaseOnCurrent;
+  private displayParentOption() {
+    return this.hasParent && this.rebaseOnCurrent;
   }
 
-  _displayParentUpToDateMsg(rebaseOnCurrent?: boolean, hasParent?: boolean) {
-    return hasParent && !rebaseOnCurrent;
+  private displayParentUpToDateMsg() {
+    return this.hasParent && !this.rebaseOnCurrent;
   }
 
-  _displayTipOption(rebaseOnCurrent?: boolean, hasParent?: boolean) {
-    return !(!rebaseOnCurrent && !hasParent);
+  private displayTipOption() {
+    return this.rebaseOnCurrent || this.hasParent;
   }
 
   /**
@@ -171,63 +285,62 @@
    * rebased on top of the target branch. Leaving out the base implies that it
    * should be rebased on top of its current parent.
    */
-  _getSelectedBase() {
-    if (this.$.rebaseOnParentInput.checked) {
+  getSelectedBase() {
+    if (this.rebaseOnParentInput.checked) {
       return null;
     }
-    if (this.$.rebaseOnTipInput.checked) {
+    if (this.rebaseOnTipInput.checked) {
       return '';
     }
-    if (!this._text) {
+    if (!this.text) {
       return '';
     }
     // Change numbers will have their description appended by the
     // autocomplete.
-    return this._text.split(':')[0];
+    return this.text.split(':')[0];
   }
 
-  _handleConfirmTap(e: Event) {
+  private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     const detail: ConfirmRebaseEventDetail = {
-      base: this._getSelectedBase(),
+      base: this.getSelectedBase(),
     };
     this.dispatchEvent(new CustomEvent('confirm', {detail}));
-    this._text = '';
+    this.text = '';
   }
 
-  _handleCancelTap(e: Event) {
+  private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(new CustomEvent('cancel'));
-    this._text = '';
+    this.text = '';
   }
 
-  _handleRebaseOnOther() {
-    this.$.parentInput.focus();
+  private handleRebaseOnOther() {
+    this.parentInput.focus();
   }
 
-  _handleEnterChangeNumberClick() {
-    this.$.rebaseOnOtherInput.checked = true;
+  private handleEnterChangeNumberClick() {
+    this.rebaseOnOtherInput.checked = true;
   }
 
   /**
    * Sets the default radio button based on the state of the app and
    * the corresponding value to be submitted.
    */
-  @observe('rebaseOnCurrent', 'hasParent')
-  _updateSelectedOption(rebaseOnCurrent?: boolean, hasParent?: boolean) {
-    // Polymer 2: check for undefined
+  private updateSelectedOption() {
+    const {rebaseOnCurrent, hasParent} = this;
     if (rebaseOnCurrent === undefined || hasParent === undefined) {
       return;
     }
 
-    if (this._displayParentOption(rebaseOnCurrent, hasParent)) {
-      this.$.rebaseOnParentInput.checked = true;
-    } else if (this._displayTipOption(rebaseOnCurrent, hasParent)) {
-      this.$.rebaseOnTipInput.checked = true;
+    if (this.displayParentOption()) {
+      this.rebaseOnParentInput.checked = true;
+    } else if (this.displayTipOption()) {
+      this.rebaseOnTipInput.checked = true;
     } else {
-      this.$.rebaseOnOtherInput.checked = true;
+      this.rebaseOnOtherInput.checked = true;
     }
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
deleted file mode 100644
index 1052201..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_html.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-      width: 30em;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    label {
-      cursor: pointer;
-    }
-    .message {
-      font-style: italic;
-    }
-    .parentRevisionContainer label,
-    .parentRevisionContainer input[type='text'] {
-      display: block;
-      width: 100%;
-    }
-    .parentRevisionContainer label {
-      margin-bottom: var(--spacing-xs);
-    }
-    .rebaseOption {
-      margin: var(--spacing-m) 0;
-    }
-  </style>
-  <gr-dialog
-    id="confirmDialog"
-    confirm-label="Rebase"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Confirm rebase</div>
-    <div class="main" slot="main">
-      <div
-        id="rebaseOnParent"
-        class="rebaseOption"
-        hidden$="[[!_displayParentOption(rebaseOnCurrent, hasParent)]]"
-      >
-        <input id="rebaseOnParentInput" name="rebaseOptions" type="radio" />
-        <label id="rebaseOnParentLabel" for="rebaseOnParentInput">
-          Rebase on parent change
-        </label>
-      </div>
-      <div
-        id="parentUpToDateMsg"
-        class="message"
-        hidden$="[[!_displayParentUpToDateMsg(rebaseOnCurrent, hasParent)]]"
-      >
-        This change is up to date with its parent.
-      </div>
-      <div
-        id="rebaseOnTip"
-        class="rebaseOption"
-        hidden$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
-      >
-        <input
-          id="rebaseOnTipInput"
-          name="rebaseOptions"
-          type="radio"
-          disabled$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
-        />
-        <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
-          Rebase on top of the [[branch]] branch<span hidden$="[[!hasParent]]">
-            (breaks relation chain)
-          </span>
-        </label>
-      </div>
-      <div
-        id="tipUpToDateMsg"
-        class="message"
-        hidden$="[[_displayTipOption(rebaseOnCurrent, hasParent)]]"
-      >
-        Change is up to date with the target branch already ([[branch]])
-      </div>
-      <div id="rebaseOnOther" class="rebaseOption">
-        <input
-          id="rebaseOnOtherInput"
-          name="rebaseOptions"
-          type="radio"
-          on-click="_handleRebaseOnOther"
-        />
-        <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
-          Rebase on a specific change, ref, or commit
-          <span hidden$="[[!hasParent]]"> (breaks relation chain) </span>
-        </label>
-      </div>
-      <div class="parentRevisionContainer">
-        <gr-autocomplete
-          id="parentInput"
-          query="[[_query]]"
-          no-debounce=""
-          text="{{_text}}"
-          on-click="_handleEnterChangeNumberClick"
-          allow-non-suggested-values=""
-          placeholder="Change number, ref, or commit hash"
-        >
-        </gr-autocomplete>
-      </div>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
index 74c1b3c..17afe92 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
@@ -18,95 +18,225 @@
 import '../../../test/common-test-setup-karma';
 import './gr-confirm-rebase-dialog';
 import {GrConfirmRebaseDialog, RebaseChange} from './gr-confirm-rebase-dialog';
-import {stubRestApi} from '../../../test/test-utils';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {NumericChangeId} from '../../../types/common';
 import {createChangeViewChange} from '../../../test/test-data-generators';
-
-const basicFixture = fixtureFromElement('gr-confirm-rebase-dialog');
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-confirm-rebase-dialog tests', () => {
   let element: GrConfirmRebaseDialog;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-confirm-rebase-dialog></gr-confirm-rebase-dialog>`
+    );
   });
 
-  test('controls with parent and rebase on current available', () => {
+  test('render', async () => {
+    expect(element).shadowDom.to.equal(/* HTML*/ `
+      <gr-dialog
+        id="confirmDialog"
+        confirm-label="Rebase"
+        role="dialog"
+      >
+        <div class="header" slot="header">Confirm rebase</div>
+        <div class="main" slot="main">
+          <div
+            id="rebaseOnParent"
+            class="rebaseOption"
+            hidden=""
+          >
+            <input id="rebaseOnParentInput" name="rebaseOptions" type="radio" />
+            <label id="rebaseOnParentLabel" for="rebaseOnParentInput">
+              Rebase on parent change
+            </label>
+          </div>
+          <div
+            id="parentUpToDateMsg"
+            class="message"
+            hidden=""
+          >
+            This change is up to date with its parent.
+          </div>
+          <div
+            id="rebaseOnTip"
+            class="rebaseOption"
+            hidden=""
+          >
+            <input
+              disabled=""
+              id="rebaseOnTipInput"
+              name="rebaseOptions"
+              type="radio"
+            />
+            <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
+              Rebase on top of the  branch<span hidden=""
+              >
+                (breaks relation chain)
+              </span>
+            </label>
+          </div>
+          <div
+            id="tipUpToDateMsg"
+            class="message"
+          >
+            Change is up to date with the target branch already ()
+          </div>
+          <div id="rebaseOnOther" class="rebaseOption">
+            <input
+              id="rebaseOnOtherInput"
+              name="rebaseOptions"
+              type="radio"
+            />
+            <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
+              Rebase on a specific change, ref, or commit
+              <span hidden=""> (breaks relation chain) </span>
+            </label>
+          </div>
+          <div class="parentRevisionContainer">
+            <gr-autocomplete
+              id="parentInput"
+              no-debounce=""
+              allow-non-suggested-values
+              placeholder="Change number, ref, or commit hash"
+              text=""
+            >
+            </gr-autocomplete>
+          </div>
+        </div>
+      </gr-dialog>
+    `);
+  });
+
+  test('controls with parent and rebase on current available', async () => {
     element.rebaseOnCurrent = true;
     element.hasParent = true;
-    flush();
-    assert.isTrue(element.$.rebaseOnParentInput.checked);
-    assert.isFalse(element.$.rebaseOnParent.hasAttribute('hidden'));
-    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
-    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+    await element.updateComplete;
+
+    assert.isTrue(
+      queryAndAssert<HTMLInputElement>(element, '#rebaseOnParentInput').checked
+    );
+    assert.isFalse(
+      queryAndAssert(element, '#rebaseOnParent').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#parentUpToDateMsg').hasAttribute('hidden')
+    );
+    assert.isFalse(
+      queryAndAssert(element, '#rebaseOnTip').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#tipUpToDateMsg').hasAttribute('hidden')
+    );
   });
 
-  test('controls with parent rebase on current not available', () => {
+  test('controls with parent rebase on current not available', async () => {
     element.rebaseOnCurrent = false;
     element.hasParent = true;
-    flush();
-    assert.isTrue(element.$.rebaseOnTipInput.checked);
-    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
-    assert.isFalse(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
-    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+    await element.updateComplete;
+
+    assert.isTrue(
+      queryAndAssert<HTMLInputElement>(element, '#rebaseOnTipInput').checked
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#rebaseOnParent').hasAttribute('hidden')
+    );
+    assert.isFalse(
+      queryAndAssert(element, '#parentUpToDateMsg').hasAttribute('hidden')
+    );
+    assert.isFalse(
+      queryAndAssert(element, '#rebaseOnTip').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#tipUpToDateMsg').hasAttribute('hidden')
+    );
   });
 
-  test('controls without parent and rebase on current available', () => {
+  test('controls without parent and rebase on current available', async () => {
     element.rebaseOnCurrent = true;
     element.hasParent = false;
-    flush();
-    assert.isTrue(element.$.rebaseOnTipInput.checked);
-    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
-    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-    assert.isFalse(element.$.rebaseOnTip.hasAttribute('hidden'));
-    assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+    await element.updateComplete;
+
+    assert.isTrue(
+      queryAndAssert<HTMLInputElement>(element, '#rebaseOnTipInput').checked
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#rebaseOnParent').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#parentUpToDateMsg').hasAttribute('hidden')
+    );
+    assert.isFalse(
+      queryAndAssert(element, '#rebaseOnTip').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#tipUpToDateMsg').hasAttribute('hidden')
+    );
   });
 
-  test('controls without parent rebase on current not available', () => {
+  test('controls without parent rebase on current not available', async () => {
     element.rebaseOnCurrent = false;
     element.hasParent = false;
-    flush();
-    assert.isTrue(element.$.rebaseOnOtherInput.checked);
-    assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
-    assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
-    assert.isTrue(element.$.rebaseOnTip.hasAttribute('hidden'));
-    assert.isFalse(element.$.tipUpToDateMsg.hasAttribute('hidden'));
+    await element.updateComplete;
+
+    assert.isTrue(element.rebaseOnOtherInput.checked);
+    assert.isTrue(
+      queryAndAssert(element, '#rebaseOnParent').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#parentUpToDateMsg').hasAttribute('hidden')
+    );
+    assert.isTrue(
+      queryAndAssert(element, '#rebaseOnTip').hasAttribute('hidden')
+    );
+    assert.isFalse(
+      queryAndAssert(element, '#tipUpToDateMsg').hasAttribute('hidden')
+    );
   });
 
-  test('input cleared on cancel or submit', () => {
-    element._text = '123';
-    element.$.confirmDialog.dispatchEvent(
+  test('input cleared on cancel or submit', async () => {
+    element.text = '123';
+    await element.updateComplete;
+    queryAndAssert(element, '#confirmDialog').dispatchEvent(
       new CustomEvent('confirm', {
         composed: true,
         bubbles: true,
       })
     );
-    assert.equal(element._text, '');
+    assert.equal(element.text, '');
 
-    element._text = '123';
-    element.$.confirmDialog.dispatchEvent(
+    element.text = '123';
+    await element.updateComplete;
+
+    queryAndAssert(element, '#confirmDialog').dispatchEvent(
       new CustomEvent('cancel', {
         composed: true,
         bubbles: true,
       })
     );
-    assert.equal(element._text, '');
+    assert.equal(element.text, '');
   });
 
-  test('_getSelectedBase', () => {
-    element._text = '5fab321c';
-    element.$.rebaseOnParentInput.checked = true;
-    assert.equal(element._getSelectedBase(), null);
-    element.$.rebaseOnParentInput.checked = false;
-    element.$.rebaseOnTipInput.checked = true;
-    assert.equal(element._getSelectedBase(), '');
-    element.$.rebaseOnTipInput.checked = false;
-    assert.equal(element._getSelectedBase(), element._text);
-    element._text = '101: Test';
-    assert.equal(element._getSelectedBase(), '101');
+  test('_getSelectedBase', async () => {
+    element.text = '5fab321c';
+    await element.updateComplete;
+
+    queryAndAssert<HTMLInputElement>(element, '#rebaseOnParentInput').checked =
+      true;
+    assert.equal(element.getSelectedBase(), null);
+    queryAndAssert<HTMLInputElement>(element, '#rebaseOnParentInput').checked =
+      false;
+    queryAndAssert<HTMLInputElement>(element, '#rebaseOnTipInput').checked =
+      true;
+    assert.equal(element.getSelectedBase(), '');
+    queryAndAssert<HTMLInputElement>(element, '#rebaseOnTipInput').checked =
+      false;
+    assert.equal(element.getSelectedBase(), element.text);
+    element.text = '101: Test';
+    await element.updateComplete;
+
+    assert.equal(element.getSelectedBase(), '101');
   });
 
   suite('parent suggestions', () => {
@@ -149,46 +279,52 @@
       );
     });
 
-    test('_getRecentChanges', () => {
-      const recentChangesSpy = sinon.spy(element, '_getRecentChanges');
-      return element
-        ._getRecentChanges()
-        .then(() => {
-          assert.deepEqual(element._recentChanges, recentChanges);
-          assert.equal(getChangesStub.callCount, 1);
-          // When called a second time, should not re-request recent changes.
-          element._getRecentChanges();
-        })
-        .then(() => {
-          assert.equal(recentChangesSpy.callCount, 2);
-          assert.equal(getChangesStub.callCount, 1);
-        });
+    test('_getRecentChanges', async () => {
+      const recentChangesSpy = sinon.spy(element, 'getRecentChanges');
+      await element.getRecentChanges();
+      await element.updateComplete;
+
+      assert.deepEqual(element.recentChanges, recentChanges);
+      assert.equal(getChangesStub.callCount, 1);
+
+      // When called a second time, should not re-request recent changes.
+      await element.getRecentChanges();
+      await element.updateComplete;
+
+      assert.equal(recentChangesSpy.callCount, 2);
+      assert.equal(getChangesStub.callCount, 1);
     });
 
-    test('_filterChanges', () => {
-      assert.equal(element._filterChanges('123', recentChanges).length, 1);
-      assert.equal(element._filterChanges('12', recentChanges).length, 2);
-      assert.equal(element._filterChanges('awesome', recentChanges).length, 3);
-      assert.equal(element._filterChanges('third', recentChanges).length, 1);
+    test('_filterChanges', async () => {
+      assert.equal(element.filterChanges('123', recentChanges).length, 1);
+      assert.equal(element.filterChanges('12', recentChanges).length, 2);
+      assert.equal(element.filterChanges('awesome', recentChanges).length, 3);
+      assert.equal(element.filterChanges('third', recentChanges).length, 1);
 
       element.changeNumber = 123 as NumericChangeId;
-      assert.equal(element._filterChanges('123', recentChanges).length, 0);
-      assert.equal(element._filterChanges('124', recentChanges).length, 1);
-      assert.equal(element._filterChanges('awesome', recentChanges).length, 2);
+      await element.updateComplete;
+
+      assert.equal(element.filterChanges('123', recentChanges).length, 0);
+      assert.equal(element.filterChanges('124', recentChanges).length, 1);
+      assert.equal(element.filterChanges('awesome', recentChanges).length, 2);
     });
 
-    test('input text change triggers function', () => {
-      const recentChangesSpy = sinon.spy(element, '_getRecentChanges');
-      element.$.parentInput.noDebounce = true;
+    test('input text change triggers function', async () => {
+      const recentChangesSpy = sinon.spy(element, 'getRecentChanges');
+      element.parentInput.noDebounce = true;
       MockInteractions.pressAndReleaseKeyOn(
-        element.$.parentInput.$.input,
+        queryAndAssert(queryAndAssert(element, '#parentInput'), '#input'),
         13,
         null,
         'enter'
       );
-      element._text = '1';
+      element.text = '1';
+      await element.updateComplete;
+
       assert.isTrue(recentChangesSpy.calledOnce);
-      element._text = '12';
+      element.text = '12';
+      await element.updateComplete;
+
       assert.isTrue(recentChangesSpy.calledTwice);
     });
   });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index e0532c2..8ea2bf5 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -15,14 +15,15 @@
  * limitations under the License.
  */
 import '../../shared/gr-dialog/gr-dialog';
-import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-revert-dialog_html';
-import {customElement, property} from '@polymer/decorators';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {LitElement, html, css, nothing} from 'lit';
+import {customElement, state} from 'lit/decorators';
 import {ChangeInfo, CommitId} from '../../../types/common';
 import {fireAlert} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {BindValueChangeEvent} from '../../../types/events';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
 const CHANGE_SUBJECT_LIMIT = 50;
@@ -40,11 +41,7 @@
 }
 
 @customElement('gr-confirm-revert-dialog')
-export class GrConfirmRevertDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrConfirmRevertDialog extends LitElement {
   /**
    * Fired when the confirm button is pressed.
    *
@@ -59,57 +56,154 @@
 
   /* The revert message updated by the user
       The default value is set by the dialog */
-  @property({type: String})
-  _message = '';
+  @state()
+  message = '';
 
-  @property({type: Number})
-  _revertType = RevertType.REVERT_SINGLE_CHANGE;
+  @state()
+  private revertType = RevertType.REVERT_SINGLE_CHANGE;
 
-  @property({type: Boolean})
-  _showRevertSubmission = false;
+  @state()
+  private showRevertSubmission = false;
 
-  @property({type: Number})
-  _changesCount?: number;
+  @state()
+  private changesCount?: number;
 
-  @property({type: Boolean})
-  _showErrorMessage = false;
+  @state()
+  showErrorMessage = false;
 
   /* store the default revert messages per revert type so that we can
   check if user has edited the revert message or not
   Set when populate() is called */
-  @property({type: Array})
-  _originalRevertMessages: string[] = [];
+  @state()
+  private originalRevertMessages: string[] = [];
 
   // Store the actual messages that the user has edited
-  @property({type: Array})
-  _revertMessages: string[] = [];
+  @state()
+  private revertMessages: string[] = [];
+
+  static override styles = [
+    sharedStyles,
+    css`
+      :host {
+        display: block;
+      }
+      :host([disabled]) {
+        opacity: 0.5;
+        pointer-events: none;
+      }
+      label {
+        cursor: pointer;
+        display: block;
+        width: 100%;
+      }
+      .revertSubmissionLayout {
+        display: flex;
+        align-items: center;
+      }
+      .label {
+        margin-left: var(--spacing-m);
+      }
+      iron-autogrow-textarea {
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-mono);
+        line-height: var(--line-height-mono);
+        width: 73ch; /* Add a char to account for the border. */
+      }
+      .error {
+        color: var(--error-text-color);
+        margin-bottom: var(--spacing-m);
+      }
+      label[for='messageInput'] {
+        margin-top: var(--spacing-m);
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <gr-dialog
+        .confirmLabel=${'Revert'}
+        @confirm=${(e: Event) => this.handleConfirmTap(e)}
+        @cancel=${(e: Event) => this.handleCancelTap(e)}
+      >
+        <div class="header" slot="header">Revert Merged Change</div>
+        <div class="main" slot="main">
+          <div class="error" ?hidden=${!this.showErrorMessage}>
+            <span> A reason is required </span>
+          </div>
+          ${this.showRevertSubmission
+            ? html`
+                <div class="revertSubmissionLayout">
+                  <input
+                    name="revertOptions"
+                    type="radio"
+                    id="revertSingleChange"
+                    @change=${() => this.handleRevertSingleChangeClicked()}
+                    ?checked=${this.computeIfSingleRevert()}
+                  />
+                  <label
+                    for="revertSingleChange"
+                    class="label revertSingleChange"
+                  >
+                    Revert single change
+                  </label>
+                </div>
+                <div class="revertSubmissionLayout">
+                  <input
+                    name="revertOptions"
+                    type="radio"
+                    id="revertSubmission"
+                    @change=${() => this.handleRevertSubmissionClicked()}
+                    .checked=${this.computeIfRevertSubmission()}
+                  />
+                  <label for="revertSubmission" class="label revertSubmission">
+                    Revert entire submission (${this.changesCount} Changes)
+                  </label>
+                </div>
+              `
+            : nothing}
+          <gr-endpoint-decorator name="confirm-revert-change">
+            <label for="messageInput"> Revert Commit Message </label>
+            <iron-autogrow-textarea
+              id="messageInput"
+              class="message"
+              .autocomplete=${'on'}
+              .maxRows=${15}
+              .bindValue=${this.message}
+              @bind-value-changed=${this.handleBindValueChanged}
+            ></iron-autogrow-textarea>
+          </gr-endpoint-decorator>
+        </div>
+      </gr-dialog>
+    `;
+  }
 
   private readonly jsAPI = getAppContext().jsApiService;
 
-  _computeIfSingleRevert(revertType: number) {
-    return revertType === RevertType.REVERT_SINGLE_CHANGE;
+  private computeIfSingleRevert() {
+    return this.revertType === RevertType.REVERT_SINGLE_CHANGE;
   }
 
-  _computeIfRevertSubmission(revertType: number) {
-    return revertType === RevertType.REVERT_SUBMISSION;
+  private computeIfRevertSubmission() {
+    return this.revertType === RevertType.REVERT_SUBMISSION;
   }
 
-  _modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
+  modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
     return this.jsAPI.modifyRevertMsg(change, message, commitMessage);
   }
 
   populate(change: ChangeInfo, commitMessage: string, changes: ChangeInfo[]) {
-    this._changesCount = changes.length;
+    this.changesCount = changes.length;
     // The option to revert a single change is always available
-    this._populateRevertSingleChangeMessage(
+    this.populateRevertSingleChangeMessage(
       change,
       commitMessage,
       change.current_revision
     );
-    this._populateRevertSubmissionMessage(change, changes, commitMessage);
+    this.populateRevertSubmissionMessage(change, changes, commitMessage);
   }
 
-  _populateRevertSingleChangeMessage(
+  populateRevertSingleChangeMessage(
     change: ChangeInfo,
     commitMessage: string,
     commitHash?: CommitId
@@ -127,20 +221,20 @@
       `${revertTitle}\n\n${revertCommitText}\n\n` +
       `Reason for revert: ${INSERT_REASON_STRING}\n`;
     // This is to give plugins a chance to update message
-    this._message = this._modifyRevertMsg(change, commitMessage, message);
-    this._revertType = RevertType.REVERT_SINGLE_CHANGE;
-    this._showRevertSubmission = false;
-    this._revertMessages[this._revertType] = this._message;
-    this._originalRevertMessages[this._revertType] = this._message;
+    this.message = this.modifyRevertMsg(change, commitMessage, message);
+    this.revertType = RevertType.REVERT_SINGLE_CHANGE;
+    this.showRevertSubmission = false;
+    this.revertMessages[this.revertType] = this.message;
+    this.originalRevertMessages[this.revertType] = this.message;
   }
 
-  _getTrimmedChangeSubject(subject: string) {
+  private getTrimmedChangeSubject(subject: string) {
     if (!subject) return '';
     if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
     return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
   }
 
-  _modifyRevertSubmissionMsg(
+  private modifyRevertSubmissionMsg(
     change: ChangeInfo,
     msg: string,
     commitMessage: string
@@ -148,7 +242,7 @@
     return this.jsAPI.modifyRevertSubmissionMsg(change, msg, commitMessage);
   }
 
-  _populateRevertSubmissionMessage(
+  populateRevertSubmissionMessage(
     change: ChangeInfo,
     changes: ChangeInfo[],
     commitMessage: string
@@ -170,48 +264,52 @@
     changes.forEach(change => {
       message +=
         `${change.change_id.substring(0, 10)}:` +
-        `${this._getTrimmedChangeSubject(change.subject)}\n`;
+        `${this.getTrimmedChangeSubject(change.subject)}\n`;
     });
-    this._message = this._modifyRevertSubmissionMsg(
+    this.message = this.modifyRevertSubmissionMsg(
       change,
       message,
       commitMessage
     );
-    this._revertType = RevertType.REVERT_SUBMISSION;
-    this._revertMessages[this._revertType] = this._message;
-    this._originalRevertMessages[this._revertType] = this._message;
-    this._showRevertSubmission = true;
+    this.revertType = RevertType.REVERT_SUBMISSION;
+    this.revertMessages[this.revertType] = this.message;
+    this.originalRevertMessages[this.revertType] = this.message;
+    this.showRevertSubmission = true;
   }
 
-  _handleRevertSingleChangeClicked() {
-    this._showErrorMessage = false;
-    if (this._message)
-      this._revertMessages[RevertType.REVERT_SUBMISSION] = this._message;
-    this._message = this._revertMessages[RevertType.REVERT_SINGLE_CHANGE];
-    this._revertType = RevertType.REVERT_SINGLE_CHANGE;
+  private handleBindValueChanged(e: BindValueChangeEvent) {
+    this.message = e.detail.value;
   }
 
-  _handleRevertSubmissionClicked() {
-    this._showErrorMessage = false;
-    this._revertType = RevertType.REVERT_SUBMISSION;
-    if (this._message)
-      this._revertMessages[RevertType.REVERT_SINGLE_CHANGE] = this._message;
-    this._message = this._revertMessages[RevertType.REVERT_SUBMISSION];
+  private handleRevertSingleChangeClicked() {
+    this.showErrorMessage = false;
+    if (this.message)
+      this.revertMessages[RevertType.REVERT_SUBMISSION] = this.message;
+    this.message = this.revertMessages[RevertType.REVERT_SINGLE_CHANGE];
+    this.revertType = RevertType.REVERT_SINGLE_CHANGE;
   }
 
-  _handleConfirmTap(e: Event) {
+  private handleRevertSubmissionClicked() {
+    this.showErrorMessage = false;
+    this.revertType = RevertType.REVERT_SUBMISSION;
+    if (this.message)
+      this.revertMessages[RevertType.REVERT_SINGLE_CHANGE] = this.message;
+    this.message = this.revertMessages[RevertType.REVERT_SUBMISSION];
+  }
+
+  private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     if (
-      this._message === this._originalRevertMessages[this._revertType] ||
-      this._message.includes(INSERT_REASON_STRING)
+      this.message === this.originalRevertMessages[this.revertType] ||
+      this.message.includes(INSERT_REASON_STRING)
     ) {
-      this._showErrorMessage = true;
+      this.showErrorMessage = true;
       return;
     }
     const detail: ConfirmRevertEventDetail = {
-      revertType: this._revertType,
-      message: this._message,
+      revertType: this.revertType,
+      message: this.message,
     };
     this.dispatchEvent(
       new CustomEvent('confirm', {
@@ -222,12 +320,12 @@
     );
   }
 
-  _handleCancelTap(e: Event) {
+  private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
       new CustomEvent('cancel', {
-        detail: {revertType: this._revertType},
+        detail: {revertType: this.revertType},
         composed: true,
         bubbles: false,
       })
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
deleted file mode 100644
index b2acff2..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_html.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    label {
-      cursor: pointer;
-      display: block;
-      width: 100%;
-    }
-    .revertSubmissionLayout {
-      display: flex;
-      align-items: center;
-    }
-    .label {
-      margin-left: var(--spacing-m);
-    }
-    iron-autogrow-textarea {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      width: 73ch; /* Add a char to account for the border. */
-    }
-    .error {
-      color: var(--error-text-color);
-      margin-bottom: var(--spacing-m);
-    }
-    label[for='messageInput'] {
-      margin-top: var(--spacing-m);
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Revert"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Revert Merged Change</div>
-    <div class="main" slot="main">
-      <div class="error" hidden$="[[!_showErrorMessage]]">
-        <span> A reason is required </span>
-      </div>
-      <template is="dom-if" if="[[_showRevertSubmission]]">
-        <div class="revertSubmissionLayout">
-          <input
-            name="revertOptions"
-            type="radio"
-            id="revertSingleChange"
-            on-change="_handleRevertSingleChangeClicked"
-            checked="[[_computeIfSingleRevert(_revertType)]]"
-          />
-          <label for="revertSingleChange" class="label revertSingleChange">
-            Revert single change
-          </label>
-        </div>
-        <div class="revertSubmissionLayout">
-          <input
-            name="revertOptions"
-            type="radio"
-            id="revertSubmission"
-            on-change="_handleRevertSubmissionClicked"
-            checked="[[_computeIfRevertSubmission(_revertType)]]"
-          />
-          <label for="revertSubmission" class="label revertSubmission">
-            Revert entire submission ([[_changesCount]] Changes)
-          </label>
-        </div>
-      </template>
-      <gr-endpoint-decorator name="confirm-revert-change">
-        <label for="messageInput"> Revert Commit Message </label>
-        <iron-autogrow-textarea
-          id="messageInput"
-          class="message"
-          autocomplete="on"
-          max-rows="15"
-          bind-value="{{_message}}"
-        ></iron-autogrow-textarea>
-      </gr-endpoint-decorator>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
index 38429b1..0fdcb97 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
@@ -15,26 +15,48 @@
  * limitations under the License.
  */
 
+import {fixture, html} from '@open-wc/testing-helpers';
 import '../../../test/common-test-setup-karma';
 import {createChange} from '../../../test/test-data-generators';
 import {CommitId} from '../../../types/common';
 import './gr-confirm-revert-dialog';
 import {GrConfirmRevertDialog} from './gr-confirm-revert-dialog';
 
-const basicFixture = fixtureFromElement('gr-confirm-revert-dialog');
-
 suite('gr-confirm-revert-dialog tests', () => {
   let element: GrConfirmRevertDialog;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(
+      html`<gr-confirm-revert-dialog></gr-confirm-revert-dialog>`
+    );
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <gr-dialog role="dialog">
+        <div class="header" slot="header">Revert Merged Change</div>
+        <div class="main" slot="main">
+          <div class="error" hidden="">
+            <span> A reason is required </span>
+          </div>
+          <gr-endpoint-decorator name="confirm-revert-change">
+            <label for="messageInput"> Revert Commit Message </label>
+            <iron-autogrow-textarea
+              id="messageInput"
+              class="message"
+              aria-disabled="false"
+            ></iron-autogrow-textarea>
+          </gr-endpoint-decorator>
+        </div>
+      </gr-dialog>
+    `);
   });
 
   test('no match', () => {
-    assert.isNotOk(element._message);
+    assert.isNotOk(element.message);
     const alertStub = sinon.stub();
     element.addEventListener('show-alert', alertStub);
-    element._populateRevertSingleChangeMessage(
+    element.populateRevertSingleChangeMessage(
       createChange(),
       'not a commitHash in sight',
       undefined
@@ -43,8 +65,8 @@
   });
 
   test('single line', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage(
+    assert.isNotOk(element.message);
+    element.populateRevertSingleChangeMessage(
       createChange(),
       'one line commit\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
@@ -53,12 +75,12 @@
       'Revert "one line commit"\n\n' +
       'This reverts commit abcd123.\n\n' +
       'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
+    assert.equal(element.message, expected);
   });
 
   test('multi line', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage(
+    assert.isNotOk(element.message);
+    element.populateRevertSingleChangeMessage(
       createChange(),
       'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
@@ -67,12 +89,12 @@
       'Revert "many lines"\n\n' +
       'This reverts commit abcd123.\n\n' +
       'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
+    assert.equal(element.message, expected);
   });
 
   test('issue above change id', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage(
+    assert.isNotOk(element.message);
+    element.populateRevertSingleChangeMessage(
       createChange(),
       'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
@@ -81,12 +103,12 @@
       'Revert "much lines"\n\n' +
       'This reverts commit abcd123.\n\n' +
       'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
+    assert.equal(element.message, expected);
   });
 
   test('revert a revert', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage(
+    assert.isNotOk(element.message);
+    element.populateRevertSingleChangeMessage(
       createChange(),
       'Revert "one line commit"\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
@@ -95,6 +117,6 @@
       'Revert "Revert "one line commit""\n\n' +
       'This reverts commit abcd123.\n\n' +
       'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
+    assert.equal(element.message, expected);
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index 8cb50fd..de9395f 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -129,7 +129,7 @@
       </p>
       <gr-thread-list
         id="commentList"
-        .threads="${this.unresolvedThreads}"
+        .threads=${this.unresolvedThreads}
         hide-dropdown
       >
       </gr-thread-list>
@@ -159,11 +159,11 @@
           ${this.renderChangeEdit()}
           <gr-endpoint-param
             name="change"
-            .value="${this.change}"
+            .value=${this.change}
           ></gr-endpoint-param>
           <gr-endpoint-param
             name="action"
-            .value="${this.action}"
+            .value=${this.action}
           ></gr-endpoint-param>
         </gr-endpoint-decorator>
       </div>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index 604dc51..c1fa7d5 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -178,10 +178,10 @@
       <div class="patchFiles">
         <label>Patch file</label>
         <div>
-          <a id="download" .href="${this.computeDownloadLink()}" download>
+          <a id="download" .href=${this.computeDownloadLink()} download>
             ${this.computeDownloadFilename()}
           </a>
-          <a .href="${this.computeDownloadLink(true)}" download>
+          <a .href=${this.computeDownloadLink(true)} download>
             ${this.computeDownloadFilename(true)}
           </a>
         </div>
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
index 29410f3..e2852f6 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
@@ -176,10 +176,10 @@
   override render() {
     return html`
       <span
-        class="${classMap({
+        class=${classMap({
           labelNameCell: true,
           newSubmitRequirements: this.isSubmitRequirementsUiEnabled,
-        })}"
+        })}
         id="labelName"
         aria-hidden="true"
         >${this.label?.name ?? ''}</span
@@ -204,7 +204,7 @@
       .fill('')
       .map(
         () => html`
-          <span class="placeholder" data-label="${this.label?.name ?? ''}">
+          <span class="placeholder" data-label=${this.label?.name ?? ''}>
           </span>
         `
       );
@@ -215,7 +215,7 @@
       <iron-selector
         id="labelSelector"
         .attrForSelected=${'data-value'}
-        selected="${ifDefined(this._computeLabelValue())}"
+        selected=${ifDefined(this._computeLabelValue())}
         @selected-item-changed=${this.setSelectedValueText}
         role="radiogroup"
         aria-labelledby="labelName"
@@ -231,22 +231,22 @@
       (value, index) => html`
         <gr-button
           role="radio"
-          title="${ifDefined(this.computeLabelValueTitle(value))}"
-          data-vote="${this._computeVoteAttribute(
+          title=${ifDefined(this.computeLabelValueTitle(value))}
+          data-vote=${this._computeVoteAttribute(
             Number(value),
             index,
             items.length
-          )}"
-          data-name="${ifDefined(this.label?.name)}"
-          data-value="${value}"
-          aria-label="${value}"
+          )}
+          data-name=${ifDefined(this.label?.name)}
+          data-value=${value}
+          aria-label=${value}
           voteChip
           flatten
         >
           <gr-tooltip-content
             has-tooltip
             light-tooltip
-            title="${ifDefined(this.computeLabelValueTitle(value))}"
+            title=${ifDefined(this.computeLabelValueTitle(value))}
           >
             ${value}
           </gr-tooltip-content>
@@ -258,10 +258,10 @@
   private renderSelectedValue() {
     return html`
       <div
-        class="${classMap({
+        class=${classMap({
           selectedValueCell: true,
           newSubmitRequirements: this.isSubmitRequirementsUiEnabled,
-        })}"
+        })}
       >
         <span id="selectedValueLabel">${this.selectedValueText}</span>
       </div>
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index 3ff3e71..dd2a83e 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -21,7 +21,6 @@
 import {
   ChangeInfo,
   AccountInfo,
-  DetailedLabelInfo,
   LabelNameToValueMap,
 } from '../../../types/common';
 import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
@@ -32,6 +31,7 @@
   computeLabels,
   Label,
   computeOrderedLabelValues,
+  getDefaultValue,
 } from '../../../utils/label-util';
 import {ChangeStatus} from '../../../constants/constants';
 import {fontStyles} from '../../../styles/gr-font-styles';
@@ -160,13 +160,13 @@
         )
         .map(
           label => html`<gr-label-score-row
-            .label="${label}"
-            .name="${label.name}"
-            .labels="${this.change?.labels}"
-            .permittedLabels="${this.permittedLabels}"
-            .orderedLabelValues="${computeOrderedLabelValues(
+            .label=${label}
+            .name=${label.name}
+            .labels=${this.change?.labels}
+            .permittedLabels=${this.permittedLabels}
+            .orderedLabelValues=${computeOrderedLabelValues(
               this.permittedLabels
-            )}"
+            )}
           ></gr-label-score-row>`
         )}
     </div>`;
@@ -205,20 +205,13 @@
 
       if (selectedVal === undefined) continue;
 
-      const defValNum = this.getDefaultValue(label);
+      const defValNum = getDefaultValue(this.change?.labels, label);
       if (includeDefaults || selectedVal !== defValNum) {
         labels[label] = selectedVal;
       }
     }
     return labels;
   }
-
-  private getDefaultValue(labelName?: string) {
-    const labels = this.change?.labels;
-    if (!labelName || !labels?.[labelName]) return undefined;
-    const labelInfo = labels[labelName] as DetailedLabelInfo;
-    return labelInfo.default_value;
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
index 50492d2..4bf9d10 100644
--- a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
@@ -119,11 +119,11 @@
     ) {
       const labels = this.change?.labels ?? {};
       return html`<gr-trigger-vote
-        .label="${score.label}"
+        .label=${score.label}
         .displayValue=${score.value}
-        .labelInfo="${labels[score.label]}"
-        .change="${this.change}"
-        .mutable="${false}"
+        .labelInfo=${labels[score.label]}
+        .change=${this.change}
+        .mutable=${false}
         disable-hovercards
       >
       </gr-trigger-vote>`;
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
index 744db3b..cbb29de 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
@@ -114,9 +114,9 @@
     return html`
       <div class="changeContainer">
         <a
-          href="${ifDefined(this.href)}"
-          aria-label="${ifDefined(this.label)}"
-          class="${linkClass}"
+          href=${ifDefined(this.href)}
+          aria-label=${ifDefined(this.label)}
+          class=${linkClass}
           ><slot></slot
         ></a>
         ${this.showSubmittableCheck
@@ -130,7 +130,7 @@
             >`
           : ''}
         ${this.showChangeStatus && !isChangeInfo(change)
-          ? html`<span class="${this._computeChangeStatusClass(change)}">
+          ? html`<span class=${this._computeChangeStatusClass(change)}>
               (${this._computeChangeStatus(change)})
             </span>`
           : ''}
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index c882764..05fe62c 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -202,31 +202,31 @@
     return html`<section id="relatedChanges">
       <gr-related-collapse
         title="Relation chain"
-        class="${classMap({first: isFirst})}"
+        class=${classMap({first: isFirst})}
         .length=${this.relatedChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.RELATED_CHANGES)}
       >
         ${this.relatedChanges.map(
           (change, index) =>
             html`<div
-              class="${classMap({
+              class=${classMap({
                 ['relatedChangeLine']: true,
                 ['show-when-collapsed']:
                   relatedChangesMarkersPredicate(index).showWhenCollapsed,
-              })}"
+              })}
             >
               ${this.renderMarkers(
                 relatedChangesMarkersPredicate(index)
               )}<gr-related-change
-                .change="${change}"
-                .connectedRevisions="${connectedRevisions}"
-                .href="${change?._change_number
+                .change=${change}
+                .connectedRevisions=${connectedRevisions}
+                .href=${change?._change_number
                   ? GerritNav.getUrlForChangeById(
                       change._change_number,
                       change.project,
                       change._revision_number as PatchSetNum
                     )
-                  : ''}"
+                  : ''}
                 .showChangeStatus=${true}
                 >${change.commit.subject}</gr-related-change
               >
@@ -259,28 +259,28 @@
     return html`<section id="submittedTogether">
       <gr-related-collapse
         title="Submitted together"
-        class="${classMap({first: isFirst})}"
+        class=${classMap({first: isFirst})}
         .length=${submittedTogetherChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.SUBMITTED_TOGETHER)}
       >
         ${submittedTogetherChanges.map(
           (change, index) =>
             html`<div
-              class="${classMap({
+              class=${classMap({
                 ['relatedChangeLine']: true,
                 ['show-when-collapsed']:
                   submittedTogetherMarkersPredicate(index).showWhenCollapsed,
-              })}"
+              })}
             >
               ${this.renderMarkers(
                 submittedTogetherMarkersPredicate(index)
               )}<gr-related-change
-                .label="${this.renderChangeTitle(change)}"
-                .change="${change}"
-                .href="${GerritNav.getUrlForChangeById(
+                .label=${this.renderChangeTitle(change)}
+                .change=${change}
+                .href=${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
-                )}"
+                )}
                 .showSubmittableCheck=${true}
                 >${this.renderChangeLine(change)}</gr-related-change
               >
@@ -309,28 +309,28 @@
     return html`<section id="sameTopic">
       <gr-related-collapse
         title="Same topic"
-        class="${classMap({first: isFirst})}"
+        class=${classMap({first: isFirst})}
         .length=${this.sameTopicChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.SAME_TOPIC)}
       >
         ${this.sameTopicChanges.map(
           (change, index) =>
             html`<div
-              class="${classMap({
+              class=${classMap({
                 ['relatedChangeLine']: true,
                 ['show-when-collapsed']:
                   sameTopicMarkersPredicate(index).showWhenCollapsed,
-              })}"
+              })}
             >
               ${this.renderMarkers(
                 sameTopicMarkersPredicate(index)
               )}<gr-related-change
-                .change="${change}"
-                .label="${this.renderChangeTitle(change)}"
-                .href="${GerritNav.getUrlForChangeById(
+                .change=${change}
+                .label=${this.renderChangeTitle(change)}
+                .href=${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
-                )}"
+                )}
                 >${this.renderChangeLine(change)}</gr-related-change
               >
             </div>`
@@ -354,27 +354,27 @@
     return html`<section id="mergeConflicts">
       <gr-related-collapse
         title="Merge conflicts"
-        class="${classMap({first: isFirst})}"
+        class=${classMap({first: isFirst})}
         .length=${this.conflictingChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.MERGE_CONFLICTS)}
       >
         ${this.conflictingChanges.map(
           (change, index) =>
             html`<div
-              class="${classMap({
+              class=${classMap({
                 ['relatedChangeLine']: true,
                 ['show-when-collapsed']:
                   mergeConflictsMarkersPredicate(index).showWhenCollapsed,
-              })}"
+              })}
             >
               ${this.renderMarkers(
                 mergeConflictsMarkersPredicate(index)
               )}<gr-related-change
-                .change="${change}"
-                .href="${GerritNav.getUrlForChangeById(
+                .change=${change}
+                .href=${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
-                )}"
+                )}
                 >${change.subject}</gr-related-change
               >
             </div>`
@@ -398,27 +398,27 @@
     return html`<section id="cherryPicks">
       <gr-related-collapse
         title="Cherry picks"
-        class="${classMap({first: isFirst})}"
+        class=${classMap({first: isFirst})}
         .length=${this.cherryPickChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.CHERRY_PICKS)}
       >
         ${this.cherryPickChanges.map(
           (change, index) =>
             html`<div
-              class="${classMap({
+              class=${classMap({
                 ['relatedChangeLine']: true,
                 ['show-when-collapsed']:
                   cherryPicksMarkersPredicate(index).showWhenCollapsed,
-              })}"
+              })}
             >
               ${this.renderMarkers(
                 cherryPicksMarkersPredicate(index)
               )}<gr-related-change
-                .change="${change}"
-                .href="${GerritNav.getUrlForChangeById(
+                .change=${change}
+                .href=${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
-                )}"
+                )}
                 >${change.branch}: ${change.subject}</gr-related-change
               >
             </div>`
@@ -433,7 +433,7 @@
 
   private renderChangeLine(change: ChangeInfo) {
     const truncatedRepo = truncatePath(change.project, 2);
-    return html`<span class="truncatedRepo" .title="${change.project}"
+    return html`<span class="truncatedRepo" .title=${change.project}
         >${truncatedRepo}</span
       >: ${change.branch}: ${change.subject}`;
   }
@@ -775,7 +775,7 @@
         buttonText = `Show all (${this.length})`;
         buttonIcon = 'expand-more';
       }
-      button = html`<gr-button link="" @click="${this.toggle}"
+      button = html`<gr-button link="" @click=${this.toggle}
         >${buttonText}<iron-icon icon="gr-icons:${buttonIcon}"></iron-icon
       ></gr-button>`;
     }
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 87b699e..d25f3c1 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -54,7 +54,6 @@
   AccountInfoInput,
   GrAccountList,
   GroupInfoInput,
-  GroupObjectInput,
   RawAccountInput,
 } from '../../shared/gr-account-list/gr-account-list';
 import {
@@ -78,6 +77,7 @@
   ReviewInput,
   ReviewResult,
   ServerInfo,
+  SuggestedReviewerGroupInfo,
   Suggestion,
 } from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
@@ -275,7 +275,7 @@
   _attentionCcsCount = 0;
 
   @property({type: Object, observer: '_reviewerPendingConfirmationUpdated'})
-  _ccPendingConfirmation: GroupObjectInput | null = null;
+  _ccPendingConfirmation: SuggestedReviewerGroupInfo | null = null;
 
   @property({
     type: String,
@@ -290,7 +290,7 @@
   _uploader?: AccountInfo;
 
   @property({type: Object})
-  _pendingConfirmationDetails: GroupObjectInput | null = null;
+  _pendingConfirmationDetails: SuggestedReviewerGroupInfo | null = null;
 
   @property({type: Boolean})
   _includeComments = true;
@@ -299,7 +299,7 @@
   _reviewers: (AccountInfo | GroupInfo)[] = [];
 
   @property({type: Object, observer: '_reviewerPendingConfirmationUpdated'})
-  _reviewerPendingConfirmation: GroupObjectInput | null = null;
+  _reviewerPendingConfirmation: SuggestedReviewerGroupInfo | null = null;
 
   @property({type: Boolean, observer: '_handleHeightChanged'})
   _previewFormatting = false;
@@ -409,6 +409,7 @@
       // elements/shared/gr-account-list/gr-account-list.js#addAccountItem
       this.$.reviewers.addAccountItem({
         account: (e as CustomEvent).detail.reviewer,
+        count: 1,
       });
     });
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index dc6820e..79fbaa0 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -1138,11 +1138,13 @@
       element._ccPendingConfirmation = {
         group,
         confirm: false,
+        count: 1,
       };
     } else {
       element._reviewerPendingConfirmation = {
         group,
         confirm: false,
+        count: 1,
       };
     }
     flush();
@@ -1203,11 +1205,13 @@
       element._ccPendingConfirmation = {
         group,
         confirm: false,
+        count: 1,
       };
     } else {
       element._reviewerPendingConfirmation = {
         group,
         confirm: false,
+        count: 1,
       };
     }
 
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 33aea05..6740977 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -17,138 +17,177 @@
 import '../../shared/gr-account-chip/gr-account-chip';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-vote-chip/gr-vote-chip';
-import '../../../styles/shared-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-reviewer-list_html';
-import {customElement, property, computed, observe} from '@polymer/decorators';
+import {LitElement, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+
 import {
   ChangeInfo,
   AccountInfo,
   ApprovalInfo,
-  Reviewers,
-  AccountId,
-  EmailAddress,
   AccountDetailInfo,
   isDetailedLabelInfo,
   LabelInfo,
 } from '../../../types/common';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 import {hasOwnProperty} from '../../../utils/common-util';
-import {isRemovableReviewer} from '../../../utils/change-util';
-import {ReviewerState} from '../../../constants/constants';
 import {getAppContext} from '../../../services/app-context';
-import {fireAlert} from '../../../utils/event-util';
 import {
   getApprovalInfo,
   getCodeReviewLabel,
   showNewSubmitRequirements,
 } from '../../../utils/label-util';
 import {sortReviewers} from '../../../utils/attention-set-util';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {LabelNameToValuesMap} from '../../../api/rest-api';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {css} from 'lit';
+import {nothing} from 'lit';
 
 @customElement('gr-reviewer-list')
-export class GrReviewerList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrReviewerList extends LitElement {
   /**
    * Fired when the "Add reviewer..." button is tapped.
    *
    * @event show-reply-dialog
    */
 
-  @property({type: Object})
-  change?: ChangeInfo;
+  @property({type: Object}) change?: ChangeInfo;
 
-  @property({type: Object})
-  account?: AccountDetailInfo;
+  @property({type: Object}) account?: AccountDetailInfo;
 
-  @property({type: Boolean, reflectToAttribute: true})
-  disabled = false;
+  @property({type: Boolean, reflect: true}) disabled = false;
 
-  @property({type: Boolean})
-  mutable = false;
+  @property({type: Boolean}) mutable = false;
 
-  @property({type: Boolean})
-  reviewersOnly = false;
+  @property({type: Boolean, attribute: 'reviewers-only'}) reviewersOnly = false;
 
-  @property({type: Boolean})
-  ccsOnly = false;
+  @property({type: Boolean, attribute: 'ccs-only'}) ccsOnly = false;
 
-  @property({type: Array})
-  _displayedReviewers: AccountInfo[] = [];
+  @state() displayedReviewers: AccountInfo[] = [];
 
-  @property({type: Array})
-  _reviewers: AccountInfo[] = [];
+  @state() reviewers: AccountInfo[] = [];
 
-  @property({type: Boolean})
-  _showInput = false;
+  @state() hiddenReviewerCount?: number;
 
-  @property({type: Object})
-  _xhrPromise?: Promise<Response | undefined>;
-
-  private readonly restApiService = getAppContext().restApiService;
+  @state() showAllReviewers = false;
 
   private readonly flagsService = getAppContext().flagsService;
 
-  @computed('ccsOnly')
-  get _addLabel() {
-    return this.ccsOnly ? 'Add CC' : 'Add reviewer';
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        :host([disabled]) {
+          opacity: 0.8;
+          pointer-events: none;
+        }
+        .container {
+          display: block;
+          /* line-height-normal for the chips, 2px for the chip border, spacing-s
+            for the gap between lines, negative bottom margin for eliminating the
+            gap after the last line */
+          line-height: calc(var(--line-height-normal) + 2px + var(--spacing-s));
+          margin-bottom: calc(0px - var(--spacing-s));
+        }
+        .addReviewer iron-icon {
+          color: inherit;
+          --iron-icon-height: 18px;
+          --iron-icon-width: 18px;
+        }
+        .controlsContainer {
+          display: inline-block;
+        }
+        gr-button.addReviewer {
+          --gr-button-padding: 1px 0px;
+          vertical-align: top;
+          top: 1px;
+        }
+        gr-button {
+          line-height: var(--line-height-normal);
+          --gr-button-padding: 0px;
+        }
+        gr-account-chip {
+          line-height: var(--line-height-normal);
+          vertical-align: top;
+          display: inline-block;
+        }
+        gr-vote-chip {
+          --gr-vote-chip-width: 14px;
+          --gr-vote-chip-height: 14px;
+        }
+      `,
+    ];
   }
 
-  @computed('_reviewers', '_displayedReviewers')
-  get _hiddenReviewerCount() {
-    // Polymer 2: check for undefined
-    if (
-      this._reviewers === undefined ||
-      this._displayedReviewers === undefined
-    ) {
-      return undefined;
-    }
-    return this._reviewers.length - this._displayedReviewers.length;
+  override render() {
+    this.displayedReviewers = this.computeDisplayedReviewers() ?? [];
+    this.hiddenReviewerCount =
+      this.reviewers.length - this.displayedReviewers.length;
+    return html`
+      <div class="container">
+        <div>
+          ${this.displayedReviewers.map(reviewer =>
+            this.renderAccountChip(reviewer)
+          )}
+          <div class="controlsContainer" ?hidden=${!this.mutable}>
+            <gr-button
+              link
+              id="addReviewer"
+              class="addReviewer"
+              @click=${this.handleAddTap}
+              title=${this.ccsOnly ? 'Add CC' : 'Add reviewer'}
+              ><iron-icon icon="gr-icons:edit"></iron-icon
+            ></gr-button>
+          </div>
+        </div>
+        <gr-button
+          class="hiddenReviewers"
+          link=""
+          ?hidden=${!this.hiddenReviewerCount}
+          @click=${() => {
+            this.showAllReviewers = true;
+          }}
+          >and ${this.hiddenReviewerCount} more</gr-button
+        >
+      </div>
+    `;
   }
 
-  /**
-   * Converts change.permitted_labels to an array of hashes of label keys to
-   * numeric scores.
-   * Example:
-   * [{
-   *   'Code-Review': ['-1', ' 0', '+1']
-   * }]
-   * will be converted to
-   * [{
-   *   label: 'Code-Review',
-   *   scores: [-1, 0, 1]
-   * }]
-   */
-  _permittedLabelsToNumericScores(labels: LabelNameToValuesMap | undefined) {
-    if (!labels) return [];
-    return Object.keys(labels).map(label => {
-      return {
-        label,
-        scores: labels[label].map(v => Number(v)),
-      };
-    });
+  private renderAccountChip(reviewer: AccountInfo) {
+    const change = this.change;
+    if (!change) return nothing;
+    return html`
+      <gr-account-chip
+        class="reviewer"
+        .account=${reviewer}
+        .change=${change}
+        highlightAttention
+        .voteableText=${this.computeVoteableText(reviewer)}
+        .vote=${this.computeVote(reviewer)}
+        .label=${this.computeCodeReviewLabel()}
+      >
+        ${showNewSubmitRequirements(this.flagsService, this.change)
+          ? html`<gr-vote-chip
+              slot="vote-chip"
+              .vote=${this.computeVote(reviewer)}
+              .label=${this.computeCodeReviewLabel()}
+              circle-shape
+            ></gr-vote-chip>`
+          : nothing}
+      </gr-account-chip>
+    `;
   }
 
   /**
    * Returns max permitted score for reviewer.
    */
-  _getReviewerPermittedScore(
-    reviewer: AccountInfo,
-    change: ChangeInfo,
-    label: string
-  ) {
+  private getReviewerPermittedScore(reviewer: AccountInfo, label: string) {
     // Note (issue 7874): sometimes the "all" list is not included in change
     // detail responses, even when DETAILED_LABELS is included in options.
-    if (!change.labels) {
+    if (!this.change?.labels) {
       return NaN;
     }
-    const detailedLabel = change.labels[label];
+    const detailedLabel = this.change.labels[label];
     if (!isDetailedLabelInfo(detailedLabel) || !detailedLabel.all) {
       return NaN;
     }
@@ -166,13 +205,15 @@
     return NaN;
   }
 
-  _computeVoteableText(reviewer: AccountInfo, change: ChangeInfo) {
+  // private but used in tests
+  computeVoteableText(reviewer: AccountInfo) {
+    const change = this.change;
     if (!change || !change.labels) {
       return '';
     }
     const maxScores = [];
     for (const label of Object.keys(change.labels)) {
-      const maxScore = this._getReviewerPermittedScore(reviewer, change, label);
+      const maxScore = this.getReviewerPermittedScore(reviewer, label);
       if (isNaN(maxScore) || maxScore < 0) {
         continue;
       }
@@ -182,35 +223,23 @@
     return maxScores.join(', ');
   }
 
-  _computeVote(
-    reviewer: AccountInfo,
-    change?: ChangeInfo
-  ): ApprovalInfo | undefined {
-    const codeReviewLabel = this._computeCodeReviewLabel(change);
+  private computeVote(reviewer: AccountInfo): ApprovalInfo | undefined {
+    const codeReviewLabel = this.computeCodeReviewLabel();
     if (!codeReviewLabel || !isDetailedLabelInfo(codeReviewLabel)) return;
     return getApprovalInfo(codeReviewLabel, reviewer);
   }
 
-  _computeCodeReviewLabel(change?: ChangeInfo): LabelInfo | undefined {
-    if (!change || !change.labels) return;
-    return getCodeReviewLabel(change.labels);
+  private computeCodeReviewLabel(): LabelInfo | undefined {
+    if (!this.change?.labels) return;
+    return getCodeReviewLabel(this.change.labels);
   }
 
-  @observe('change.reviewers.*', 'change.owner')
-  _reviewersChanged(
-    changeRecord: PolymerDeepPropertyChange<Reviewers, Reviewers>,
-    owner: AccountInfo
-  ) {
-    // Polymer 2: check for undefined
-    if (
-      changeRecord === undefined ||
-      owner === undefined ||
-      this.change === undefined
-    ) {
+  private computeDisplayedReviewers() {
+    if (this.change?.owner === undefined) {
       return;
     }
     let result: AccountInfo[] = [];
-    const reviewers = changeRecord.base;
+    const reviewers = this.change.reviewers;
     for (const key of Object.keys(reviewers)) {
       if (this.reviewersOnly && key !== 'REVIEWER') {
         continue;
@@ -222,68 +251,21 @@
         result = result.concat(reviewers[key]!);
       }
     }
-    this._reviewers = result
-      .filter(reviewer => reviewer._account_id !== owner._account_id)
+    this.reviewers = result
+      .filter(
+        reviewer => reviewer._account_id !== this.change?.owner._account_id
+      )
       .sort((r1, r2) => sortReviewers(r1, r2, this.change, this.account));
 
-    if (this._reviewers.length > 8) {
-      this._displayedReviewers = this._reviewers.slice(0, 6);
+    if (this.reviewers.length > 8 && !this.showAllReviewers) {
+      return this.reviewers.slice(0, 6);
     } else {
-      this._displayedReviewers = this._reviewers;
+      return this.reviewers;
     }
   }
 
-  _computeCanRemoveReviewer(reviewer: AccountInfo, mutable: boolean) {
-    if (this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)) {
-      return false;
-    }
-    return mutable && isRemovableReviewer(this.change, reviewer);
-  }
-
-  _handleRemove(e: Event) {
-    e.preventDefault();
-    const target = (dom(e) as EventApi).rootTarget as GrAccountChip;
-    if (!target.account || !this.change?.reviewers) return;
-    const accountID = target.account._account_id || target.account.email;
-    if (!accountID) return;
-    const reviewers = this.change.reviewers;
-    let removedAccount: AccountInfo | undefined;
-    let removedType: ReviewerState | undefined;
-    for (const type of [ReviewerState.REVIEWER, ReviewerState.CC]) {
-      const reviewerStateByType = reviewers[type] || [];
-      reviewers[type] = reviewerStateByType;
-      for (let i = 0; i < reviewerStateByType.length; i++) {
-        if (
-          reviewerStateByType[i]._account_id === accountID ||
-          reviewerStateByType[i].email === accountID
-        ) {
-          removedAccount = reviewerStateByType[i];
-          removedType = type;
-          this.splice(`change.reviewers.${type}`, i, 1);
-          break;
-        }
-      }
-    }
-    const curChange = this.change;
-    this.disabled = true;
-    this._xhrPromise = this._removeReviewer(accountID)
-      .then(response => {
-        this.disabled = false;
-        if (!this.change?.reviewers || this.change !== curChange) return;
-        if (!response?.ok) {
-          this.push(`change.reviewers.${removedType}`, removedAccount);
-          fireAlert(this, `Cannot remove a ${removedType}`);
-          return response;
-        }
-        return;
-      })
-      .catch((err: Error) => {
-        this.disabled = false;
-        throw err;
-      });
-  }
-
-  _handleAddTap(e: Event) {
+  // private but used in tests
+  handleAddTap(e: Event) {
     e.preventDefault();
     const value = {
       reviewersOnly: false,
@@ -303,19 +285,6 @@
       })
     );
   }
-
-  _handleViewAll() {
-    this._displayedReviewers = this._reviewers;
-  }
-
-  _removeReviewer(id: AccountId | EmailAddress): Promise<Response | undefined> {
-    if (!this.change) return Promise.resolve(undefined);
-    return this.restApiService.removeChangeReviewer(this.change._number, id);
-  }
-
-  showNewSubmitRequirements(change?: ChangeInfo) {
-    return showNewSubmitRequirements(this.flagsService, change);
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
deleted file mode 100644
index b697cd5..0000000
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_html.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) {
-      opacity: 0.8;
-      pointer-events: none;
-    }
-    .container {
-      display: block;
-      /* line-height-normal for the chips, 2px for the chip border, spacing-s
-         for the gap between lines, negative bottom margin for eliminating the
-         gap after the last line */
-      line-height: calc(var(--line-height-normal) + 2px + var(--spacing-s));
-      margin-bottom: calc(0px - var(--spacing-s));
-    }
-    .addReviewer iron-icon {
-      color: inherit;
-      --iron-icon-height: 18px;
-      --iron-icon-width: 18px;
-    }
-    .controlsContainer {
-      display: inline-block;
-    }
-    gr-button.addReviewer {
-      --gr-button-padding: 1px 0px;
-      vertical-align: top;
-      top: 1px;
-    }
-    gr-button {
-      line-height: var(--line-height-normal);
-      --gr-button-padding: 0px;
-    }
-    gr-account-chip {
-      line-height: var(--line-height-normal);
-      vertical-align: top;
-      display: inline-block;
-    }
-    gr-vote-chip {
-      --gr-vote-chip-width: 14px;
-      --gr-vote-chip-height: 14px;
-    }
-  </style>
-  <div class="container">
-    <div>
-      <template is="dom-repeat" items="[[_displayedReviewers]]" as="reviewer">
-        <gr-account-chip
-          class="reviewer"
-          account="[[reviewer]]"
-          change="[[change]]"
-          on-remove="_handleRemove"
-          highlightAttention
-          voteable-text="[[_computeVoteableText(reviewer, change)]]"
-          removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]"
-          vote="[[_computeVote(reviewer, change)]]"
-          label="[[_computeCodeReviewLabel(change)]]"
-        >
-          <template is="dom-if" if="[[showNewSubmitRequirements(change)]]">
-            <gr-vote-chip
-              slot="vote-chip"
-              vote="[[_computeVote(reviewer, change)]]"
-              label="[[_computeCodeReviewLabel(change)]]"
-              circle-shape
-            ></gr-vote-chip>
-          </template>
-        </gr-account-chip>
-      </template>
-      <div class="controlsContainer" hidden$="[[!mutable]]">
-        <gr-button
-          link=""
-          id="addReviewer"
-          class="addReviewer"
-          on-click="_handleAddTap"
-          title="[[_addLabel]]"
-          ><iron-icon icon="gr-icons:edit"></iron-icon
-        ></gr-button>
-      </div>
-    </div>
-    <gr-button
-      class="hiddenReviewers"
-      link=""
-      hidden$="[[!_hiddenReviewerCount]]"
-      on-click="_handleViewAll"
-      >and [[_hiddenReviewerCount]] more</gr-button
-    >
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
index f942443..c6b1d5f 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
@@ -17,42 +17,38 @@
 
 import '../../../test/common-test-setup-karma';
 import './gr-reviewer-list';
-import {
-  mockPromise,
-  queryAndAssert,
-  stubRestApi,
-} from '../../../test/test-utils';
+import {mockPromise, queryAndAssert} from '../../../test/test-utils';
 import {GrReviewerList} from './gr-reviewer-list';
 import {
   createAccountDetailWithId,
   createChange,
   createDetailedLabelInfo,
 } from '../../../test/test-data-generators';
-import {tap} from '@polymer/iron-test-helpers/mock-interactions';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {AccountId, EmailAddress} from '../../../types/common';
-import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
+import './gr-reviewer-list';
 
 const basicFixture = fixtureFromElement('gr-reviewer-list');
 
 suite('gr-reviewer-list tests', () => {
   let element: GrReviewerList;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
-
-    stubRestApi('removeChangeReviewer').returns(
-      Promise.resolve({...new Response(), ok: true})
-    );
+    await element.updateComplete;
   });
 
-  test('controls hidden on immutable element', () => {
-    flush();
+  test('controls hidden on immutable element', async () => {
     element.mutable = false;
+    await element.updateComplete;
+
     assert.isTrue(
       queryAndAssert(element, '.controlsContainer').hasAttribute('hidden')
     );
+
     element.mutable = true;
+    await element.updateComplete;
+
     assert.isFalse(
       queryAndAssert(element, '.controlsContainer').hasAttribute('hidden')
     );
@@ -63,171 +59,11 @@
     element.addEventListener('show-reply-dialog', () => {
       dialogShown.resolve();
     });
-    await flush();
-    tap(queryAndAssert(element, '.addReviewer'));
+    queryAndAssert<GrButton>(element, '.addReviewer').click();
     await dialogShown;
   });
 
-  test('only show remove for removable reviewers', async () => {
-    element.mutable = true;
-    element.change = {
-      ...createChange(),
-      owner: {
-        ...createAccountDetailWithId(1),
-      },
-      reviewers: {
-        REVIEWER: [
-          {
-            ...createAccountDetailWithId(2),
-            name: 'Bojack Horseman',
-            email: 'SecretariatRulez96@hotmail.com' as EmailAddress,
-          },
-          {
-            _account_id: 3 as AccountId,
-            name: 'Pinky Penguin',
-          },
-        ],
-        CC: [
-          {
-            ...createAccountDetailWithId(4),
-            name: 'Diane Nguyen',
-            email: 'macarthurfellow2B@juno.com' as EmailAddress,
-          },
-          {
-            email: 'test@e.mail' as EmailAddress,
-          },
-        ],
-      },
-      removable_reviewers: [
-        {
-          _account_id: 3 as AccountId,
-          name: 'Pinky Penguin',
-        },
-        {
-          ...createAccountDetailWithId(4),
-          name: 'Diane Nguyen',
-          email: 'macarthurfellow2B@juno.com' as EmailAddress,
-        },
-        {
-          email: 'test@e.mail' as EmailAddress,
-        },
-      ],
-    };
-    await flush();
-    const chips = element.root!.querySelectorAll('gr-account-chip');
-    assert.equal(chips.length, 4);
-
-    for (const el of Array.from(chips)) {
-      const accountID = el.account!._account_id || el.account!.email;
-      assert.ok(accountID);
-
-      const buttonEl = queryAndAssert(el, 'gr-button');
-      if (accountID === 2) {
-        assert.isTrue(buttonEl.hasAttribute('hidden'));
-      } else {
-        assert.isFalse(buttonEl.hasAttribute('hidden'));
-      }
-    }
-  });
-
-  suite('_handleRemove', () => {
-    let removeReviewerStub: sinon.SinonStub;
-    let reviewersChangedSpy: sinon.SinonSpy;
-
-    const reviewerWithId = {
-      ...createAccountDetailWithId(2),
-      name: 'Some name',
-    };
-
-    const reviewerWithIdAndEmail = {
-      ...createAccountDetailWithId(4),
-      name: 'Some other name',
-      email: 'example@' as EmailAddress,
-    };
-
-    const reviewerWithEmailOnly = {
-      email: 'example2@example' as EmailAddress,
-    };
-
-    let chips: GrAccountChip[];
-
-    setup(() => {
-      removeReviewerStub = sinon
-        .stub(element, '_removeReviewer')
-        .returns(Promise.resolve(new Response()));
-      element.mutable = true;
-
-      const allReviewers = [
-        reviewerWithId,
-        reviewerWithIdAndEmail,
-        reviewerWithEmailOnly,
-      ];
-
-      element.change = {
-        ...createChange(),
-        owner: {
-          ...createAccountDetailWithId(1),
-        },
-        reviewers: {
-          REVIEWER: allReviewers,
-        },
-        removable_reviewers: allReviewers,
-      };
-      flush();
-      chips = Array.from(element.root!.querySelectorAll('gr-account-chip'));
-      assert.equal(chips.length, allReviewers.length);
-      reviewersChangedSpy = sinon.spy(element, '_reviewersChanged');
-    });
-
-    test('_handleRemove for account with accountId only', async () => {
-      const accountChip = chips.find(
-        chip => chip.account!._account_id === reviewerWithId._account_id
-      );
-      accountChip!._handleRemoveTap(new MouseEvent('click'));
-      await flush();
-      assert.isTrue(removeReviewerStub.calledOnce);
-      assert.isTrue(removeReviewerStub.calledWith(reviewerWithId._account_id));
-      assert.isTrue(reviewersChangedSpy.called);
-      expect(element.change!.reviewers.REVIEWER).to.have.deep.members([
-        reviewerWithIdAndEmail,
-        reviewerWithEmailOnly,
-      ]);
-    });
-
-    test('_handleRemove for account with accountId and email', async () => {
-      const accountChip = chips.find(
-        chip => chip.account!._account_id === reviewerWithIdAndEmail._account_id
-      );
-      accountChip!._handleRemoveTap(new MouseEvent('click'));
-      await flush();
-      assert.isTrue(removeReviewerStub.calledOnce);
-      assert.isTrue(
-        removeReviewerStub.calledWith(reviewerWithIdAndEmail._account_id)
-      );
-      assert.isTrue(reviewersChangedSpy.called);
-      expect(element.change!.reviewers.REVIEWER).to.have.deep.members([
-        reviewerWithId,
-        reviewerWithEmailOnly,
-      ]);
-    });
-
-    test('_handleRemove for account with email only', async () => {
-      const accountChip = chips.find(
-        chip => chip.account!.email === reviewerWithEmailOnly.email
-      );
-      accountChip!._handleRemoveTap(new MouseEvent('click'));
-      await flush();
-      assert.isTrue(removeReviewerStub.calledOnce);
-      assert.isTrue(removeReviewerStub.calledWith(reviewerWithEmailOnly.email));
-      assert.isTrue(reviewersChangedSpy.called);
-      expect(element.change!.reviewers.REVIEWER).to.have.deep.members([
-        reviewerWithId,
-        reviewerWithIdAndEmail,
-      ]);
-    });
-  });
-
-  test('tracking reviewers and ccs', () => {
+  test('tracking reviewers and ccs', async () => {
     let counter = 0;
     function makeAccount() {
       return {_account_id: counter++ as AccountId};
@@ -249,7 +85,8 @@
       owner,
       reviewers,
     };
-    assert.deepEqual(element._reviewers, [reviewer, cc]);
+    await element.updateComplete;
+    assert.deepEqual(element.reviewers, [reviewer, cc]);
 
     element.reviewersOnly = true;
     element.change = {
@@ -257,7 +94,9 @@
       owner,
       reviewers,
     };
-    assert.deepEqual(element._reviewers, [reviewer]);
+    await element.updateComplete;
+
+    assert.deepEqual(element.reviewers, [reviewer]);
 
     element.ccsOnly = true;
     element.reviewersOnly = false;
@@ -266,16 +105,18 @@
       owner,
       reviewers,
     };
-    assert.deepEqual(element._reviewers, [cc]);
+    await element.updateComplete;
+
+    assert.deepEqual(element.reviewers, [cc]);
   });
 
-  test('_handleAddTap passes mode with event', () => {
+  test('handleAddTap passes mode with event', () => {
     const fireStub = sinon.stub(element, 'dispatchEvent');
     const e = {...new Event(''), preventDefault() {}};
 
     element.ccsOnly = false;
     element.reviewersOnly = false;
-    element._handleAddTap(e);
+    element.handleAddTap(e);
     assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
     assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
       value: {
@@ -285,7 +126,7 @@
     });
 
     element.reviewersOnly = true;
-    element._handleAddTap(e);
+    element.handleAddTap(e);
     assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
     assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
       value: {reviewersOnly: true, ccsOnly: false},
@@ -293,14 +134,14 @@
 
     element.ccsOnly = true;
     element.reviewersOnly = false;
-    element._handleAddTap(e);
+    element.handleAddTap(e);
     assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
     assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
       value: {ccsOnly: true, reviewersOnly: false},
     });
   });
 
-  test('dont show all reviewers button with 4 reviewers', () => {
+  test('dont show all reviewers button with 4 reviewers', async () => {
     const reviewers = [];
     for (let i = 0; i < 4; i++) {
       reviewers.push({
@@ -320,13 +161,15 @@
         CC: reviewers,
       },
     };
-    assert.equal(element._hiddenReviewerCount, 0);
-    assert.equal(element._displayedReviewers.length, 4);
-    assert.equal(element._reviewers.length, 4);
+    await element.updateComplete;
+
+    assert.equal(element.hiddenReviewerCount, 0);
+    assert.equal(element.displayedReviewers.length, 4);
+    assert.equal(element.reviewers.length, 4);
     assert.isTrue(queryAndAssert<GrButton>(element, '.hiddenReviewers').hidden);
   });
 
-  test('account owner comes first in list of reviewers', () => {
+  test('account owner comes first in list of reviewers', async () => {
     const reviewers = [];
     for (let i = 0; i < 4; i++) {
       reviewers.push({
@@ -348,11 +191,12 @@
         REVIEWER: reviewers,
       },
     };
-    flush();
-    assert.equal(element._displayedReviewers[0]._account_id, 1 as AccountId);
+    await element.updateComplete;
+
+    assert.equal(element.displayedReviewers[0]._account_id, 1 as AccountId);
   });
 
-  test('show all reviewers button with 9 reviewers', () => {
+  test('show all reviewers button with 9 reviewers', async () => {
     const reviewers = [];
     for (let i = 0; i < 9; i++) {
       reviewers.push({
@@ -372,15 +216,17 @@
         CC: reviewers,
       },
     };
-    assert.equal(element._hiddenReviewerCount, 3);
-    assert.equal(element._displayedReviewers.length, 6);
-    assert.equal(element._reviewers.length, 9);
+    await element.updateComplete;
+
+    assert.equal(element.hiddenReviewerCount, 3);
+    assert.equal(element.displayedReviewers.length, 6);
+    assert.equal(element.reviewers.length, 9);
     assert.isFalse(
       queryAndAssert<GrButton>(element, '.hiddenReviewers').hidden
     );
   });
 
-  test('show all reviewers button', () => {
+  test('show all reviewers button', async () => {
     const reviewers = [];
     for (let i = 0; i < 100; i++) {
       reviewers.push({
@@ -400,23 +246,28 @@
         CC: reviewers,
       },
     };
-    assert.equal(element._hiddenReviewerCount, 94);
-    assert.equal(element._displayedReviewers.length, 6);
-    assert.equal(element._reviewers.length, 100);
+
+    await element.updateComplete;
+
+    assert.equal(element.hiddenReviewerCount, 94);
+    assert.equal(element.displayedReviewers.length, 6);
+    assert.equal(element.reviewers.length, 100);
     assert.isFalse(
       queryAndAssert<GrButton>(element, '.hiddenReviewers').hidden
     );
 
-    tap(queryAndAssert(element, '.hiddenReviewers'));
+    queryAndAssert<GrButton>(element, '.hiddenReviewers').click();
 
-    assert.equal(element._hiddenReviewerCount, 0);
-    assert.equal(element._displayedReviewers.length, 100);
-    assert.equal(element._reviewers.length, 100);
+    await element.updateComplete;
+
+    assert.equal(element.hiddenReviewerCount, 0);
+    assert.equal(element.displayedReviewers.length, 100);
+    assert.equal(element.reviewers.length, 100);
     assert.isTrue(queryAndAssert<GrButton>(element, '.hiddenReviewers').hidden);
   });
 
-  test('votable labels', () => {
-    const change = {
+  test('votable labels', async () => {
+    element.change = {
       ...createChange(),
       labels: {
         Foo: {
@@ -451,30 +302,33 @@
         FooBar: ['-1', ' 0'],
       },
     };
+    await element.updateComplete;
+
     assert.strictEqual(
-      element._computeVoteableText({...createAccountDetailWithId(1)}, change),
+      element.computeVoteableText({...createAccountDetailWithId(1)}),
       'Bar: +1'
     );
     assert.strictEqual(
-      element._computeVoteableText({...createAccountDetailWithId(7)}, change),
+      element.computeVoteableText({...createAccountDetailWithId(7)}),
       'Foo: +2, Bar: +1, FooBar: 0'
     );
     assert.strictEqual(
-      element._computeVoteableText({...createAccountDetailWithId(2)}, change),
+      element.computeVoteableText({...createAccountDetailWithId(2)}),
       ''
     );
   });
 
-  test('fails gracefully when all is not included', () => {
-    const change = {
+  test('fails gracefully when all is not included', async () => {
+    element.change = {
       ...createChange(),
       labels: {Foo: {}},
       permitted_labels: {
         Foo: ['-1', ' 0', '+1', '+2'],
       },
     };
+    await element.updateComplete;
     assert.strictEqual(
-      element._computeVoteableText({...createAccountDetailWithId(1)}, change),
+      element.computeVoteableText({...createAccountDetailWithId(1)}),
       ''
     );
   });
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
index decbe93..a3ef937 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
@@ -144,7 +144,7 @@
     return html` <div id="container" role="tooltip" tabindex="-1">
       <div class="section">
         <div class="sectionIcon">
-          <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+          <iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>
         </div>
         <div class="sectionContent">
           <h3 class="name heading-3">
@@ -237,7 +237,7 @@
       <gr-button
         link=""
         id="toggleConditionsButton"
-        @click="${(_: MouseEvent) => this.toggleConditionsVisibility()}"
+        @click=${(_: MouseEvent) => this.toggleConditionsVisibility()}
       >
         ${buttonText}
         <iron-icon icon="gr-icons:${icon}"></iron-icon
@@ -285,7 +285,7 @@
     return html` <div class="button quickApprove">
       <gr-button
         link=""
-        @click="${(_: MouseEvent) => this.quickApprove(labelName, maxVote)}"
+        @click=${(_: MouseEvent) => this.quickApprove(labelName, maxVote)}
       >
         ${this.computeVoteButtonName(labelName, maxVote, type)}
       </gr-button>
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
index 3367eb9..ded88de 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
@@ -40,9 +40,9 @@
   setup(async () => {
     element = await fixture<GrSubmitRequirementHovercard>(
       html`<gr-submit-requirement-hovercard
-        .requirement="${createSubmitRequirementResultInfo()}"
+        .requirement=${createSubmitRequirementResultInfo()}
         .change=${createChange()}
-        .account="${createAccountWithId()}"
+        .account=${createAccountWithId()}
       ></gr-submit-requirement-hovercard>`
     );
   });
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
index 46e600e..6e60a22 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
@@ -93,6 +93,7 @@
         iron-icon {
           width: var(--line-height-normal, 20px);
           height: var(--line-height-normal, 20px);
+          vertical-align: top;
         }
         .requirements,
         section.trigger-votes {
@@ -122,13 +123,6 @@
         .votes-cell {
           display: flex;
         }
-        .check-error {
-          margin-right: var(--spacing-l);
-        }
-        .check-error iron-icon {
-          color: var(--error-foreground);
-          vertical-align: top;
-        }
         gr-vote-chip {
           margin-right: var(--spacing-s);
         }
@@ -182,10 +176,10 @@
             (requirement, index) => html`
               <gr-submit-requirement-hovercard
                 for="requirement-${index}-${charsOnly(requirement.name)}"
-                .requirement="${requirement}"
-                .change="${this.change}"
-                .account="${this.account}"
-                .mutable="${this.mutable ?? false}"
+                .requirement=${requirement}
+                .change=${this.change}
+                .account=${this.account}
+                .mutable=${this.mutable ?? false}
               ></gr-submit-requirement-hovercard>
             `
           )}
@@ -199,7 +193,7 @@
           <gr-limited-text
             class="name"
             limit="25"
-            .text="${requirement.name}"
+            .text=${requirement.name}
           ></gr-limited-text>
         </td>
         <td>
@@ -237,10 +231,7 @@
       return html`<div class="votes-cell">${slot}</div>`;
 
     const endpointName = this.calculateEndpointName(requirement.name);
-    return html`<gr-endpoint-decorator
-      class="votes-cell"
-      name="${endpointName}"
-    >
+    return html`<gr-endpoint-decorator class="votes-cell" name=${endpointName}>
       <gr-endpoint-param
         name="change"
         .value=${this.change}
@@ -256,10 +247,10 @@
   renderStatus(status: SubmitRequirementStatus) {
     const icon = iconForStatus(status);
     return html`<iron-icon
-      class="${icon}"
+      class=${icon}
       icon="gr-icons:${icon}"
       role="img"
-      aria-label="${status.toLowerCase()}"
+      aria-label=${status.toLowerCase()}
     ></iron-icon>`;
   }
 
@@ -304,15 +295,15 @@
       return uniqueApprovals.map(
         approvalInfo =>
           html`<gr-vote-chip
-            .vote="${approvalInfo}"
-            .label="${labelInfo}"
-            .more="${(labelInfo.all ?? []).filter(
+            .vote=${approvalInfo}
+            .label=${labelInfo}
+            .more=${(labelInfo.all ?? []).filter(
               other => other.value === approvalInfo.value
-            ).length > 1}"
+            ).length > 1}
           ></gr-vote-chip>`
       );
     } else if (isQuickLabelInfo(labelInfo)) {
-      return [html`<gr-vote-chip .label="${labelInfo}"></gr-vote-chip>`];
+      return [html`<gr-vote-chip .label=${labelInfo}></gr-vote-chip>`];
     } else {
       return html``;
     }
@@ -338,13 +329,13 @@
       .text=${`${runsCount}`}
       .links=${links}
       .statusOrCategory=${Category.ERROR}
-      @click="${() => {
+      @click=${() => {
         fireShowPrimaryTab(this, PrimaryTab.CHECKS, false, {
           checksTab: {
             statusOrCategory: Category.ERROR,
           },
         });
-      }}"
+      }}
     ></gr-checks-chip>`;
   }
 
@@ -373,11 +364,11 @@
         ${triggerVotes.map(
           label =>
             html`<gr-trigger-vote
-              .label="${label}"
-              .labelInfo="${labels[label]}"
-              .change="${this.change}"
-              .account="${this.account}"
-              .mutable="${this.mutable ?? false}"
+              .label=${label}
+              .labelInfo=${labels[label]}
+              .change=${this.change}
+              .account=${this.account}
+              .mutable=${this.mutable ?? false}
               .disableHovercards=${this.disableHovercards}
             ></gr-trigger-vote>`
         )}
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 477f579..bf85d11 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -330,19 +330,19 @@
         <span class="sort-text">Sort By:</span>
         <gr-dropdown-list
           id="sortDropdown"
-          .value="${this.sortDropdownValue}"
-          @value-change="${(e: CustomEvent) =>
-            (this.sortDropdownValue = e.detail.value)}"
-          .items="${this.getSortDropdownEntries()}"
+          .value=${this.sortDropdownValue}
+          @value-change=${(e: CustomEvent) =>
+            (this.sortDropdownValue = e.detail.value)}
+          .items=${this.getSortDropdownEntries()}
         >
         </gr-dropdown-list>
         <span class="separator"></span>
         <span class="filter-text">Filter By:</span>
         <gr-dropdown-list
           id="filterDropdown"
-          .value="${this.getCommentsDropdownValue()}"
-          @value-change="${this.handleCommentsDropdownValueChange}"
-          .items="${this.getCommentsDropdownEntries()}"
+          .value=${this.getCommentsDropdownValue()}
+          @value-change=${this.handleCommentsDropdownValueChange}
+          .items=${this.getCommentsDropdownEntries()}
         >
         </gr-dropdown-list>
         ${this.renderAuthorChips()}
@@ -362,7 +362,7 @@
       <gr-button
         class="show-resolved-comments"
         link
-        @click="${this.handleAllComments}"
+        @click=${this.handleAllComments}
         >Show ${pluralize(threads.length, 'resolved comment')}</gr-button
       >
     `;
@@ -398,16 +398,16 @@
   private renderCommentThread(thread: CommentThread, isFirst: boolean) {
     return html`
       <gr-comment-thread
-        .thread="${thread}"
+        .thread=${thread}
         show-file-path
-        ?show-ported-comment="${thread.ported}"
-        ?show-comment-context="${this.showCommentContext}"
-        ?show-file-name="${isFirst}"
-        .messageId="${this.messageId}"
-        ?should-scroll-into-view="${thread.rootId === this.scrollCommentId}"
-        @comment-thread-editing-changed="${() => {
+        ?show-ported-comment=${thread.ported}
+        ?show-comment-context=${this.showCommentContext}
+        ?show-file-name=${isFirst}
+        .messageId=${this.messageId}
+        ?should-scroll-into-view=${thread.rootId === this.scrollCommentId}
+        @comment-thread-editing-changed=${() => {
           this.requestUpdate();
-        }}"
+        }}
       ></gr-comment-thread>
     `;
   }
@@ -426,11 +426,11 @@
     );
     return html`
       <gr-account-label
-        .account="${account}"
-        @click="${this.handleAccountClicked}"
+        .account=${account}
+        @click=${this.handleAccountClicked}
         selectionChipStyle
         noStatusIcons
-        ?selected="${selected}"
+        ?selected=${selected}
       ></gr-account-label>
     `;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts
index 41964c2..f2bd350 100644
--- a/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote.ts
@@ -133,12 +133,12 @@
       );
       return approvals.map(
         approvalInfo => html`<gr-vote-chip
-          .vote="${approvalInfo}"
-          .label="${labelInfo}"
+          .vote=${approvalInfo}
+          .label=${labelInfo}
         ></gr-vote-chip>`
       );
     } else if (isQuickLabelInfo(labelInfo)) {
-      return [html`<gr-vote-chip .label="${this.labelInfo}"></gr-vote-chip>`];
+      return [html`<gr-vote-chip .label=${this.labelInfo}></gr-vote-chip>`];
     } else {
       return html``;
     }
diff --git a/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote_test.ts b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote_test.ts
index f72ebf4..8497ff4 100644
--- a/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote/gr-trigger-vote_test.ts
@@ -63,11 +63,11 @@
     const labelInfo = change?.labels?.[label];
     element = await fixture<GrTriggerVote>(
       html`<gr-trigger-vote
-        .label="${label}"
-        .labelInfo="${labelInfo}"
-        .change="${change}"
-        .account="${account}"
-        .mutable="${false}"
+        .label=${label}
+        .labelInfo=${labelInfo}
+        .change=${change}
+        .account=${account}
+        .mutable=${false}
       ></gr-trigger-vote>`
     );
   });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-action.ts b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
index 29fe368..6097cde 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-action.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
@@ -63,9 +63,9 @@
     return html`
       <gr-button
         link
-        ?disabled="${this.action.disabled}"
+        ?disabled=${this.action.disabled}
         class="action"
-        @click="${(e: Event) => this.handleClick(e)}"
+        @click=${(e: Event) => this.handleClick(e)}
       >
         ${this.action.name}
       </gr-button>
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 85c0e4a..9ea29b0 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -333,16 +333,16 @@
       `;
     }
     return html`
-      <tr class="${classMap({container: true, collapsed: !this.isExpanded})}">
-        <td class="nameCol" @click="${this.toggleExpandedClick}">
+      <tr class=${classMap({container: true, collapsed: !this.isExpanded})}>
+        <td class="nameCol" @click=${this.toggleExpandedClick}>
           <div class="flex">
-            <gr-hovercard-run .run="${this.result}"></gr-hovercard-run>
+            <gr-hovercard-run .run=${this.result}></gr-hovercard-run>
             <div
               class="name"
               role="button"
               tabindex="0"
-              @click="${this.toggleExpandedClick}"
-              @keydown="${this.toggleExpandedPress}"
+              @click=${this.toggleExpandedClick}
+              @keydown=${this.toggleExpandedPress}
             >
               ${this.result.checkName}
             </div>
@@ -353,7 +353,7 @@
           <div class="summary-cell">
             ${this.renderLink(firstPrimaryLink(this.result))}
             ${this.renderSummary(this.result.summary)}
-            <div class="message" @click="${this.toggleExpandedClick}">
+            <div class="message" @click=${this.toggleExpandedClick}>
               ${this.isExpanded ? '' : this.result.message}
             </div>
             ${this.renderLinks()} ${this.renderActions()}
@@ -363,27 +363,27 @@
             ${this.renderLabel()}
           </div>
         </td>
-        <td class="expanderCol" @click="${this.toggleExpandedClick}">
+        <td class="expanderCol" @click=${this.toggleExpandedClick}>
           <div
             class="show-hide"
             role="switch"
             tabindex="0"
-            ?hidden="${!this.isExpandable}"
-            aria-checked="${this.isExpanded ? 'true' : 'false'}"
-            aria-label="${this.isExpanded
+            ?hidden=${!this.isExpandable}
+            aria-checked=${this.isExpanded ? 'true' : 'false'}
+            aria-label=${this.isExpanded
               ? 'Collapse result row'
-              : 'Expand result row'}"
-            @keydown="${this.toggleExpandedPress}"
+              : 'Expand result row'}
+            @keydown=${this.toggleExpandedPress}
           >
             <iron-icon
-              icon="${this.isExpanded
+              icon=${this.isExpanded
                 ? 'gr-icons:expand-less'
-                : 'gr-icons:expand-more'}"
+                : 'gr-icons:expand-more'}
             ></iron-icon>
           </div>
         </td>
       </tr>
-      <tr class="${classMap({detailsRow: true, collapsed: !this.isExpanded})}">
+      <tr class=${classMap({detailsRow: true, collapsed: !this.isExpanded})}>
         <td class="expandedCol" colspan="3">${this.renderExpanded()}</td>
       </tr>
     `;
@@ -392,7 +392,7 @@
   private renderExpanded() {
     if (!this.isExpanded) return;
     return html`<gr-result-expanded
-      .result="${this.result}"
+      .result=${this.result}
     ></gr-result-expanded>`;
   }
 
@@ -437,7 +437,7 @@
     return html`
       <!-- The &nbsp; is for being able to shrink a tiny amount without
        the text itself getting shrunk with an ellipsis. -->
-      <div class="summary" @click="${this.toggleExpanded}">${text}&nbsp;</div>
+      <div class="summary" @click=${this.toggleExpanded}>${text}&nbsp;</div>
     `;
   }
 
@@ -457,7 +457,7 @@
     return html`
       <div class="label ${status}">
         <span>${label} ${valueStr}</span>
-        <paper-tooltip offset="5" ?fitToVisibleBounds="${true}">
+        <paper-tooltip offset="5" ?fitToVisibleBounds=${true}>
           The check result has (probably) influenced this label vote.
         </paper-tooltip>
       </div>
@@ -484,7 +484,7 @@
     if (this.isExpanded) return;
     if (!link) return;
     const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
-    return html`<a href="${link.url}" class="link" target="_blank"
+    return html`<a href=${link.url} class="link" target="_blank"
       ><iron-icon
         aria-label="external link to details"
         class="link"
@@ -510,12 +510,12 @@
         link=""
         vertical-offset="32"
         horizontal-align="right"
-        @tap-item="${this.handleAction}"
-        @opened-changed="${(e: CustomEvent) =>
-          toggleClass(this, 'dropdown-open', e.detail.value)}"
-        ?hidden="${overflowItems.length === 0}"
-        .items="${overflowItems}"
-        .disabledIds="${disabledItems}"
+        @tap-item=${this.handleAction}
+        @opened-changed=${(e: CustomEvent) =>
+          toggleClass(this, 'dropdown-open', e.detail.value)}
+        ?hidden=${overflowItems.length === 0}
+        .items=${overflowItems}
+        .disabledIds=${disabledItems}
       >
         <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
         </iron-icon>
@@ -536,7 +536,7 @@
     if (!action) return;
     return html`<gr-checks-action
       context="result-row"
-      .action="${action}"
+      .action=${action}
     ></gr-checks-action>`;
   }
 
@@ -561,10 +561,10 @@
   renderTag(tag: Tag) {
     return html`<button
       class="tag ${tag.color}"
-      @click="${(e: MouseEvent) => this.tagClick(e, tag.name)}"
+      @click=${(e: MouseEvent) => this.tagClick(e, tag.name)}
     >
       <span>${tag.name}</span>
-      <paper-tooltip offset="5" ?fitToVisibleBounds="${true}">
+      <paper-tooltip offset="5" ?fitToVisibleBounds=${true}>
         ${tag.tooltip ??
         'A category tag for this check result. Click to filter.'}
       </paper-tooltip>
@@ -624,21 +624,18 @@
       ${this.renderSecondaryLinks()} ${this.renderCodePointers()}
       <gr-endpoint-decorator
         name="check-result-expanded"
-        .targetPlugin="${this.result.pluginName}"
+        .targetPlugin=${this.result.pluginName}
       >
-        <gr-endpoint-param
-          name="run"
-          .value="${this.result}"
-        ></gr-endpoint-param>
+        <gr-endpoint-param name="run" .value=${this.result}></gr-endpoint-param>
         <gr-endpoint-param
           name="result"
-          .value="${this.result}"
+          .value=${this.result}
         ></gr-endpoint-param>
         <gr-formatted-text
           noTrailingMargin
           class="message"
-          .content="${this.result.message}"
-          .config="${this.repoConfig?.commentlinks}"
+          .content=${this.result.message}
+          .config=${this.repoConfig?.commentlinks}
         ></gr-formatted-text>
       </gr-endpoint-decorator>
     `;
@@ -697,7 +694,7 @@
     if (!link) return;
     const text = link.tooltip ?? tooltipForLink(link.icon);
     const target = targetBlank ? '_blank' : undefined;
-    return html`<a href="${link.url}" target="${ifDefined(target)}">
+    return html`<a href=${link.url} target=${ifDefined(target)}>
       <iron-icon
         class="link"
         icon="gr-icons:${iconForLink(link.icon)}"
@@ -1056,27 +1053,27 @@
       notLatest: !!this.checksPatchsetNumber,
     };
     return html`
-      <div class="${classMap(headerClasses)}">
+      <div class=${classMap(headerClasses)}>
         <div class="headerTopRow">
           <div class="left">
             <h2 class="heading-2">Results</h2>
-            <div class="loading" ?hidden="${!this.someProvidersAreLoading}">
+            <div class="loading" ?hidden=${!this.someProvidersAreLoading}>
               <span>Loading results </span>
               <span class="loadingSpin"></span>
             </div>
           </div>
           <div class="right">
             <div class="goToLatest">
-              <gr-button @click="${this.goToLatestPatchset}" link
+              <gr-button @click=${this.goToLatestPatchset} link
                 >Go to latest patchset</gr-button
               >
             </div>
             <gr-dropdown-list
-              value="${this.checksPatchsetNumber ??
+              value=${this.checksPatchsetNumber ??
               this.latestPatchsetNumber ??
-              0}"
-              .items="${this.createPatchsetDropdownItems()}"
-              @value-change="${this.onPatchsetSelected}"
+              0}
+              .items=${this.createPatchsetDropdownItems()}
+              @value-change=${this.onPatchsetSelected}
             ></gr-dropdown-list>
           </div>
         </div>
@@ -1141,9 +1138,9 @@
   private renderLink(link?: Link) {
     if (!link) return;
     const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
-    return html`<a href="${link.url}" target="_blank"
+    return html`<a href=${link.url} target="_blank"
       ><iron-icon
-        aria-label="${tooltipText}"
+        aria-label=${tooltipText}
         class="link"
         icon="gr-icons:${iconForLink(link.icon)}"
       ></iron-icon
@@ -1159,9 +1156,9 @@
         link=""
         vertical-offset="32"
         horizontal-align="right"
-        @tap-item="${this.handleAction}"
-        .items="${items}"
-        .disabledIds="${disabledIds}"
+        @tap-item=${this.handleAction}
+        .items=${items}
+        .disabledIds=${disabledIds}
       >
         <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
         </iron-icon>
@@ -1190,7 +1187,7 @@
     if (!action) return;
     return html`<gr-checks-action
       context="results"
-      .action="${action}"
+      .action=${action}
     ></gr-checks-action>`;
   }
 
@@ -1241,7 +1238,7 @@
           id="filterInput"
           type="text"
           placeholder="Filter results by tag or regular expression"
-          @input="${this.onFilterInputChange}"
+          @input=${this.onFilterInputChange}
         />
       </div>
     `;
@@ -1295,12 +1292,12 @@
       resultCount
     );
     return html`
-      <div class="${expandedClass}">
+      <div class=${expandedClass}>
         <h3
           class="categoryHeader ${catString} ${empty} heading-3"
-          @click="${() => this.toggleExpanded(category)}"
+          @click=${() => this.toggleExpanded(category)}
         >
-          <iron-icon class="expandIcon" icon="${icon}"></iron-icon>
+          <iron-icon class="expandIcon" icon=${icon}></iron-icon>
           <div class="statusIconWrapper">
             <iron-icon
               icon="gr-icons:${iconFor(category)}"
@@ -1336,7 +1333,7 @@
     return html`
       <tr class="showAllRow">
         <td colspan="3">
-          <gr-button class="showAll" link @click="${handler}"
+          <gr-button class="showAll" link @click=${handler}
             >${message}</gr-button
           >
         </td>
@@ -1386,7 +1383,7 @@
       <table class="resultsTable">
         <thead>
           <tr class="headerRow">
-            <th class="${classMap(nameColClasses)}">Run</th>
+            <th class=${classMap(nameColClasses)}>Run</th>
             <th class="summaryCol">Summary</th>
             <th class="expanderCol"></th>
           </tr>
@@ -1397,8 +1394,8 @@
             result => result.internalResultId,
             (result?: RunResult) => html`
               <gr-result-row
-                class="${charsOnly(result!.checkName)}"
-                .result="${result}"
+                class=${charsOnly(result!.checkName)}
+                .result=${result}
               ></gr-result-row>
             `
           )}
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index bf77c1b..81d757e 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -245,35 +245,35 @@
 
     return html`
       <div
-        @click="${this.handleChipClick}"
-        @keydown="${this.handleChipKey}"
-        class="${classMap(classes)}"
+        @click=${this.handleChipClick}
+        @keydown=${this.handleChipKey}
+        class=${classMap(classes)}
         tabindex="0"
       >
         <div class="left">
-          <gr-hovercard-run .run="${this.run}"></gr-hovercard-run>
+          <gr-hovercard-run .run=${this.run}></gr-hovercard-run>
           ${this.renderFilterIcon()}
-          <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+          <iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>
           ${this.renderAdditionalIcon()}
           <span class="name">${this.run.checkName}</span>
           ${this.renderETA()}
         </div>
         <div class="middle">
-          <gr-checks-attempt .run="${this.run}"></gr-checks-attempt>
+          <gr-checks-attempt .run=${this.run}></gr-checks-attempt>
           ${this.renderStatusLink()}
         </div>
         <div class="right">
           ${action
             ? html`<gr-checks-action
                 context="runs"
-                .action="${action}"
+                .action=${action}
               ></gr-checks-action>`
             : ''}
         </div>
       </div>
       <div
         class="attemptDetails"
-        ?hidden="${this.run.isSingleAttempt || !this.selected}"
+        ?hidden=${this.run.isSingleAttempt || !this.selected}
       >
         ${this.run.attemptDetails.map(a => this.renderAttempt(a))}
       </div>
@@ -295,14 +295,14 @@
     return html`<div class="attemptDetail">
       <input
         type="radio"
-        id="${id}"
-        name="${`${checkNameId}-attempt-choice`}"
-        ?checked="${this.isSelected(detail)}"
-        ?disabled="${!this.isSelected(detail) && wasNotRun}"
-        @change="${() => this.handleAttemptChange(detail)}"
+        id=${id}
+        name=${`${checkNameId}-attempt-choice`}
+        ?checked=${this.isSelected(detail)}
+        ?disabled=${!this.isSelected(detail) && wasNotRun}
+        @change=${() => this.handleAttemptChange(detail)}
       />
-      <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
-      <label for="${id}">
+      <iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>
+      <label for=${id}>
         Attempt ${detail.attempt}${wasNotRun ? ' (not run)' : ''}
       </label>
     </div>`;
@@ -325,7 +325,7 @@
     const link = this.run.statusLink;
     if (!link) return;
     return html`
-      <a href="${link}" target="_blank" @click="${this.onLinkClick}"
+      <a href=${link} target="_blank" @click=${this.onLinkClick}
         ><iron-icon
           class="statusLinkIcon"
           icon="gr-icons:launch"
@@ -362,7 +362,7 @@
     if (!category) return nothing;
     const icon = iconFor(category);
     return html`
-      <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+      <iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>
     `;
   }
 
@@ -597,8 +597,8 @@
         id="filterInput"
         type="text"
         placeholder="Filter runs by regular expression"
-        ?hidden="${!this.showFilter()}"
-        @input="${this.onInput}"
+        ?hidden=${!this.showFilter()}
+        @input=${this.onInput}
       />
       ${this.renderSection(RunStatus.RUNNING)}
       ${this.renderSection(RunStatus.COMPLETED)}
@@ -641,7 +641,7 @@
           Sign in to Checks Plugin to see runs and results
         </div>
         <div class="buttonRow">
-          <gr-button @click="${this.loginCallback}" link>Sign in</gr-button>
+          <gr-button @click=${this.loginCallback} link>Sign in</gr-button>
         </div>
       </div>
     `;
@@ -664,20 +664,20 @@
       <gr-button
         class="font-normal"
         link
-        @click="${() => fireRunSelectionReset(this)}"
+        @click=${() => fireRunSelectionReset(this)}
         >Unselect All</gr-button
       >
       <gr-tooltip-content
-        title="${runButtonDisabled
+        title=${runButtonDisabled
           ? 'Disabled. Unselect checks without a "Run" action to enable the button.'
-          : ''}"
+          : ''}
         ?has-tooltip=${runButtonDisabled}
       >
         <gr-button
           class="font-normal"
           link
           ?disabled=${runButtonDisabled}
-          @click="${() => {
+          @click=${() => {
             actions.forEach(action => {
               if (!action) return;
               this.getChecksModel().triggerAction(
@@ -689,7 +689,7 @@
             this.reporting.reportInteraction(
               Interaction.CHECKS_RUNS_SELECTED_TRIGGERED
             );
-          }}"
+          }}
           >Run Selected</gr-button
         >
       </gr-tooltip-content>
@@ -700,22 +700,22 @@
     return html`
       <gr-tooltip-content
         has-tooltip
-        title="${this.collapsed ? 'Expand runs panel' : 'Collapse runs panel'}"
+        title=${this.collapsed ? 'Expand runs panel' : 'Collapse runs panel'}
       >
         <gr-button
           link
           class="expandButton"
           role="switch"
-          aria-checked="${this.collapsed ? 'true' : 'false'}"
-          aria-label="${this.collapsed
+          aria-checked=${this.collapsed ? 'true' : 'false'}
+          aria-label=${this.collapsed
             ? 'Expand runs panel'
-            : 'Collapse runs panel'}"
-          @click="${this.toggleCollapsed}"
+            : 'Collapse runs panel'}
+          @click=${this.toggleCollapsed}
           ><iron-icon
             class="expandIcon"
-            icon="${this.collapsed
+            icon=${this.collapsed
               ? 'gr-icons:chevron-right'
-              : 'gr-icons:chevron-left'}"
+              : 'gr-icons:chevron-left'}
           ></iron-icon>
         </gr-button>
       </gr-tooltip-content>
@@ -781,11 +781,8 @@
     }
     return html`
       <div class="${status.toLowerCase()} ${expandedClass}">
-        <div
-          class="sectionHeader"
-          @click="${() => this.toggleExpanded(status)}"
-        >
-          <iron-icon class="expandIcon" icon="${icon}"></iron-icon>
+        <div class="sectionHeader" @click=${() => this.toggleExpanded(status)}>
+          <iron-icon class="expandIcon" icon=${icon}></iron-icon>
           <h3 class="heading-3">${header}</h3>
         </div>
         <div class="sectionRuns">${runs.map(run => this.renderRun(run))}</div>
@@ -808,10 +805,10 @@
     const selectedAttempt = this.selectedAttempts.get(run.checkName);
     const deselected = !selectedRun && this.selectedRuns.length > 0;
     return html`<gr-checks-run
-      .run="${run}"
-      .selected="${selectedRun}"
-      .selectedAttempt="${selectedAttempt}"
-      .deselected="${deselected}"
+      .run=${run}
+      .selected=${selectedRun}
+      .selectedAttempt=${selectedAttempt}
+      .deselected=${deselected}
     ></gr-checks-run>`;
   }
 
@@ -824,39 +821,31 @@
     return html`
       <div class="testing">
         <div>Toggle fake runs by clicking buttons:</div>
-        <gr-button
-          link
-          @click="${() => clearAllFakeRuns(this.getChecksModel())}"
+        <gr-button link @click=${() => clearAllFakeRuns(this.getChecksModel())}
           >none</gr-button
         >
         <gr-button
           link
-          @click="${() =>
-            this.toggle(
-              'f0',
-              [fakeRun0],
-              fakeActions,
-              fakeLinks,
-              'ETA: 1 min'
-            )}"
+          @click=${() =>
+            this.toggle('f0', [fakeRun0], fakeActions, fakeLinks, 'ETA: 1 min')}
           >0</gr-button
         >
-        <gr-button link @click="${() => this.toggle('f1', [fakeRun1])}"
+        <gr-button link @click=${() => this.toggle('f1', [fakeRun1])}
           >1</gr-button
         >
-        <gr-button link @click="${() => this.toggle('f2', [fakeRun2])}"
+        <gr-button link @click=${() => this.toggle('f2', [fakeRun2])}
           >2</gr-button
         >
-        <gr-button link @click="${() => this.toggle('f3', [fakeRun3])}"
+        <gr-button link @click=${() => this.toggle('f3', [fakeRun3])}
           >3</gr-button
         >
         <gr-button link @click="${() => this.toggle('f4', fakeRun4Att)}}"
           >4</gr-button
         >
-        <gr-button link @click="${() => this.toggle('f5', [fakeRun5])}"
+        <gr-button link @click=${() => this.toggle('f5', [fakeRun5])}
           >5</gr-button
         >
-        <gr-button link @click="${() => setAllFakeRuns(this.getChecksModel())}"
+        <gr-button link @click=${() => setAllFakeRuns(this.getChecksModel())}
           >all</gr-button
         >
       </div>
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index 2f9592f..d808d11 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -133,21 +133,21 @@
       <div class="container">
         <gr-checks-runs
           class="runs"
-          ?collapsed="${this.offsetWidth < 1000}"
-          .runs="${this.runs}"
-          .selectedRuns="${this.selectedRuns}"
-          .selectedAttempts="${this.selectedAttempts}"
-          .tabState="${this.tabState?.checksTab}"
-          @run-selected="${this.handleRunSelected}"
-          @attempt-selected="${this.handleAttemptSelected}"
+          ?collapsed=${this.offsetWidth < 1000}
+          .runs=${this.runs}
+          .selectedRuns=${this.selectedRuns}
+          .selectedAttempts=${this.selectedAttempts}
+          .tabState=${this.tabState?.checksTab}
+          @run-selected=${this.handleRunSelected}
+          @attempt-selected=${this.handleAttemptSelected}
         ></gr-checks-runs>
         <gr-checks-results
           class="results"
-          .tabState="${this.tabState?.checksTab}"
-          .runs="${this.runs}"
-          .selectedRuns="${this.selectedRuns}"
-          .selectedAttempts="${this.selectedAttempts}"
-          @run-selected="${this.handleRunSelected}"
+          .tabState=${this.tabState?.checksTab}
+          .runs=${this.runs}
+          .selectedRuns=${this.selectedRuns}
+          .selectedAttempts=${this.selectedAttempts}
+          @run-selected=${this.handleRunSelected}
         ></gr-checks-results>
       </div>
     `;
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
index b42c4fd..a602c72 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -129,19 +129,19 @@
     const cat = this.result.category.toLowerCase();
     return html`
       <div class="${cat} container font-normal">
-        <div class="header" @click="${this.toggleExpandedClick}">
+        <div class="header" @click=${this.toggleExpandedClick}>
           <div class="icon">
             <iron-icon
               icon="gr-icons:${iconFor(this.result.category)}"
             ></iron-icon>
           </div>
           <div class="name">
-            <gr-hovercard-run .run="${this.result}"></gr-hovercard-run>
+            <gr-hovercard-run .run=${this.result}></gr-hovercard-run>
             <div
               class="name"
               role="button"
               tabindex="0"
-              @keydown="${this.toggleExpandedPress}"
+              @keydown=${this.toggleExpandedPress}
             >
               ${this.result.checkName}
             </div>
@@ -166,16 +166,16 @@
         class="show-hide"
         role="switch"
         tabindex="0"
-        aria-checked="${this.isExpanded ? 'true' : 'false'}"
-        aria-label="${this.isExpanded
+        aria-checked=${this.isExpanded ? 'true' : 'false'}
+        aria-label=${this.isExpanded
           ? 'Collapse result row'
-          : 'Expand result row'}"
-        @keydown="${this.toggleExpandedPress}"
+          : 'Expand result row'}
+        @keydown=${this.toggleExpandedPress}
       >
         <iron-icon
-          icon="${this.isExpanded
+          icon=${this.isExpanded
             ? 'gr-icons:expand-less'
-            : 'gr-icons:expand-more'}"
+            : 'gr-icons:expand-more'}
         ></iron-icon>
       </div>
     `;
@@ -186,7 +186,7 @@
     return html`
       <gr-result-expanded
         hidecodepointers
-        .result="${this.result}"
+        .result=${this.result}
       ></gr-result-expanded>
     `;
   }
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
index ecc24c4..cc38693 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
@@ -137,7 +137,7 @@
       <div id="container" role="tooltip" tabindex="-1">
         <div class="section">
           <div
-            ?hidden="${!this.run || this.run.status === RunStatus.RUNNABLE}"
+            ?hidden=${!this.run || this.run.status === RunStatus.RUNNABLE}
             class="chipRow"
           >
             <div class="chip">
@@ -147,8 +147,8 @@
           </div>
         </div>
         <div class="section">
-          <div class="sectionIcon" ?hidden="${icon.length === 0}">
-            <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+          <div class="sectionIcon" ?hidden=${icon.length === 0}>
+            <iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>
           </div>
           <div class="sectionContent">
             <h3 class="name heading-3">
@@ -177,7 +177,7 @@
             ? html` <div class="row">
                 <div class="title">Status</div>
                 <div>
-                  <a href="${this.run.statusLink}" target="_blank"
+                  <a href=${this.run.statusLink} target="_blank"
                     ><iron-icon
                       aria-label="external link to check status"
                       class="small link"
@@ -222,7 +222,7 @@
       <div>
         <div class="attemptIcon">
           <iron-icon
-            class="${attempt.icon}"
+            class=${attempt.icon}
             icon="gr-icons:${attempt.icon}"
           ></iron-icon>
         </div>
@@ -320,7 +320,7 @@
             ? html` <div class="row">
                 <div class="title">Documentation</div>
                 <div>
-                  <a href="${this.run.checkLink}" target="_blank"
+                  <a href=${this.run.checkLink} target="_blank"
                     ><iron-icon
                       aria-label="external link to check documentation"
                       class="small link"
@@ -344,8 +344,8 @@
           <div class="action">
             <gr-checks-action
               context="hovercard"
-              .eventTarget="${this._target}"
-              .action="${action}"
+              .eventTarget=${this._target}
+              .action=${action}
             ></gr-checks-action>
           </div>
         `
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
index b55d5a4..fbe1c60 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
@@ -97,16 +97,16 @@
   override render() {
     return html`<gr-dropdown
       link=""
-      .items="${this.links}"
-      .topContent="${this.topContent}"
+      .items=${this.links}
+      .topContent=${this.topContent}
       @tap-item-shortcuts=${this._handleShortcutsTap}
       .horizontalAlign=${'right'}
     >
-      <span ?hidden="${this._hasAvatars}"
+      <span ?hidden=${this._hasAvatars}
         >${this._accountName(this.account)}</span
       >
       <gr-avatar
-        .account="${this.account}"
+        .account=${this.account}
         ?hidden=${!this._hasAvatars}
         .imageSize=${56}
         aria-label="Account avatar"
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
index ba0984b..a0f286f 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
@@ -88,7 +88,7 @@
 
     return html`
       <gr-button id="signIn" class="signInLink" link="" slot="footer">
-        <a class="signInLink" href="${this.loginUrl}">Sign in</a>
+        <a class="signInLink" href=${this.loginUrl}>Sign in</a>
       </gr-button>
     `;
   }
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
index 5cc3737..e736928 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -358,7 +358,7 @@
   override render() {
     return html`
   <nav>
-    <a href="${`//${window.location.host}${getBaseUrl()}/`}" class="bigTitle">
+    <a href=${`//${window.location.host}${getBaseUrl()}/`} class="bigTitle">
       <gr-endpoint-decorator name="header-title">
         <span class="titleText"></span>
       </gr-endpoint-decorator>
@@ -398,14 +398,14 @@
 
   private renderLinkGroup(linkGroup: MainHeaderLinkGroup) {
     return html`
-      <li class="${linkGroup.class ?? ''}">
+      <li class=${linkGroup.class ?? ''}>
         <gr-dropdown
           link
           down-arrow
           .items=${linkGroup.links}
           horizontal-align="left"
         >
-          <span class="linksTitle" id="${linkGroup.title}">
+          <span class="linksTitle" id=${linkGroup.title}>
             ${linkGroup.title}
           </span>
         </gr-dropdown>
@@ -418,7 +418,7 @@
 
     return html`
       <a
-        href="${this.feedbackURL}"
+        href=${this.feedbackURL}
         title="File a bug"
         aria-label="File a bug"
         target="_blank"
@@ -439,12 +439,12 @@
             this.onMobileSearchTap(e);
           }}
           role="button"
-          aria-label="${this.mobileSearchHidden
+          aria-label=${this.mobileSearchHidden
             ? 'Show Searchbar'
-            : 'Hide Searchbar'}"
+            : 'Hide Searchbar'}
         ></iron-icon>
         ${this.renderRegister()}
-        <a class="loginButton" href="${this.loginUrl}">Sign in</a>
+        <a class="loginButton" href=${this.loginUrl}>Sign in</a>
         <a
           class="settingsButton"
           href="${getBaseUrl()}/settings/"
@@ -464,7 +464,7 @@
 
     return html`
       <div class="registerDiv">
-        <a class="registerButton" href="${this.registerURL}">
+        <a class="registerButton" href=${this.registerURL}>
           ${this.registerText}
         </a>
       </div>
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 567e1bb..c264978 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -1,28 +1,14 @@
 /**
  * @license
- * Copyright (C) 2019 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.
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-icon/iron-icon';
 import '../../../styles/shared-styles';
 import '../../shared/gr-dialog/gr-dialog';
 import '../../shared/gr-overlay/gr-overlay';
 import '../../../embed/diff/gr-diff/gr-diff';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-apply-fix-dialog_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property} from '@polymer/decorators';
 import {
   NumericChangeId,
   EditPatchSetNum,
@@ -41,13 +27,9 @@
 import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
-
-export interface GrApplyFixDialog {
-  $: {
-    applyFixOverlay: GrOverlay;
-    nextFix: GrButton;
-  };
-}
+import {css, html, LitElement} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
 
 interface FilePreview {
   filepath: string;
@@ -55,10 +37,12 @@
 }
 
 @customElement('gr-apply-fix-dialog')
-export class GrApplyFixDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrApplyFixDialog extends LitElement {
+  @query('#applyFixOverlay')
+  applyFixOverlay?: GrOverlay;
+
+  @query('#nextFix')
+  nextFix?: GrButton;
 
   @property({type: Object})
   prefs?: DiffPreferencesInfo;
@@ -66,52 +50,142 @@
   @property({type: Object})
   change?: ParsedChangeInfo;
 
-  @property({type: String})
+  @property({type: Number})
   changeNum?: NumericChangeId;
 
-  @property({type: Number})
-  _patchNum?: PatchSetNum;
+  @state()
+  patchNum?: PatchSetNum;
 
-  @property({type: String})
-  _robotId?: RobotId;
+  @state()
+  robotId?: RobotId;
 
-  @property({type: Object})
-  _currentFix?: FixSuggestionInfo;
+  @state()
+  currentFix?: FixSuggestionInfo;
 
-  @property({type: Array})
-  _currentPreviews: FilePreview[] = [];
+  @state()
+  currentPreviews: FilePreview[] = [];
 
-  @property({type: Array})
-  _fixSuggestions?: FixSuggestionInfo[];
+  @state()
+  fixSuggestions?: FixSuggestionInfo[];
 
-  @property({type: Boolean})
-  _isApplyFixLoading = false;
+  @state()
+  isApplyFixLoading = false;
 
-  @property({type: Number})
-  _selectedFixIdx = 0;
+  @state()
+  selectedFixIdx = 0;
 
-  @property({
-    type: Boolean,
-    computed:
-      '_computeDisableApplyFixButton(_isApplyFixLoading, change, ' +
-      '_patchNum)',
-  })
-  _disableApplyFixButton = false;
-
-  @property({type: Array})
+  @state()
   layers: DiffLayer[] = [];
 
-  private refitOverlay?: () => void;
-
   private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
+    // TODO Get preferences from model.
     this.restApiService.getPreferences().then(prefs => {
       if (!prefs?.disable_token_highlighting) {
         this.layers = [new TokenHighlightLayer(this)];
       }
     });
+    this.addEventListener('diff-context-expanded', () => {
+      if (this.applyFixOverlay) fireEvent(this.applyFixOverlay, 'iron-resize');
+    });
+  }
+
+  static override styles = [
+    sharedStyles,
+    css`
+      gr-diff {
+        --content-width: 90vw;
+      }
+      .diffContainer {
+        padding: var(--spacing-l) 0;
+        border-bottom: 1px solid var(--border-color);
+      }
+      .file-name {
+        display: block;
+        padding: var(--spacing-s) var(--spacing-l);
+        background-color: var(--background-color-secondary);
+        border-bottom: 1px solid var(--border-color);
+      }
+      gr-button {
+        margin-left: var(--spacing-m);
+      }
+      .fix-picker {
+        display: flex;
+        align-items: center;
+        margin-right: var(--spacing-l);
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <gr-overlay id="applyFixOverlay" with-backdrop="">
+        <gr-dialog
+          id="applyFixDialog"
+          .confirmLabel=${this.isApplyFixLoading ? 'Saving...' : 'Apply Fix'}
+          .confirmTooltip=${this.computeTooltip()}
+          ?disabled=${this.computeDisableApplyFixButton()}
+          @confirm=${this.handleApplyFix}
+          @cancel=${this.onCancel}
+        >
+          ${this.renderHeader()} ${this.renderMain()} ${this.renderFooter()}
+        </gr-dialog>
+      </gr-overlay>
+    `;
+  }
+
+  private renderHeader() {
+    return html`
+      <div slot="header">
+        ${this.robotId ?? ''} - ${this.currentFix?.description ?? ''}
+      </div>
+    `;
+  }
+
+  private renderMain() {
+    const items = this.currentPreviews.map(
+      item => html`
+        <div class="file-name">
+          <span>${item.filepath}</span>
+        </div>
+        <div class="diffContainer">
+          <gr-diff
+            .prefs=${this.overridePartialPrefs()}
+            .path=${item.filepath}
+            .diff=${item.preview}
+            .layers=${this.layers}
+          ></gr-diff>
+        </div>
+      `
+    );
+    return html`<div slot="main">${items}</div>`;
+  }
+
+  private renderFooter() {
+    const id = this.selectedFixIdx;
+    const fixCount = this.fixSuggestions?.length ?? 0;
+    if (fixCount < 2) return;
+    return html`
+      <div slot="footer" class="fix-picker">
+        <span>Suggested fix ${id + 1} of ${fixCount}</span>
+        <gr-button
+          id="prevFix"
+          @click=${this.onPrevFixClick}
+          ?disabled=${id === 0}
+        >
+          <iron-icon icon="gr-icons:chevron-left"></iron-icon>
+        </gr-button>
+        <gr-button
+          id="nextFix"
+          @click=${this.onNextFixClick}
+          ?disabled=${id === fixCount - 1}
+        >
+          <iron-icon icon="gr-icons:chevron-right"></iron-icon>
+        </gr-button>
+      </div>
+    `;
   }
 
   /**
@@ -128,183 +202,131 @@
     if (!detail.patchNum || !comment || !isRobot(comment)) {
       return Promise.resolve();
     }
-    this._patchNum = detail.patchNum;
-    this._fixSuggestions = comment.fix_suggestions;
-    this._robotId = comment.robot_id;
-    if (!this._fixSuggestions || !this._fixSuggestions.length) {
+    this.patchNum = detail.patchNum;
+    this.fixSuggestions = comment.fix_suggestions;
+    this.robotId = comment.robot_id;
+    if (!this.fixSuggestions || !this.fixSuggestions.length) {
       return Promise.resolve();
     }
-    this._selectedFixIdx = 0;
+    this.selectedFixIdx = 0;
     const promises = [];
     promises.push(
-      this._showSelectedFixSuggestion(this._fixSuggestions[0]),
-      this.$.applyFixOverlay.open()
+      this.showSelectedFixSuggestion(this.fixSuggestions[0]),
+      this.applyFixOverlay?.open()
     );
     return Promise.all(promises).then(() => {
-      // ensures gr-overlay repositions overlay in center
-      fireEvent(this.$.applyFixOverlay, 'iron-resize');
+      if (this.applyFixOverlay) fireEvent(this.applyFixOverlay, 'iron-resize');
     });
   }
 
-  override connectedCallback() {
-    super.connectedCallback();
-    this.refitOverlay = () => {
-      // re-center the dialog as content changed
-      fireEvent(this.$.applyFixOverlay, 'iron-resize');
-    };
-    this.addEventListener('diff-context-expanded', this.refitOverlay);
+  private showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
+    this.currentFix = fixSuggestion;
+    return this.fetchFixPreview(fixSuggestion.fix_id);
   }
 
-  override disconnectedCallback() {
-    if (this.refitOverlay) {
-      this.removeEventListener('diff-context-expanded', this.refitOverlay);
-    }
-    super.disconnectedCallback();
-  }
-
-  _showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
-    this._currentFix = fixSuggestion;
-    return this._fetchFixPreview(fixSuggestion.fix_id);
-  }
-
-  _fetchFixPreview(fixId: FixId) {
-    if (!this.changeNum || !this._patchNum) {
+  private fetchFixPreview(fixId: FixId) {
+    if (!this.changeNum || !this.patchNum) {
       return Promise.reject(
-        new Error('Both _patchNum and changeNum must be set')
+        new Error('Both patchNum and changeNum must be set')
       );
     }
     return this.restApiService
-      .getRobotCommentFixPreview(this.changeNum, this._patchNum, fixId)
+      .getRobotCommentFixPreview(this.changeNum, this.patchNum, fixId)
       .then(res => {
         if (res) {
-          this._currentPreviews = Object.keys(res).map(key => {
+          this.currentPreviews = Object.keys(res).map(key => {
             return {filepath: key, preview: res[key]};
           });
         }
       })
       .catch(err => {
-        this._close(false);
+        this.close(false);
         throw err;
       });
   }
 
-  hasSingleFix(_fixSuggestions?: FixSuggestionInfo[]) {
-    return (_fixSuggestions || []).length === 1;
-  }
-
-  overridePartialPrefs(prefs?: DiffPreferencesInfo) {
-    if (!prefs) return undefined;
+  private overridePartialPrefs() {
+    if (!this.prefs) return undefined;
     // generate a smaller gr-diff than fullscreen for dialog
-    return {...prefs, line_length: 50};
+    return {...this.prefs, line_length: 50};
   }
 
+  // visible for testing
   onCancel(e: Event) {
-    if (e) {
-      e.stopPropagation();
-    }
-    this._close(false);
-  }
-
-  addOneTo(_selectedFixIdx: number) {
-    return _selectedFixIdx + 1;
-  }
-
-  _onPrevFixClick(e: Event) {
     if (e) e.stopPropagation();
-    if (this._selectedFixIdx >= 1 && this._fixSuggestions) {
-      this._selectedFixIdx -= 1;
-      this._showSelectedFixSuggestion(
-        this._fixSuggestions[this._selectedFixIdx]
-      );
+    this.close(false);
+  }
+
+  // visible for testing
+  onPrevFixClick(e: Event) {
+    if (e) e.stopPropagation();
+    if (this.selectedFixIdx >= 1 && this.fixSuggestions) {
+      this.selectedFixIdx -= 1;
+      this.showSelectedFixSuggestion(this.fixSuggestions[this.selectedFixIdx]);
     }
   }
 
-  _onNextFixClick(e: Event) {
+  // visible for testing
+  onNextFixClick(e: Event) {
     if (e) e.stopPropagation();
     if (
-      this._fixSuggestions &&
-      this._selectedFixIdx < this._fixSuggestions.length
+      this.fixSuggestions &&
+      this.selectedFixIdx < this.fixSuggestions.length
     ) {
-      this._selectedFixIdx += 1;
-      this._showSelectedFixSuggestion(
-        this._fixSuggestions[this._selectedFixIdx]
-      );
+      this.selectedFixIdx += 1;
+      this.showSelectedFixSuggestion(this.fixSuggestions[this.selectedFixIdx]);
     }
   }
 
-  _noPrevFix(_selectedFixIdx: number) {
-    return _selectedFixIdx === 0;
-  }
-
-  _noNextFix(_selectedFixIdx: number, fixSuggestions?: FixSuggestionInfo[]) {
-    if (!fixSuggestions) return true;
-    return _selectedFixIdx === fixSuggestions.length - 1;
-  }
-
-  _close(fixApplied: boolean) {
-    this._currentFix = undefined;
-    this._currentPreviews = [];
-    this._isApplyFixLoading = false;
+  private close(fixApplied: boolean) {
+    this.currentFix = undefined;
+    this.currentPreviews = [];
+    this.isApplyFixLoading = false;
 
     fireCloseFixPreview(this, fixApplied);
-    this.$.applyFixOverlay.close();
+    this.applyFixOverlay?.close();
   }
 
-  _getApplyFixButtonLabel(isLoading: boolean) {
-    return isLoading ? 'Saving...' : 'Apply Fix';
-  }
-
-  _computeTooltip(change?: ParsedChangeInfo, patchNum?: PatchSetNum) {
-    if (!change || !patchNum) return '';
-    const latestPatchNum = change.revisions[change.current_revision]._number;
-    return latestPatchNum !== patchNum
+  private computeTooltip() {
+    if (!this.change || !this.patchNum) return '';
+    const currentPatchNum =
+      this.change.revisions[this.change.current_revision]._number;
+    return currentPatchNum !== this.patchNum
       ? 'Fix can only be applied to the latest patchset'
       : '';
   }
 
-  _computeDisableApplyFixButton(
-    isApplyFixLoading: boolean,
-    change?: ParsedChangeInfo,
-    patchNum?: PatchSetNum
-  ) {
-    if (!change || isApplyFixLoading === undefined || patchNum === undefined) {
-      return true;
-    }
-    const currentPatchNum = change.revisions[change.current_revision]._number;
-    if (patchNum !== currentPatchNum) {
-      return true;
-    }
-    return isApplyFixLoading;
+  private computeDisableApplyFixButton() {
+    if (!this.change || !this.patchNum) return true;
+    const currentPatchNum =
+      this.change.revisions[this.change.current_revision]._number;
+    return this.patchNum !== currentPatchNum || this.isApplyFixLoading;
   }
 
-  _handleApplyFix(e: Event) {
-    if (e) {
-      e.stopPropagation();
-    }
+  // visible for testing
+  async handleApplyFix(e: Event) {
+    if (e) e.stopPropagation();
 
     const changeNum = this.changeNum;
-    const patchNum = this._patchNum;
+    const patchNum = this.patchNum;
     const change = this.change;
-    if (!changeNum || !patchNum || !change || !this._currentFix) {
-      return Promise.reject(new Error('Not all required properties are set.'));
+    if (!changeNum || !patchNum || !change || !this.currentFix) {
+      throw new Error('Not all required properties are set.');
     }
-    this._isApplyFixLoading = true;
-    return this.restApiService
-      .applyFixSuggestion(changeNum, patchNum, this._currentFix.fix_id)
-      .then(res => {
-        if (res && res.ok) {
-          GerritNav.navigateToChange(change, {
-            patchNum: EditPatchSetNum,
-            basePatchNum: patchNum as BasePatchSetNum,
-          });
-          this._close(true);
-        }
-        this._isApplyFixLoading = false;
+    this.isApplyFixLoading = true;
+    const res = await this.restApiService.applyFixSuggestion(
+      changeNum,
+      patchNum,
+      this.currentFix.fix_id
+    );
+    if (res && res.ok) {
+      GerritNav.navigateToChange(change, {
+        patchNum: EditPatchSetNum,
+        basePatchNum: patchNum as BasePatchSetNum,
       });
-  }
-
-  getFixDescription(currentFix?: FixSuggestionInfo) {
-    return currentFix && currentFix.description ? currentFix.description : '';
+      this.close(true);
+    }
+    this.isApplyFixLoading = false;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
deleted file mode 100644
index 0f50157..0000000
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_html.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    gr-diff {
-      --content-width: 90vw;
-    }
-    .diffContainer {
-      padding: var(--spacing-l) 0;
-      border-bottom: 1px solid var(--border-color);
-    }
-    .file-name {
-      display: block;
-      padding: var(--spacing-s) var(--spacing-l);
-      background-color: var(--background-color-secondary);
-      border-bottom: 1px solid var(--border-color);
-    }
-    gr-button {
-      margin-left: var(--spacing-m);
-    }
-    .fix-picker {
-      display: flex;
-      align-items: center;
-      margin-right: var(--spacing-l);
-    }
-  </style>
-  <gr-overlay id="applyFixOverlay" with-backdrop="">
-    <gr-dialog
-      id="applyFixDialog"
-      on-confirm="_handleApplyFix"
-      confirm-label="[[_getApplyFixButtonLabel(_isApplyFixLoading)]]"
-      disabled="[[_disableApplyFixButton]]"
-      confirm-tooltip="[[_computeTooltip(change, _patchNum)]]"
-      on-cancel="onCancel"
-    >
-      <div slot="header">[[_robotId]] - [[getFixDescription(_currentFix)]]</div>
-      <div slot="main">
-        <template is="dom-repeat" items="[[_currentPreviews]]">
-          <div class="file-name">
-            <span>[[item.filepath]]</span>
-          </div>
-          <div class="diffContainer">
-            <gr-diff
-              prefs="[[overridePartialPrefs(prefs)]]"
-              path="[[item.filepath]]"
-              diff="[[item.preview]]"
-              layers="[[layers]]"
-            ></gr-diff>
-          </div>
-        </template>
-      </div>
-      <div
-        slot="footer"
-        class="fix-picker"
-        hidden$="[[hasSingleFix(_fixSuggestions)]]"
-      >
-        <span
-          >Suggested fix [[addOneTo(_selectedFixIdx)]] of
-          [[_fixSuggestions.length]]</span
-        >
-        <gr-button
-          id="prevFix"
-          on-click="_onPrevFixClick"
-          disabled$="[[_noPrevFix(_selectedFixIdx)]]"
-        >
-          <iron-icon icon="gr-icons:chevron-left"></iron-icon>
-        </gr-button>
-        <gr-button
-          id="nextFix"
-          on-click="_onNextFixClick"
-          disabled$="[[_noNextFix(_selectedFixIdx, _fixSuggestions)]]"
-        >
-          <iron-icon icon="gr-icons:chevron-right"></iron-icon>
-        </gr-button>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
index 9392cb9d1..2c0fe0d 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -1,20 +1,8 @@
 /**
  * @license
- * Copyright (C) 2019 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.
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../../test/common-test-setup-karma';
 import './gr-apply-fix-dialog';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
@@ -30,6 +18,7 @@
   Timestamp,
   UrlEncodedCommentId,
 } from '../../../types/common';
+import {Comment} from '../../../utils/comment-util';
 import {
   createFixSuggestionInfo,
   createParsedChange,
@@ -44,8 +33,7 @@
   OpenFixPreviewEventDetail,
 } from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
-
-const basicFixture = fixtureFromElement('gr-apply-fix-dialog');
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-apply-fix-dialog tests', () => {
   let element: GrApplyFixDialog;
@@ -78,15 +66,29 @@
     );
   }
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  async function open(comment: Comment) {
+    await element.open(
+      new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
+        detail: {
+          patchNum: 2 as PatchSetNum,
+          comment,
+        },
+      })
+    );
+    await element.updateComplete;
+  }
+
+  setup(async () => {
+    element = await fixture<GrApplyFixDialog>(
+      html`<gr-apply-fix-dialog></gr-apply-fix-dialog>`
+    );
     const change = {
       ...createParsedChange(),
       revisions: createRevisions(2),
       current_revision: getCurrentRevision(1),
     };
     element.changeNum = change._number;
-    element._patchNum = change.revisions[change.current_revision]._number;
+    element.patchNum = change.revisions[change.current_revision]._number;
     element.change = change;
     element.prefs = {
       ...createDefaultDiffPrefs(),
@@ -94,6 +96,7 @@
       line_length: 100,
       tab_size: 4,
     };
+    await element.updateComplete;
   });
 
   suite('dialog open', () => {
@@ -156,37 +159,22 @@
           f2: diffInfo2,
         })
       );
-      sinon.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
+      sinon.stub(element.applyFixOverlay!, 'open').returns(Promise.resolve());
     });
 
     test('dialog opens fetch and sets previews', async () => {
-      await element.open(
-        new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
-          detail: {
-            patchNum: 2 as PatchSetNum,
-            comment: ROBOT_COMMENT_WITH_TWO_FIXES,
-          },
-        })
-      );
-      assert.equal(element._currentFix!.fix_id, 'fix_1');
-      assert.equal(element._currentPreviews.length, 2);
-      assert.equal(element._robotId, 'robot_1' as RobotId);
+      await open(ROBOT_COMMENT_WITH_TWO_FIXES);
+      assert.equal(element.currentFix!.fix_id, 'fix_1');
+      assert.equal(element.currentPreviews.length, 2);
+      assert.equal(element.robotId, 'robot_1' as RobotId);
       const button = getConfirmButton();
       assert.isFalse(button.hasAttribute('disabled'));
       assert.equal(button.getAttribute('title'), '');
     });
 
     test('tooltip is hidden if apply fix is loading', async () => {
-      await element.open(
-        new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
-          detail: {
-            patchNum: 2 as PatchSetNum,
-            comment: ROBOT_COMMENT_WITH_TWO_FIXES,
-          },
-        })
-      );
-      element._isApplyFixLoading = true;
-      await flush();
+      element.isApplyFixLoading = true;
+      await open(ROBOT_COMMENT_WITH_TWO_FIXES);
       const button = getConfirmButton();
       assert.isTrue(button.hasAttribute('disabled'));
       assert.equal(button.getAttribute('title'), '');
@@ -198,15 +186,7 @@
         revisions: createRevisions(2),
         current_revision: getCurrentRevision(0),
       };
-      await element.open(
-        new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
-          detail: {
-            patchNum: 2 as PatchSetNum,
-            comment: ROBOT_COMMENT_WITH_ONE_FIX,
-          },
-        })
-      );
-      await flush();
+      await open(ROBOT_COMMENT_WITH_TWO_FIXES);
       const button = getConfirmButton();
       assert.isTrue(button.hasAttribute('disabled'));
       assert.equal(
@@ -216,44 +196,63 @@
     });
   });
 
+  test('renders', async () => {
+    await open(ROBOT_COMMENT_WITH_TWO_FIXES);
+    expect(element).shadowDom.to.equal(
+      /* HTML */ `
+        <gr-overlay id="applyFixOverlay" tabindex="-1" with-backdrop="">
+          <gr-dialog id="applyFixDialog" role="dialog">
+            <div slot="header">robot_1 - Fix fix_1</div>
+            <div slot="main"></div>
+            <div class="fix-picker" slot="footer">
+              <span>Suggested fix 1 of 2</span>
+              <gr-button
+                aria-disabled="true"
+                disabled=""
+                id="prevFix"
+                role="button"
+                tabindex="-1"
+              >
+                <iron-icon icon="gr-icons:chevron-left"> </iron-icon>
+              </gr-button>
+              <gr-button
+                aria-disabled="false"
+                id="nextFix"
+                role="button"
+                tabindex="0"
+              >
+                <iron-icon icon="gr-icons:chevron-right"> </iron-icon>
+              </gr-button>
+            </div>
+          </gr-dialog>
+        </gr-overlay>
+      `,
+      {ignoreAttributes: ['style']}
+    );
+  });
+
   test('next button state updated when suggestions changed', async () => {
     stubRestApi('getRobotCommentFixPreview').returns(Promise.resolve({}));
-    sinon.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
+    sinon.stub(element.applyFixOverlay!, 'open').returns(Promise.resolve());
 
-    await element.open(
-      new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
-        detail: {
-          patchNum: 2 as PatchSetNum,
-          comment: ROBOT_COMMENT_WITH_ONE_FIX,
-        },
-      })
-    );
-    assert.isTrue(element.$.nextFix.disabled);
-    await element.open(
-      new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
-        detail: {
-          patchNum: 2 as PatchSetNum,
-          comment: ROBOT_COMMENT_WITH_TWO_FIXES,
-        },
-      })
-    );
-    assert.isFalse(element.$.nextFix.disabled);
+    await open(ROBOT_COMMENT_WITH_ONE_FIX);
+    await element.updateComplete;
+    assert.notOk(element.nextFix);
+    await open(ROBOT_COMMENT_WITH_TWO_FIXES);
+    assert.ok(element.nextFix);
+    assert.notOk(element.nextFix!.disabled);
   });
 
   test('preview endpoint throws error should reset dialog', async () => {
     stubRestApi('getRobotCommentFixPreview').returns(
       Promise.reject(new Error('backend error'))
     );
-    element.open(
-      new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
-        detail: {
-          patchNum: 2 as PatchSetNum,
-          comment: ROBOT_COMMENT_WITH_TWO_FIXES,
-        },
-      })
-    );
-    await flush();
-    assert.equal(element._currentFix, undefined);
+    try {
+      await open(ROBOT_COMMENT_WITH_TWO_FIXES);
+    } catch (error) {
+      // expected
+    }
+    assert.equal(element.currentFix, undefined);
   });
 
   test('apply fix button should call apply, navigate to change view and fire close', async () => {
@@ -261,7 +260,7 @@
       Promise.resolve(new Response(null, {status: 200}))
     );
     const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
-    element._currentFix = createFixSuggestionInfo('123');
+    element.currentFix = createFixSuggestionInfo('123');
 
     const closeFixPreviewEventSpy = sinon.spy();
     // Element is recreated after each test, removeEventListener isn't required
@@ -269,7 +268,7 @@
       EventType.CLOSE_FIX_PREVIEW,
       closeFixPreviewEventSpy
     );
-    await element._handleApplyFix(new CustomEvent('confirm'));
+    await element.handleApplyFix(new CustomEvent('confirm'));
 
     sinon.assert.calledOnceWithExactly(
       applyFixSuggestionStub,
@@ -292,8 +291,8 @@
     );
 
     // reset gr-apply-fix-dialog and close
-    assert.equal(element._currentFix, undefined);
-    assert.equal(element._currentPreviews.length, 0);
+    assert.equal(element.currentFix, undefined);
+    assert.equal(element.currentPreviews.length, 0);
   });
 
   test('should not navigate to change view if incorect reponse', async () => {
@@ -301,9 +300,9 @@
       Promise.resolve(new Response(null, {status: 500}))
     );
     const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
-    element._currentFix = createFixSuggestionInfo('fix_123');
+    element.currentFix = createFixSuggestionInfo('fix_123');
 
-    await element._handleApplyFix(new CustomEvent('confirm'));
+    await element.handleApplyFix(new CustomEvent('confirm'));
     sinon.assert.calledWithExactly(
       applyFixSuggestionStub,
       element.change!._number,
@@ -312,24 +311,17 @@
     );
     assert.isTrue(navigateToChangeStub.notCalled);
 
-    assert.equal(element._isApplyFixLoading, false);
+    assert.equal(element.isApplyFixLoading, false);
   });
 
   test('select fix forward and back of multiple suggested fixes', async () => {
-    sinon.stub(element.$.applyFixOverlay, 'open').returns(Promise.resolve());
+    sinon.stub(element.applyFixOverlay!, 'open').returns(Promise.resolve());
 
-    await element.open(
-      new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
-        detail: {
-          patchNum: 2 as PatchSetNum,
-          comment: ROBOT_COMMENT_WITH_TWO_FIXES,
-        },
-      })
-    );
-    element._onNextFixClick(new CustomEvent('click'));
-    assert.equal(element._currentFix!.fix_id, 'fix_2');
-    element._onPrevFixClick(new CustomEvent('click'));
-    assert.equal(element._currentFix!.fix_id, 'fix_1');
+    await open(ROBOT_COMMENT_WITH_TWO_FIXES);
+    element.onNextFixClick(new CustomEvent('click'));
+    assert.equal(element.currentFix!.fix_id, 'fix_2');
+    element.onPrevFixClick(new CustomEvent('click'));
+    assert.equal(element.currentFix!.fix_id, 'fix_1');
   });
 
   test('server-error should throw for failed apply call', async () => {
@@ -337,7 +329,7 @@
       Promise.reject(new Error('backend error'))
     );
     const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
-    element._currentFix = createFixSuggestionInfo('fix_123');
+    element.currentFix = createFixSuggestionInfo('fix_123');
 
     const closeFixPreviewEventSpy = sinon.spy();
     // Element is recreated after each test, removeEventListener isn't required
@@ -347,7 +339,7 @@
     );
 
     let expectedError;
-    await element._handleApplyFix(new CustomEvent('click')).catch(e => {
+    await element.handleApplyFix(new CustomEvent('click')).catch(e => {
       expectedError = e;
     });
     assert.isOk(expectedError);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index 85f8ce0..f4e5835 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -312,9 +312,6 @@
       'create-comment',
       e => this._handleCreateThread(e)
     );
-    this.addEventListener('normalize-range', event =>
-      this._handleNormalizeRange(event)
-    );
     this.addEventListener('diff-context-expanded', event =>
       this._handleDiffContextExpanded(event)
     );
@@ -1215,13 +1212,6 @@
     return true;
   }
 
-  _handleNormalizeRange(event: CustomEvent) {
-    this.reporting.reportInteraction('normalize-range', {
-      side: event.detail.side,
-      lineNum: event.detail.lineNum,
-    });
-  }
-
   _handleDiffContextExpanded(e: CustomEvent<DiffContextExpandedEventDetail>) {
     this.reporting.reportInteraction('diff-context-expanded', {
       numLines: e.detail.numLines,
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index eeb636f..39fc048 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -180,8 +180,8 @@
       <span class="patchRange" aria-label="patch range starts with">
         <gr-dropdown-list
           id="basePatchDropdown"
-          .value="${convertToString(this.basePatchNum)}"
-          .items="${this.computeBaseDropdownContent()}"
+          .value=${convertToString(this.basePatchNum)}
+          .items=${this.computeBaseDropdownContent()}
           @value-change=${this.handlePatchChange}
         >
         </gr-dropdown-list>
@@ -191,8 +191,8 @@
       <span class="patchRange" aria-label="patch range ends with">
         <gr-dropdown-list
           id="patchNumDropdown"
-          .value="${convertToString(this.patchNum)}"
-          .items="${this.computePatchDropdownContent()}"
+          .value=${convertToString(this.patchNum)}
+          .items=${this.computePatchDropdownContent()}
           @value-change=${this.handlePatchChange}
         >
         </gr-dropdown-list>
@@ -206,7 +206,7 @@
     return html`<span class="filesWeblinks">
       ${fileLinks.map(
         weblink => html`
-          <a target="_blank" rel="noopener" href="${ifDefined(weblink.url)}">
+          <a target="_blank" rel="noopener" href=${ifDefined(weblink.url)}>
             ${weblink.name}
           </a>
         `
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
index e2acdbf..281b7e54 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
@@ -70,7 +70,7 @@
             <td>Loading...</td>
           </tr>
         </tbody>
-        <tbody class="${this.loading ? 'loading' : ''}">
+        <tbody class=${this.loading ? 'loading' : ''}>
           ${this.documentationSearches?.map(search =>
             this.renderDocumentationList(search)
           )}
@@ -83,7 +83,7 @@
     return html`
       <tr class="table">
         <td class="name">
-          <a href="${this.computeSearchUrl(search.url)}">${search.title}</a>
+          <a href=${this.computeSearchUrl(search.url)}>${search.title}</a>
         </td>
         <td></td>
         <td></td>
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
index 5312be2..8b8a615 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
@@ -61,7 +61,7 @@
   override render() {
     return html` <textarea
       id="textarea"
-      .value="${this.fileContent}"
+      .value=${this.fileContent}
       @input=${this._handleTextareaInput}
     ></textarea>`;
   }
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
index ae81f07..f081037 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -155,8 +155,8 @@
   private renderAction(action: GrEditAction) {
     return html`
       <gr-button
-        id="${action.id}"
-        class="${this.computeIsInvisible(action.id)}"
+        id=${action.id}
+        class=${this.computeIsInvisible(action.id)}
         link=""
         @click=${this.handleTap}
         >${action.label}</gr-button
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
index 1b854b4..a770d5b 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
@@ -82,7 +82,7 @@
         .items=${fileActions}
         down-arrow=""
         vertical-offset="20"
-        @tap-item="${this._handleActionTap}"
+        @tap-item=${this._handleActionTap}
         link=""
         >Actions</gr-dropdown
       >`;
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index 40515d9..dc8e7a6 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -218,7 +218,7 @@
             <span>Edit mode</span>
             <span class="separator"></span>
             <gr-editable-label
-              label-text="File path"
+              labelText="File path"
               .value=${this.path}
               placeholder="File path..."
               @changed=${this.handlePathChanged}
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
index 6b8c4f0..1ca9918 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
@@ -1,20 +1,8 @@
 /**
  * @license
- * Copyright (C) 2017 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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {PluginApi} from '../../../api/plugin';
 import {HookApi, HookCallback, PluginElement} from '../../../api/hook';
 
@@ -81,20 +69,17 @@
   }
 
   _createPlaceholder(hookName: string) {
-    class HookPlaceholder extends PolymerElement {
-      static get is() {
-        return hookName;
-      }
+    /**
+     * See gr-endpoint-decorator.ts for how hooks are instantiated and
+     * initialized.
+     */
+    class HookPlaceholder extends HTMLElement {
+      plugin?: PluginApi;
 
-      static get properties() {
-        return {
-          plugin: Object,
-          content: Object,
-        };
-      }
+      content?: Element | null;
     }
 
-    customElements.define(HookPlaceholder.is, HookPlaceholder);
+    customElements.define(hookName, HookPlaceholder);
   }
 
   handleInstanceDetached(instance: T) {
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.ts b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.ts
index fde699d..6001622 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.ts
@@ -1,20 +1,8 @@
 /**
  * @license
- * Copyright (C) 2017 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.
+ * Copyright 2017 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import {HookApi, PluginElement} from '../../../api/hook';
 import {PluginApi} from '../../../api/plugin';
 import '../../../test/common-test-setup-karma';
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
index 2db7c76..96b8d12 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
@@ -62,7 +62,7 @@
     return html`
       <tr>
         <td class="nameColumn">
-          <a href="${this.getUrlBase(agreement?.url)}" rel="external">
+          <a href=${this.getUrlBase(agreement?.url)} rel="external">
             ${agreement.name}
           </a>
         </td>
@@ -86,7 +86,7 @@
           )}
         </tbody>
       </table>
-      <a href="${this.getUrl()}">New Contributor Agreement</a>
+      <a href=${this.getUrl()}>New Contributor Agreement</a>
     </div>`;
   }
 
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
index eff22d1..34e376b 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
@@ -15,27 +15,24 @@
  * limitations under the License.
  */
 import '../../shared/gr-button/gr-button';
-import '../../../styles/shared-styles';
-import '../../../styles/gr-form-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-change-table-editor_html';
-import {customElement, property, observe} from '@polymer/decorators';
 import {ServerInfo} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
 import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
 import {KnownExperimentId} from '../../../services/flags/flags';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {PropertyValues} from 'lit';
+import {fire} from '../../../utils/event-util';
+import {ValueChangedEvent} from '../../../types/events';
 
 @customElement('gr-change-table-editor')
-export class GrChangeTableEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Array, notify: true})
+export class GrChangeTableEditor extends LitElement {
+  @property({type: Array})
   displayedColumns: string[] = [];
 
-  @property({type: Boolean, notify: true})
+  @property({type: Boolean})
   showNumber?: boolean;
 
   @property({type: Object})
@@ -46,22 +43,97 @@
 
   private readonly flagsService = getAppContext().flagsService;
 
-  @observe('serverConfig')
-  _configChanged(config: ServerInfo) {
-    this.defaultColumns = columnNames.filter(col =>
-      this._isColumnEnabled(col, config)
+  static override styles = [
+    sharedStyles,
+    formStyles,
+    css`
+      #changeCols {
+        width: auto;
+      }
+      #changeCols .visibleHeader {
+        text-align: center;
+      }
+      .checkboxContainer {
+        cursor: pointer;
+        text-align: center;
+      }
+      .checkboxContainer input {
+        cursor: pointer;
+      }
+      .checkboxContainer:hover {
+        outline: 1px solid var(--border-color);
+      }
+    `,
+  ];
+
+  override render() {
+    return html`<div class="gr-form-styles">
+      <table id="changeCols">
+        <thead>
+          <tr>
+            <th class="nameHeader">Column</th>
+            <th class="visibleHeader">Visible</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr>
+            <td><label for="numberCheckbox">Number</label></td>
+            <td
+              class="checkboxContainer"
+              @click=${this.handleCheckboxContainerClick}
+            >
+              <input
+                id="numberCheckbox"
+                type="checkbox"
+                name="number"
+                @click=${this.handleNumberCheckboxClick}
+                ?checked=${this.showNumber}
+              />
+            </td>
+          </tr>
+          ${this.defaultColumns.map(column => this.renderRow(column))}
+        </tbody>
+      </table>
+    </div>`;
+  }
+
+  renderRow(column: string) {
+    return html`<tr>
+      <td><label for=${column}>${column}</label></td>
+      <td class="checkboxContainer" @click=${this.handleCheckboxContainerClick}>
+        <input
+          id=${column}
+          type="checkbox"
+          name=${column}
+          @click=${this.handleTargetClick}
+          ?checked=${!this.computeIsColumnHidden(column)}
+        />
+      </td>
+    </tr>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('serverConfig')) {
+      this.configChanged();
+    }
+  }
+
+  private configChanged() {
+    this.defaultColumns = columnNames.filter(column =>
+      this.isColumnEnabled(column)
     );
     if (!this.displayedColumns) return;
     this.displayedColumns = this.displayedColumns.filter(column =>
-      this._isColumnEnabled(column, config)
+      this.isColumnEnabled(column)
     );
   }
 
   /**
    * Is the column disabled by a server config or experiment?
+   * private but used in test
    */
-  _isColumnEnabled(column: string, config: ServerInfo) {
-    if (!config || !config.change) return true;
+  isColumnEnabled(column: string) {
+    if (!this.serverConfig?.change) return true;
     if (column === 'Comments')
       return this.flagsService.isEnabled('comments-column');
     if (column === 'Status')
@@ -78,11 +150,12 @@
   /**
    * Get the list of enabled column names from whichever checkboxes are
    * checked (excluding the number checkbox).
+   * private but used in test
    */
-  _getDisplayedColumns() {
-    if (this.root === null) return [];
+  getDisplayedColumns() {
+    if (this.shadowRoot === null) return [];
     return Array.from(
-      this.root.querySelectorAll<HTMLInputElement>(
+      this.shadowRoot.querySelectorAll<HTMLInputElement>(
         '.checkboxContainer input:not([name=number])'
       )
     )
@@ -90,18 +163,18 @@
       .map(checkbox => checkbox.name);
   }
 
-  _computeIsColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]) {
-    if (!columnsToDisplay || !columnToCheck) {
+  private computeIsColumnHidden(columnToCheck?: string) {
+    if (!this.displayedColumns || !columnToCheck) {
       return false;
     }
-    return !columnsToDisplay.includes(columnToCheck);
+    return !this.displayedColumns.includes(columnToCheck);
   }
 
   /**
    * Handle a click on a checkbox container and relay the click to the checkbox it
    * contains.
    */
-  _handleCheckboxContainerClick(e: MouseEvent) {
+  private handleCheckboxContainerClick(e: MouseEvent) {
     if (e.target === null) return;
     const checkbox = (e.target as HTMLElement).querySelector('input');
     if (!checkbox) {
@@ -114,22 +187,26 @@
    * Handle a click on the number checkbox and update the showNumber property
    * accordingly.
    */
-  _handleNumberCheckboxClick(e: MouseEvent) {
-    this.showNumber = (
-      (dom(e) as EventApi).rootTarget as HTMLInputElement
-    ).checked;
+  private handleNumberCheckboxClick(e: MouseEvent) {
+    this.showNumber = (e.target as HTMLInputElement).checked;
+    fire(this, 'show-number-changed', {value: this.showNumber});
   }
 
   /**
    * Handle a click on a displayed column checkboxes (excluding number) and
    * update the displayedColumns property accordingly.
    */
-  _handleTargetClick() {
-    this.set('displayedColumns', this._getDisplayedColumns());
+  private handleTargetClick() {
+    this.displayedColumns = this.getDisplayedColumns();
+    fire(this, 'displayed-columns-changed', {value: this.displayedColumns});
   }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'show-number-changed': ValueChangedEvent<boolean>;
+    'displayed-columns-changed': ValueChangedEvent<string[]>;
+  }
   interface HTMLElementTagNameMap {
     'gr-change-table-editor': GrChangeTableEditor;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts
deleted file mode 100644
index e756a20..0000000
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    #changeCols {
-      width: auto;
-    }
-    #changeCols .visibleHeader {
-      text-align: center;
-    }
-    .checkboxContainer {
-      cursor: pointer;
-      text-align: center;
-    }
-    .checkboxContainer input {
-      cursor: pointer;
-    }
-    .checkboxContainer:hover {
-      outline: 1px solid var(--border-color);
-    }
-  </style>
-  <div class="gr-form-styles">
-    <table id="changeCols">
-      <thead>
-        <tr>
-          <th class="nameHeader">Column</th>
-          <th class="visibleHeader">Visible</th>
-        </tr>
-      </thead>
-      <tbody>
-        <tr>
-          <td><label for="numberCheckbox">Number</label></td>
-          <td
-            class="checkboxContainer"
-            on-click="_handleCheckboxContainerClick"
-          >
-            <input
-              id="numberCheckbox"
-              type="checkbox"
-              name="number"
-              on-click="_handleNumberCheckboxClick"
-              checked$="[[showNumber]]"
-            />
-          </td>
-        </tr>
-        <template is="dom-repeat" items="[[defaultColumns]]">
-          <tr>
-            <td><label for$="[[item]]">[[item]]</label></td>
-            <td
-              class="checkboxContainer"
-              on-click="_handleCheckboxContainerClick"
-            >
-              <input
-                id$="[[item]]"
-                type="checkbox"
-                name="[[item]]"
-                on-click="_handleTargetClick"
-                checked$="[[!_computeIsColumnHidden(item, displayedColumns)]]"
-              />
-            </td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
index c2bcec2..c37c3f9 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
@@ -14,21 +14,21 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import '../../../test/common-test-setup-karma';
 import './gr-change-table-editor';
 import {GrChangeTableEditor} from './gr-change-table-editor';
 import {queryAndAssert} from '../../../test/test-utils';
 import {createServerInfo} from '../../../test/test-data-generators';
-
-const basicFixture = fixtureFromElement('gr-change-table-editor');
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-change-table-editor tests', () => {
   let element: GrChangeTableEditor;
   let columns: string[];
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture<GrChangeTableEditor>(
+      html`<gr-change-table-editor></gr-change-table-editor>`
+    );
 
     columns = [
       'Subject',
@@ -41,10 +41,84 @@
       'Updated',
     ];
 
-    element.set('displayedColumns', columns);
+    element.displayedColumns = columns;
     element.showNumber = false;
     element.serverConfig = createServerInfo();
-    await flush();
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ ` <div class="gr-form-styles">
+      <table id="changeCols">
+        <thead>
+          <tr>
+            <th class="nameHeader">Column</th>
+            <th class="visibleHeader">Visible</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr>
+            <td><label for="numberCheckbox"> Number </label></td>
+            <td class="checkboxContainer">
+              <input id="numberCheckbox" name="number" type="checkbox" />
+            </td>
+          </tr>
+          <tr>
+            <td><label for="Subject"> Subject </label></td>
+            <td class="checkboxContainer">
+              <input checked="" id="Subject" name="Subject" type="checkbox" />
+            </td>
+          </tr>
+          <tr>
+            <td><label for="Status"> Status </label></td>
+            <td class="checkboxContainer">
+              <input checked="" id="Status" name="Status" type="checkbox" />
+            </td>
+          </tr>
+          <tr>
+            <td><label for="Owner"> Owner </label></td>
+            <td class="checkboxContainer">
+              <input checked="" id="Owner" name="Owner" type="checkbox" />
+            </td>
+          </tr>
+          <tr>
+            <td><label for="Reviewers"> Reviewers </label></td>
+            <td class="checkboxContainer">
+              <input
+                checked=""
+                id="Reviewers"
+                name="Reviewers"
+                type="checkbox"
+              />
+            </td>
+          </tr>
+          <tr>
+            <td><label for="Repo"> Repo </label></td>
+            <td class="checkboxContainer">
+              <input checked="" id="Repo" name="Repo" type="checkbox" />
+            </td>
+          </tr>
+          <tr>
+            <td><label for="Branch"> Branch </label></td>
+            <td class="checkboxContainer">
+              <input checked="" id="Branch" name="Branch" type="checkbox" />
+            </td>
+          </tr>
+          <tr>
+            <td><label for="Updated"> Updated </label></td>
+            <td class="checkboxContainer">
+              <input checked="" id="Updated" name="Updated" type="checkbox" />
+            </td>
+          </tr>
+          <tr>
+            <td><label for="Size"> Size </label></td>
+            <td class="checkboxContainer">
+              <input id="Size" name="Size" type="checkbox" />
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>`);
   });
 
   test('renders', () => {
@@ -60,7 +134,7 @@
     }
   });
 
-  test('hide item', () => {
+  test('hide item', async () => {
     const checkbox = queryAndAssert<HTMLInputElement>(
       element,
       'table tr:nth-child(2) input'
@@ -69,23 +143,17 @@
     const displayedLength = element.displayedColumns.length;
     assert.isTrue(isChecked);
 
-    MockInteractions.tap(checkbox);
-    flush();
+    checkbox.click();
+    await element.updateComplete;
 
     assert.equal(element.displayedColumns.length, displayedLength - 1);
   });
 
-  test('show item', () => {
-    element.set('displayedColumns', [
-      'Status',
-      'Owner',
-      'Repo',
-      'Branch',
-      'Updated',
-    ]);
+  test('show item', async () => {
+    element.displayedColumns = ['Status', 'Owner', 'Repo', 'Branch', 'Updated'];
     // trigger computation of enabled displayed columns
     element.serverConfig = createServerInfo();
-    flush();
+    await element.updateComplete;
     const checkbox = queryAndAssert<HTMLInputElement>(
       element,
       'table tr:nth-child(2) input'
@@ -96,74 +164,68 @@
     const table = queryAndAssert<HTMLTableElement>(element, 'table');
     assert.equal(table.style.display, '');
 
-    MockInteractions.tap(checkbox);
-    flush();
+    checkbox.click();
+    await element.updateComplete;
 
     assert.equal(element.displayedColumns.length, displayedLength + 1);
   });
 
-  test('_getDisplayedColumns', () => {
+  test('getDisplayedColumns', () => {
     const enabledColumns = columns.filter(column =>
-      element._isColumnEnabled(column, element.serverConfig!)
+      element.isColumnEnabled(column)
     );
-    assert.deepEqual(element._getDisplayedColumns(), enabledColumns);
+    assert.deepEqual(element.getDisplayedColumns(), enabledColumns);
     const input = queryAndAssert<HTMLInputElement>(
       element,
       '.checkboxContainer input[name=Subject]'
     );
-    MockInteractions.tap(input);
+    input.click();
     assert.deepEqual(
-      element._getDisplayedColumns(),
+      element.getDisplayedColumns(),
       enabledColumns.filter(c => c !== 'Subject')
     );
   });
 
-  test('_handleCheckboxContainerClick relays taps to checkboxes', () => {
-    const checkBoxClickStub = sinon.stub(element, '_handleNumberCheckboxClick');
-    const targetClickStub = sinon.stub(element, '_handleTargetClick');
-
-    const firstContainer = queryAndAssert(
+  test('handleCheckboxContainerClick relays taps to checkboxes', async () => {
+    const firstContainer = queryAndAssert<HTMLTableRowElement>(
       element,
       'table tr:first-of-type .checkboxContainer'
     );
-    MockInteractions.tap(firstContainer);
-    assert.isTrue(checkBoxClickStub.calledOnce);
-    assert.isFalse(targetClickStub.called);
+    assert.isFalse(element.showNumber);
+    firstContainer.click();
+    assert.isTrue(element.showNumber);
 
-    const lastContainer = queryAndAssert(
+    const lastContainer = queryAndAssert<HTMLTableRowElement>(
       element,
       'table tr:last-of-type .checkboxContainer'
     );
-    MockInteractions.tap(lastContainer);
-    assert.isTrue(checkBoxClickStub.calledOnce);
-    assert.isTrue(targetClickStub.calledOnce);
+    const lastColumn =
+      element.defaultColumns[element.defaultColumns.length - 1];
+    assert.notInclude(element.displayedColumns, lastColumn);
+    lastContainer.click();
+    await element.updateComplete;
+    assert.include(element.displayedColumns, lastColumn);
   });
 
-  test('_handleNumberCheckboxClick', () => {
-    const checkBoxClickSpy = sinon.spy(element, '_handleNumberCheckboxClick');
-
-    const numberInput = queryAndAssert(
+  test('handleNumberCheckboxClick', () => {
+    const numberInput = queryAndAssert<HTMLInputElement>(
       element,
       '.checkboxContainer input[name=number]'
     );
-    MockInteractions.tap(numberInput);
-    assert.isTrue(checkBoxClickSpy.calledOnce);
+    numberInput.click();
     assert.isTrue(element.showNumber);
 
-    MockInteractions.tap(numberInput);
-    assert.isTrue(checkBoxClickSpy.calledTwice);
+    numberInput.click();
     assert.isFalse(element.showNumber);
   });
 
-  test('_handleTargetClick', () => {
-    const targetClickSpy = sinon.spy(element, '_handleTargetClick');
+  test('handleTargetClick', () => {
     assert.include(element.displayedColumns, 'Subject');
-    const subjectInput = queryAndAssert(
+    const subjectInput = queryAndAssert<HTMLInputElement>(
       element,
       '.checkboxContainer input[name=Subject]'
     );
-    MockInteractions.tap(subjectInput);
-    assert.isTrue(targetClickSpy.calledOnce);
+    subjectInput.click();
     assert.notInclude(element.displayedColumns, 'Subject');
   });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
index db8a008..f429c22 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
@@ -132,8 +132,8 @@
           id="claNewAgreementsInput${item.name}"
           name="claNewAgreementsRadio"
           type="radio"
-          data-name="${ifDefined(item.name)}"
-          data-url="${ifDefined(item.url)}"
+          data-name=${ifDefined(item.name)}
+          data-url=${ifDefined(item.url)}
           @click=${this.handleShowAgreement}
           ?disabled=${this.disableAgreements(item)}
         />
@@ -159,7 +159,7 @@
         <h3 class="heading-3">Review the agreement:</h3>
         <div id="agreementsUrl" class="agreementsUrl">
           <a
-            href="${ifDefined(this.agreementsUrl)}"
+            href=${ifDefined(this.agreementsUrl)}
             target="blank"
             rel="noopener"
           >
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
index c18ea78..0f7e065 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
@@ -92,7 +92,7 @@
     return html`
       <h2
         id="EditPreferences"
-        class="${this.hasUnsavedChanges() ? 'edited' : ''}"
+        class=${this.hasUnsavedChanges() ? 'edited' : ''}
       >
         Edit Preferences
       </h2>
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
index afd04b4..40c0690 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
@@ -16,73 +16,147 @@
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
-import '../../../styles/shared-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-email-editor_html';
-import {customElement, property} from '@polymer/decorators';
 import {EmailInfo} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {ValueChangedEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
 
 @customElement('gr-email-editor')
-export class GrEmailEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrEmailEditor extends LitElement {
+  @property({type: Boolean}) hasUnsavedChanges = false;
 
-  @property({type: Boolean, notify: true})
-  hasUnsavedChanges = false;
+  /* private but used in test */
+  @state() emails: EmailInfo[] = [];
 
-  @property({type: Array})
-  _emails: EmailInfo[] = [];
+  /* private but used in test */
+  @state() emailsToRemove: EmailInfo[] = [];
 
-  @property({type: Array})
-  _emailsToRemove: EmailInfo[] = [];
-
-  @property({type: String})
-  _newPreferred: string | null = null;
+  /* private but used in test */
+  @state() newPreferred = '';
 
   readonly restApiService = getAppContext().restApiService;
 
+  static override styles = [
+    sharedStyles,
+    formStyles,
+    css`
+      th {
+        color: var(--deemphasized-text-color);
+        text-align: left;
+      }
+      #emailTable .emailColumn {
+        min-width: 32.5em;
+        width: auto;
+      }
+      #emailTable .preferredHeader {
+        text-align: center;
+        width: 6em;
+      }
+      #emailTable .preferredControl {
+        cursor: pointer;
+        height: auto;
+        text-align: center;
+      }
+      #emailTable .preferredControl .preferredRadio {
+        height: auto;
+      }
+      .preferredControl:hover {
+        outline: 1px solid var(--border-color);
+      }
+    `,
+  ];
+
+  override render() {
+    return html`<div class="gr-form-styles">
+      <table id="emailTable">
+        <thead>
+          <tr>
+            <th class="emailColumn">Email</th>
+            <th class="preferredHeader">Preferred</th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          ${this.emails.map((email, index) => this.renderEmail(email, index))}
+        </tbody>
+      </table>
+    </div>`;
+  }
+
+  private renderEmail(email: EmailInfo, index: number) {
+    return html`<tr>
+      <td class="emailColumn">${email.email}</td>
+      <td class="preferredControl" @click=${this.handlePreferredControlClick}>
+        <iron-input
+          class="preferredRadio"
+          @change=${this.handlePreferredChange}
+          .bindValue=${email.email}
+        >
+          <input
+            class="preferredRadio"
+            type="radio"
+            @change=${this.handlePreferredChange}
+            name="preferred"
+            ?checked=${email.preferred}
+          />
+        </iron-input>
+      </td>
+      <td>
+        <gr-button
+          data-index=${index}
+          @click=${this.handleDeleteButton}
+          ?disabled=${this.checkPreferred(email.preferred)}
+          class="remove-button"
+          >Delete</gr-button
+        >
+      </td>
+    </tr>`;
+  }
+
   loadData() {
     return this.restApiService.getAccountEmails().then(emails => {
-      this._emails = emails ?? [];
+      this.emails = emails ?? [];
     });
   }
 
   save() {
     const promises: Promise<unknown>[] = [];
 
-    for (const emailObj of this._emailsToRemove) {
+    for (const emailObj of this.emailsToRemove) {
       promises.push(this.restApiService.deleteAccountEmail(emailObj.email));
     }
 
-    if (this._newPreferred) {
+    if (this.newPreferred) {
       promises.push(
-        this.restApiService.setPreferredAccountEmail(this._newPreferred)
+        this.restApiService.setPreferredAccountEmail(this.newPreferred)
       );
     }
 
     return Promise.all(promises).then(() => {
-      this._emailsToRemove = [];
-      this._newPreferred = null;
-      this.hasUnsavedChanges = false;
+      this.emailsToRemove = [];
+      this.newPreferred = '';
+      this.setHasUnsavedChanges(false);
     });
   }
 
-  _handleDeleteButton(e: Event) {
-    const target = (dom(e) as EventApi).localTarget;
+  private handleDeleteButton(e: Event) {
+    const target = e.target;
     if (!(target instanceof Element)) return;
     const indexStr = target.getAttribute('data-index');
     if (indexStr === null) return;
     const index = Number(indexStr);
-    const email = this._emails[index];
-    this.push('_emailsToRemove', email);
-    this.splice('_emails', index, 1);
-    this.hasUnsavedChanges = true;
+    const email = this.emails[index];
+    this.emailsToRemove = [...this.emailsToRemove, email];
+    this.emails.splice(index, 1);
+    this.requestUpdate();
+    this.setHasUnsavedChanges(true);
   }
 
-  _handlePreferredControlClick(e: Event) {
+  private handlePreferredControlClick(e: Event) {
     if (
       e.target instanceof HTMLElement &&
       e.target.classList.contains('preferredControl') &&
@@ -92,26 +166,36 @@
     }
   }
 
-  _handlePreferredChange(e: Event) {
+  private handlePreferredChange(e: Event) {
     if (!(e.target instanceof HTMLInputElement)) return;
     const preferred = e.target.value;
-    for (let i = 0; i < this._emails.length; i++) {
-      if (preferred === this._emails[i].email) {
-        this.set(['_emails', i, 'preferred'], true);
-        this._newPreferred = preferred;
-        this.hasUnsavedChanges = true;
-      } else if (this._emails[i].preferred) {
-        this.set(['_emails', i, 'preferred'], false);
+    for (let i = 0; i < this.emails.length; i++) {
+      if (preferred === this.emails[i].email) {
+        this.emails[i].preferred = true;
+        this.requestUpdate();
+        this.newPreferred = preferred;
+        this.setHasUnsavedChanges(true);
+      } else if (this.emails[i].preferred) {
+        this.emails[i].preferred = false;
+        this.requestUpdate();
       }
     }
   }
 
-  _checkPreferred(preferred?: boolean) {
+  private checkPreferred(preferred?: boolean) {
     return preferred ?? false;
   }
+
+  private setHasUnsavedChanges(value: boolean) {
+    this.hasUnsavedChanges = value;
+    fire(this, 'has-unsaved-changes-changed', {value});
+  }
 }
 
 declare global {
+  interface HTMLElementEventMap {
+    'has-unsaved-changes-changed': ValueChangedEvent<boolean>;
+  }
   interface HTMLElementTagNameMap {
     'gr-email-editor': GrEmailEditor;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
deleted file mode 100644
index 666afb7..0000000
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_html.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    th {
-      color: var(--deemphasized-text-color);
-      text-align: left;
-    }
-    #emailTable .emailColumn {
-      min-width: 32.5em;
-      width: auto;
-    }
-    #emailTable .preferredHeader {
-      text-align: center;
-      width: 6em;
-    }
-    #emailTable .preferredControl {
-      cursor: pointer;
-      height: auto;
-      text-align: center;
-    }
-    #emailTable .preferredControl .preferredRadio {
-      height: auto;
-    }
-    .preferredControl:hover {
-      outline: 1px solid var(--border-color);
-    }
-  </style>
-  <div class="gr-form-styles">
-    <table id="emailTable">
-      <thead>
-        <tr>
-          <th class="emailColumn">Email</th>
-          <th class="preferredHeader">Preferred</th>
-          <th></th>
-        </tr>
-      </thead>
-      <tbody>
-        <template is="dom-repeat" items="[[_emails]]">
-          <tr>
-            <td class="emailColumn">[[item.email]]</td>
-            <td
-              class="preferredControl"
-              on-click="_handlePreferredControlClick"
-            >
-              <iron-input
-                class="preferredRadio"
-                type="radio"
-                on-change="_handlePreferredChange"
-                name="preferred"
-                bind-value="[[item.email]]"
-                checked$="[[item.preferred]]"
-              >
-                <input
-                  class="preferredRadio"
-                  type="radio"
-                  on-change="_handlePreferredChange"
-                  name="preferred"
-                  checked$="[[item.preferred]]"
-                />
-              </iron-input>
-            </td>
-            <td>
-              <gr-button
-                data-index$="[[index]]"
-                on-click="_handleDeleteButton"
-                disabled="[[_checkPreferred(item.preferred)]]"
-                class="remove-button"
-                >Delete</gr-button
-              >
-            </td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
index e478381..8ac101d 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
@@ -19,8 +19,7 @@
 import './gr-email-editor';
 import {GrEmailEditor} from './gr-email-editor';
 import {spyRestApi, stubRestApi} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromElement('gr-email-editor');
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-email-editor tests', () => {
   let element: GrEmailEditor;
@@ -34,10 +33,102 @@
 
     stubRestApi('getAccountEmails').returns(Promise.resolve(emails));
 
-    element = basicFixture.instantiate();
+    element = await fixture<GrEmailEditor>(
+      html`<gr-email-editor></gr-email-editor>`
+    );
 
     await element.loadData();
-    await flush();
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `<div class="gr-form-styles">
+      <table id="emailTable">
+        <thead>
+          <tr>
+            <th class="emailColumn">Email</th>
+            <th class="preferredHeader">Preferred</th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr>
+            <td class="emailColumn">email@one.com</td>
+            <td class="preferredControl">
+              <iron-input class="preferredRadio">
+                <input
+                  class="preferredRadio"
+                  name="preferred"
+                  type="radio"
+                  value="email@one.com"
+                />
+              </iron-input>
+            </td>
+            <td>
+              <gr-button
+                aria-disabled="false"
+                class="remove-button"
+                data-index="0"
+                role="button"
+                tabindex="0"
+              >
+                Delete
+              </gr-button>
+            </td>
+          </tr>
+          <tr>
+            <td class="emailColumn">email@two.com</td>
+            <td class="preferredControl">
+              <iron-input class="preferredRadio">
+                <input
+                  checked=""
+                  class="preferredRadio"
+                  name="preferred"
+                  type="radio"
+                  value="email@two.com"
+                />
+              </iron-input>
+            </td>
+            <td>
+              <gr-button
+                aria-disabled="true"
+                class="remove-button"
+                data-index="1"
+                disabled=""
+                role="button"
+                tabindex="-1"
+              >
+                Delete
+              </gr-button>
+            </td>
+          </tr>
+          <tr>
+            <td class="emailColumn">email@three.com</td>
+            <td class="preferredControl">
+              <iron-input class="preferredRadio">
+                <input
+                  class="preferredRadio"
+                  name="preferred"
+                  type="radio"
+                  value="email@three.com"
+                />
+              </iron-input>
+            </td>
+            <td>
+              <gr-button
+                aria-disabled="false"
+                class="remove-button"
+                data-index="2"
+                role="button"
+                tabindex="0"
+              >
+                Delete
+              </gr-button>
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>`);
   });
 
   test('renders', () => {
@@ -66,28 +157,27 @@
   });
 
   test('edit preferred', () => {
-    const preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
     const radios = element
       .shadowRoot!.querySelector('table')!
       .querySelectorAll<HTMLInputElement>('input[type=radio]');
 
     assert.isFalse(element.hasUnsavedChanges);
-    assert.isNotOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 0);
-    assert.equal(element._emails.length, 3);
+    assert.isNotOk(element.newPreferred);
+    assert.equal(element.emailsToRemove.length, 0);
+    assert.equal(element.emails.length, 3);
     assert.isNotOk(radios[0].checked);
     assert.isOk(radios[1].checked);
-    assert.isFalse(preferredChangedSpy.called);
+    assert.isUndefined(element.emails[0].preferred);
 
     radios[0].click();
 
     assert.isTrue(element.hasUnsavedChanges);
-    assert.isOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 0);
-    assert.equal(element._emails.length, 3);
+    assert.isOk(element.newPreferred);
+    assert.equal(element.emailsToRemove.length, 0);
+    assert.equal(element.emails.length, 3);
     assert.isOk(radios[0].checked);
     assert.isNotOk(radios[1].checked);
-    assert.isTrue(preferredChangedSpy.called);
+    assert.isTrue(element.emails[0].preferred);
   });
 
   test('delete email', () => {
@@ -96,18 +186,18 @@
       .querySelectorAll('gr-button');
 
     assert.isFalse(element.hasUnsavedChanges);
-    assert.isNotOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 0);
-    assert.equal(element._emails.length, 3);
+    assert.isNotOk(element.newPreferred);
+    assert.equal(element.emailsToRemove.length, 0);
+    assert.equal(element.emails.length, 3);
 
     buttons[2].click();
 
     assert.isTrue(element.hasUnsavedChanges);
-    assert.isNotOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 1);
-    assert.equal(element._emails.length, 2);
+    assert.isNotOk(element.newPreferred);
+    assert.equal(element.emailsToRemove.length, 1);
+    assert.equal(element.emails.length, 2);
 
-    assert.equal(element._emailsToRemove[0].email, 'email@three.com');
+    assert.equal(element.emailsToRemove[0].email, 'email@three.com');
   });
 
   test('save changes', async () => {
@@ -119,19 +209,19 @@
       .querySelectorAll('tbody tr');
 
     assert.isFalse(element.hasUnsavedChanges);
-    assert.isNotOk(element._newPreferred);
-    assert.equal(element._emailsToRemove.length, 0);
-    assert.equal(element._emails.length, 3);
+    assert.isNotOk(element.newPreferred);
+    assert.equal(element.emailsToRemove.length, 0);
+    assert.equal(element.emails.length, 3);
 
     // Delete the first email and set the last as preferred.
     rows[0].querySelector('gr-button')!.click();
     rows[2].querySelector<HTMLInputElement>('input[type=radio]')!.click();
 
     assert.isTrue(element.hasUnsavedChanges);
-    assert.equal(element._newPreferred, 'email@three.com');
-    assert.equal(element._emailsToRemove.length, 1);
-    assert.equal(element._emailsToRemove[0].email, 'email@one.com');
-    assert.equal(element._emails.length, 2);
+    assert.equal(element.newPreferred, 'email@three.com');
+    assert.equal(element.emailsToRemove.length, 1);
+    assert.equal(element.emailsToRemove[0].email, 'email@one.com');
+    assert.equal(element.emails.length, 2);
 
     await element.save();
     assert.equal(deleteEmailSpy.callCount, 1);
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
index ed07fd6..3c54c59 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
@@ -19,24 +19,18 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../../styles/shared-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-gpg-editor_html';
-import {customElement, property} from '@polymer/decorators';
 import {GpgKeyInfo, GpgKeyId} from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
 import {getAppContext} from '../../../services/app-context';
-
-export interface GrGpgEditor {
-  $: {
-    viewKeyOverlay: GrOverlay;
-    addButton: GrButton;
-    newKey: IronAutogrowTextareaElement;
-  };
-}
+import {css, html, LitElement} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {assertIsDefined} from '../../../utils/common-util';
+import {BindValueChangeEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -44,35 +38,157 @@
   }
 }
 @customElement('gr-gpg-editor')
-export class GrGpgEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrGpgEditor extends LitElement {
+  @query('#viewKeyOverlay') viewKeyOverlay?: GrOverlay;
 
-  @property({type: Boolean, notify: true})
+  @query('#addButton') addButton?: GrButton;
+
+  @query('#newKey') newKeyTextarea?: IronAutogrowTextareaElement;
+
+  @property({type: Boolean})
   hasUnsavedChanges = false;
 
-  @property({type: Array})
-  _keys: GpgKeyInfo[] = [];
+  // private but used in test
+  @state() keys: GpgKeyInfo[] = [];
 
-  @property({type: Object})
-  _keyToView?: GpgKeyInfo;
+  // private but used in test
+  @state() keyToView?: GpgKeyInfo;
 
-  @property({type: String})
-  _newKey = '';
+  // private but used in test
+  @state() newKey = '';
 
-  @property({type: Array})
-  _keysToRemove: GpgKeyInfo[] = [];
+  // private but used in test
+  @state() keysToRemove: GpgKeyInfo[] = [];
 
   private readonly restApiService = getAppContext().restApiService;
 
+  static override styles = [
+    sharedStyles,
+    formStyles,
+    css`
+      .keyHeader {
+        width: 9em;
+      }
+      .userIdHeader {
+        width: 15em;
+      }
+      #viewKeyOverlay {
+        padding: var(--spacing-xxl);
+        width: 50em;
+      }
+      .closeButton {
+        bottom: 2em;
+        position: absolute;
+        right: 2em;
+      }
+      #existing {
+        margin-bottom: var(--spacing-l);
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <div class="gr-form-styles">
+        <fieldset id="existing">
+          <table>
+            <thead>
+              <tr>
+                <th class="idColumn">ID</th>
+                <th class="fingerPrintColumn">Fingerprint</th>
+                <th class="userIdHeader">User IDs</th>
+                <th class="keyHeader">Public Key</th>
+                <th></th>
+                <th></th>
+              </tr>
+            </thead>
+            <tbody>
+              ${this.keys.map((key, index) => this.renderKey(key, index))}
+            </tbody>
+          </table>
+          <gr-overlay id="viewKeyOverlay" with-backdrop="">
+            <fieldset>
+              <section>
+                <span class="title">Status</span>
+                <span class="value">${this.keyToView?.status}</span>
+              </section>
+              <section>
+                <span class="title">Key</span>
+                <span class="value">${this.keyToView?.key}</span>
+              </section>
+            </fieldset>
+            <gr-button
+              class="closeButton"
+              @click=${() => {
+                this.viewKeyOverlay?.close();
+              }}
+              >Close</gr-button
+            >
+          </gr-overlay>
+          <gr-button @click=${this.save} ?disabled=${!this.hasUnsavedChanges}
+            >Save changes</gr-button
+          >
+        </fieldset>
+        <fieldset>
+          <section>
+            <span class="title">New GPG key</span>
+            <span class="value">
+              <iron-autogrow-textarea
+                id="newKey"
+                autocomplete="on"
+                .bindValue=${this.newKey}
+                @bind-value-changed=${(e: BindValueChangeEvent) =>
+                  this.handleNewKeyChanged(e)}
+                placeholder="New GPG Key"
+              ></iron-autogrow-textarea>
+            </span>
+          </section>
+          <gr-button
+            id="addButton"
+            ?disabled=${!this.newKey?.length}
+            @click=${this.handleAddKey}
+            >Add new GPG key</gr-button
+          >
+        </fieldset>
+      </div>
+    `;
+  }
+
+  private renderKey(key: GpgKeyInfo, index: number) {
+    return html`<tr>
+      <td class="idColumn">${key.id}</td>
+      <td class="fingerPrintColumn">${key.fingerprint}</td>
+      <td class="userIdHeader">${key.user_ids?.map(id => html`${id}`)}</td>
+      <td class="keyHeader">
+        <gr-button @click=${() => this.showKey(key)} link=""
+          >Click to View</gr-button
+        >
+      </td>
+      <td>
+        <gr-copy-clipboard
+          hasTooltip
+          buttonTitle="Copy GPG public key to clipboard"
+          hideInput
+          .text=${key.key}
+        >
+        </gr-copy-clipboard>
+      </td>
+      <td>
+        <gr-button @click=${() => this.handleDeleteKey(index)}
+          >Delete</gr-button
+        >
+      </td>
+    </tr>`;
+  }
+
+  // private but used in test
   loadData() {
-    this._keys = [];
+    this.keys = [];
     return this.restApiService.getAccountGPGKeys().then(keys => {
       if (!keys) {
         return;
       }
-      this._keys = Object.keys(keys).map(key => {
+      this.keys = Object.keys(keys).map(key => {
         const gpgKey = keys[key];
         gpgKey.id = key as GpgKeyId;
         return gpgKey;
@@ -80,53 +196,58 @@
     });
   }
 
+  // private but used in test
   save() {
-    const promises = this._keysToRemove.map(key =>
+    const promises = this.keysToRemove.map(key =>
       this.restApiService.deleteAccountGPGKey(key.id!)
     );
 
     return Promise.all(promises).then(() => {
-      this._keysToRemove = [];
-      this.hasUnsavedChanges = false;
+      this.keysToRemove = [];
+      this.setHasUnsavedChanges(false);
     });
   }
 
-  _showKey(e: Event) {
-    const el = (dom(e) as EventApi).localTarget as Element;
-    const index = Number(el.getAttribute('data-index')!);
-    this._keyToView = this._keys[index];
-    this.$.viewKeyOverlay.open();
+  private showKey(key: GpgKeyInfo) {
+    this.keyToView = key;
+    this.viewKeyOverlay?.open();
   }
 
-  _closeOverlay() {
-    this.$.viewKeyOverlay.close();
+  private handleNewKeyChanged(e: BindValueChangeEvent) {
+    this.newKey = e.detail.value;
   }
 
-  _handleDeleteKey(e: Event) {
-    const el = (dom(e) as EventApi).localTarget as Element;
-    const index = Number(el.getAttribute('data-index')!);
-    this.push('_keysToRemove', this._keys[index]);
-    this.splice('_keys', index, 1);
-    this.hasUnsavedChanges = true;
+  private handleDeleteKey(index: number) {
+    this.keysToRemove.push(this.keys[index]);
+    this.keys.splice(index, 1);
+    this.requestUpdate();
+    this.setHasUnsavedChanges(true);
   }
 
-  _handleAddKey() {
-    this.$.addButton.disabled = true;
-    this.$.newKey.disabled = true;
+  // private but used in test
+  handleAddKey() {
+    assertIsDefined(this.newKeyTextarea);
+    assertIsDefined(this.addButton);
+    this.addButton.disabled = true;
+    this.newKeyTextarea.disabled = true;
     return this.restApiService
-      .addAccountGPGKey({add: [this._newKey.trim()]})
+      .addAccountGPGKey({add: [this.newKey.trim()]})
       .then(() => {
-        this.$.newKey.disabled = false;
-        this._newKey = '';
+        assertIsDefined(this.newKeyTextarea);
+        this.newKeyTextarea.disabled = false;
+        this.newKey = '';
         this.loadData();
       })
       .catch(() => {
-        this.$.addButton.disabled = false;
-        this.$.newKey.disabled = false;
+        assertIsDefined(this.newKeyTextarea);
+        assertIsDefined(this.addButton);
+        this.addButton.disabled = false;
+        this.newKeyTextarea.disabled = false;
       });
   }
 
-  _computeAddButtonDisabled(newKey: string) {
-    return !newKey.length;
+  private setHasUnsavedChanges(value: boolean) {
+    this.hasUnsavedChanges = value;
+    fire(this, 'has-unsaved-changes-changed', {value});
   }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
deleted file mode 100644
index f4641c2..0000000
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_html.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    .keyHeader {
-      width: 9em;
-    }
-    .userIdHeader {
-      width: 15em;
-    }
-    #viewKeyOverlay {
-      padding: var(--spacing-xxl);
-      width: 50em;
-    }
-    .closeButton {
-      bottom: 2em;
-      position: absolute;
-      right: 2em;
-    }
-    #existing {
-      margin-bottom: var(--spacing-l);
-    }
-  </style>
-  <div class="gr-form-styles">
-    <fieldset id="existing">
-      <table>
-        <thead>
-          <tr>
-            <th class="idColumn">ID</th>
-            <th class="fingerPrintColumn">Fingerprint</th>
-            <th class="userIdHeader">User IDs</th>
-            <th class="keyHeader">Public Key</th>
-            <th></th>
-            <th></th>
-          </tr>
-        </thead>
-        <tbody>
-          <template is="dom-repeat" items="[[_keys]]" as="key">
-            <tr>
-              <td class="idColumn">[[key.id]]</td>
-              <td class="fingerPrintColumn">[[key.fingerprint]]</td>
-              <td class="userIdHeader">
-                <template is="dom-repeat" items="[[key.user_ids]]">
-                  [[item]]
-                </template>
-              </td>
-              <td class="keyHeader">
-                <gr-button on-click="_showKey" data-index$="[[index]]" link=""
-                  >Click to View</gr-button
-                >
-              </td>
-              <td>
-                <gr-copy-clipboard
-                  hasTooltip=""
-                  buttonTitle="Copy GPG public key to clipboard"
-                  hideInput=""
-                  text="[[key.key]]"
-                >
-                </gr-copy-clipboard>
-              </td>
-              <td>
-                <gr-button data-index$="[[index]]" on-click="_handleDeleteKey"
-                  >Delete</gr-button
-                >
-              </td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-      <gr-overlay id="viewKeyOverlay" with-backdrop="">
-        <fieldset>
-          <section>
-            <span class="title">Status</span>
-            <span class="value">[[_keyToView.status]]</span>
-          </section>
-          <section>
-            <span class="title">Key</span>
-            <span class="value">[[_keyToView.key]]</span>
-          </section>
-        </fieldset>
-        <gr-button class="closeButton" on-click="_closeOverlay"
-          >Close</gr-button
-        >
-      </gr-overlay>
-      <gr-button on-click="save" disabled$="[[!hasUnsavedChanges]]"
-        >Save changes</gr-button
-      >
-    </fieldset>
-    <fieldset>
-      <section>
-        <span class="title">New GPG key</span>
-        <span class="value">
-          <iron-autogrow-textarea
-            id="newKey"
-            autocomplete="on"
-            bind-value="{{_newKey}}"
-            placeholder="New GPG Key"
-          ></iron-autogrow-textarea>
-        </span>
-      </section>
-      <gr-button
-        id="addButton"
-        disabled$="[[_computeAddButtonDisabled(_newKey)]]"
-        on-click="_handleAddKey"
-        >Add new GPG key</gr-button
-      >
-    </fieldset>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
index c31b40d..0f775b80 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
@@ -31,7 +31,6 @@
   OpenPgpUserIds,
 } from '../../../api/rest-api';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 
 const basicFixture = fixtureFromElement('gr-gpg-editor');
 
@@ -41,9 +40,9 @@
 
   setup(async () => {
     const fingerprint1 =
-      '0192 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B' as GpgKeyFingerprint;
+      '0192 723D 42D1 0C5B 32A6 E1E0 9350 9E4B AFC8 A49B' as GpgKeyFingerprint;
     const fingerprint2 =
-      '0196 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B' as GpgKeyFingerprint;
+      '0196 723D 42D1 0C5B 32A6 E1E0 9350 9E4B AFC8 A49B' as GpgKeyFingerprint;
     keys = {
       AFC8A49B: {
         fingerprint: fingerprint1,
@@ -70,7 +69,138 @@
     element = basicFixture.instantiate();
 
     await element.loadData();
-    await flush();
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `<div class="gr-form-styles">
+      <fieldset id="existing">
+        <table>
+          <thead>
+            <tr>
+              <th class="idColumn">ID</th>
+              <th class="fingerPrintColumn">Fingerprint</th>
+              <th class="userIdHeader">User IDs</th>
+              <th class="keyHeader">Public Key</th>
+              <th></th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr>
+              <td class="idColumn">AFC8A49B</td>
+              <td class="fingerPrintColumn">
+                0192 723D 42D1 0C5B 32A6 E1E0 9350 9E4B AFC8 A49B
+              </td>
+              <td class="userIdHeader">John Doe john.doe@example.com</td>
+              <td class="keyHeader">
+                <gr-button
+                  aria-disabled="false"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Click to View
+                </gr-button>
+              </td>
+              <td>
+                <gr-copy-clipboard
+                  buttontitle="Copy GPG public key to clipboard"
+                  hastooltip=""
+                  hideinput=""
+                >
+                </gr-copy-clipboard>
+              </td>
+              <td>
+                <gr-button aria-disabled="false" role="button" tabindex="0">
+                  Delete
+                </gr-button>
+              </td>
+            </tr>
+            <tr>
+              <td class="idColumn">AED9B59C</td>
+              <td class="fingerPrintColumn">
+                0196 723D 42D1 0C5B 32A6 E1E0 9350 9E4B AFC8 A49B
+              </td>
+              <td class="userIdHeader">Gerrit gerrit@example.com</td>
+              <td class="keyHeader">
+                <gr-button
+                  aria-disabled="false"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  Click to View
+                </gr-button>
+              </td>
+              <td>
+                <gr-copy-clipboard
+                  buttontitle="Copy GPG public key to clipboard"
+                  hastooltip=""
+                  hideinput=""
+                >
+                </gr-copy-clipboard>
+              </td>
+              <td>
+                <gr-button aria-disabled="false" role="button" tabindex="0">
+                  Delete
+                </gr-button>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+        <gr-overlay
+          aria-hidden="true"
+          id="viewKeyOverlay"
+          style="outline: none; display: none;"
+          tabindex="-1"
+          with-backdrop=""
+        >
+          <fieldset>
+            <section>
+              <span class="title"> Status </span> <span class="value"> </span>
+            </section>
+            <section>
+              <span class="title"> Key </span> <span class="value"> </span>
+            </section>
+          </fieldset>
+          <gr-button
+            aria-disabled="false"
+            class="closeButton"
+            role="button"
+            tabindex="0"
+          >
+            Close
+          </gr-button>
+        </gr-overlay>
+        <gr-button aria-disabled="true" disabled="" role="button" tabindex="-1">
+          Save changes
+        </gr-button>
+      </fieldset>
+      <fieldset>
+        <section>
+          <span class="title"> New GPG key </span>
+          <span class="value">
+            <iron-autogrow-textarea
+              aria-disabled="false"
+              autocomplete="on"
+              id="newKey"
+              placeholder="New GPG Key"
+            >
+            </iron-autogrow-textarea>
+          </span>
+        </section>
+        <gr-button
+          aria-disabled="true"
+          disabled=""
+          id="addButton"
+          role="button"
+          tabindex="-1"
+        >
+          Add new GPG key
+        </gr-button>
+      </fieldset>
+    </div> `);
   });
 
   test('renders', () => {
@@ -92,7 +222,7 @@
       Promise.resolve(new Response())
     );
 
-    assert.equal(element._keysToRemove.length, 0);
+    assert.equal(element.keysToRemove.length, 0);
     assert.isFalse(element.hasUnsavedChanges);
 
     // Get the delete button for the last row.
@@ -101,23 +231,23 @@
       'tbody tr:last-of-type td:nth-child(6) gr-button'
     );
 
-    MockInteractions.tap(button);
+    button.click();
 
-    assert.equal(element._keys.length, 1);
-    assert.equal(element._keysToRemove.length, 1);
-    assert.equal(element._keysToRemove[0], lastKey);
+    assert.equal(element.keys.length, 1);
+    assert.equal(element.keysToRemove.length, 1);
+    assert.equal(element.keysToRemove[0], lastKey);
     assert.isTrue(element.hasUnsavedChanges);
     assert.isFalse(saveStub.called);
 
     await element.save();
     assert.isTrue(saveStub.called);
     assert.equal(saveStub.lastCall.args[0], Object.keys(keys)[1]);
-    assert.equal(element._keysToRemove.length, 0);
+    assert.equal(element.keysToRemove.length, 0);
     assert.isFalse(element.hasUnsavedChanges);
   });
 
   test('show key', () => {
-    const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
+    const openSpy = sinon.spy(element.viewKeyOverlay!, 'open');
 
     // Get the show button for the last row.
     const button = queryAndAssert<GrButton>(
@@ -125,16 +255,14 @@
       'tbody tr:last-of-type td:nth-child(4) gr-button'
     );
 
-    MockInteractions.tap(button);
-
-    assert.equal(element._keyToView, keys[Object.keys(keys)[1]]);
+    button.click();
+    assert.equal(element.keyToView, keys[Object.keys(keys)[1]]);
     assert.isTrue(openSpy.called);
   });
 
   test('add key', async () => {
     const newKeyString =
-      '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
-      '\nVersion: BCPG v1.52\n\t<key 3>';
+      '-----BEGIN PGP PUBLIC KEY BLOCK-----' + ' Version: BCPG v1.52 \t<key 3>';
     const newKeyObject = {
       ADE8A59B: {
         fingerprint:
@@ -150,21 +278,22 @@
       Promise.resolve(newKeyObject)
     );
 
-    element._newKey = newKeyString;
+    element.newKey = newKeyString;
+    await element.updateComplete;
 
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
+    assert.isFalse(element.addButton!.disabled);
+    assert.isFalse(element.newKeyTextarea!.disabled);
 
     const promise = mockPromise();
-    element._handleAddKey().then(() => {
-      assert.isTrue(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 2);
+    element.handleAddKey().then(() => {
+      assert.isTrue(element.addButton!.disabled);
+      assert.isFalse(element.newKeyTextarea!.disabled);
+      assert.equal(element.keys.length, 2);
       promise.resolve();
     });
 
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
+    assert.isTrue(element.addButton!.disabled);
+    assert.isTrue(element.newKeyTextarea!.disabled);
 
     assert.isTrue(addStub.called);
     assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
@@ -178,21 +307,22 @@
       Promise.reject(new Error('error'))
     );
 
-    element._newKey = newKeyString;
+    element.newKey = newKeyString;
+    await element.updateComplete;
 
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
+    assert.isFalse(element.addButton!.disabled);
+    assert.isFalse(element.newKeyTextarea!.disabled);
 
     const promise = mockPromise();
-    element._handleAddKey().then(() => {
-      assert.isFalse(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 2);
+    element.handleAddKey().then(() => {
+      assert.isFalse(element.addButton!.disabled);
+      assert.isFalse(element.newKeyTextarea!.disabled);
+      assert.equal(element.keys.length, 2);
       promise.resolve();
     });
 
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
+    assert.isTrue(element.addButton!.disabled);
+    assert.isTrue(element.newKeyTextarea!.disabled);
 
     assert.isTrue(addStub.called);
     assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
index 2db8752..2b8a1e9 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
@@ -80,7 +80,7 @@
             return html`
               <tr>
                 <td class="nameColumn">
-                  <a href="${href}"> ${group.name} </a>
+                  <a href=${href}> ${group.name} </a>
                 </td>
                 <td>${group.description}</td>
                 <td class="visibleCell">
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
index ebe30a3..73fc55f 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
@@ -125,7 +125,7 @@
           >
         </div>
         <span ?hidden=${!this._passwordUrl}>
-          <a href="${this._passwordUrl!}" target="_blank" rel="noopener">
+          <a href=${this._passwordUrl!} target="_blank" rel="noopener">
             Obtain password</a
           >
           (opens in a new tab)
@@ -144,7 +144,7 @@
               hasTooltip=""
               buttonTitle="Copy password to clipboard"
               hideInput=""
-              .text="${this._generatedPassword}"
+              .text=${this._generatedPassword}
             >
             </gr-copy-clipboard>
           </section>
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
index 9e1aaa9..d8a7579 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
@@ -14,105 +14,185 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import '../../../styles/gr-form-styles';
 import '../../admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-overlay/gr-overlay';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-identities_html';
 import {getBaseUrl} from '../../../utils/url-util';
-import {customElement, property} from '@polymer/decorators';
 import {AccountExternalIdInfo, ServerInfo} from '../../../types/common';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {PolymerDomRepeatEvent} from '../../../types/types';
 import {getAppContext} from '../../../services/app-context';
 import {AuthType} from '../../../constants/constants';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {classMap} from 'lit/directives/class-map';
+import {when} from 'lit/directives/when.js';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const AUTH = [AuthType.OPENID, AuthType.OAUTH];
 
-export interface GrIdentities {
-  $: {
-    overlay: GrOverlay;
-  };
-}
-
 @customElement('gr-identities')
-export class GrIdentities extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrIdentities extends LitElement {
+  @query('#overlay') overlay?: GrOverlay;
 
-  @property({type: Array})
-  _identities: AccountExternalIdInfo[] = [];
+  @state() private identities: AccountExternalIdInfo[] = [];
 
-  @property({type: String})
-  _idName?: string;
+  // temporary var for communicating with the confirmation dialog
+  // private but used in test
+  @state() idName?: string;
 
-  @property({type: Object})
-  serverConfig?: ServerInfo;
+  @property({type: Object}) serverConfig?: ServerInfo;
 
-  @property({
-    type: Boolean,
-    computed: '_computeShowLinkAnotherIdentity(serverConfig)',
-  })
-  _showLinkAnotherIdentity?: boolean;
+  @state() showLinkAnotherIdentity = false;
 
   private readonly restApiService = getAppContext().restApiService;
 
+  static override styles = [
+    sharedStyles,
+    formStyles,
+    css`
+      tr th.emailAddressHeader,
+      tr th.identityHeader {
+        width: 15em;
+        padding: 0 10px;
+      }
+      tr td.statusColumn,
+      tr td.emailAddressColumn,
+      tr td.identityColumn {
+        word-break: break-word;
+      }
+      tr td.emailAddressColumn,
+      tr td.identityColumn {
+        padding: 4px 10px;
+        width: 15em;
+      }
+      .deleteButton {
+        float: right;
+      }
+      .deleteButton:not(.show) {
+        display: none;
+      }
+      .space {
+        margin-bottom: var(--spacing-l);
+      }
+    `,
+  ];
+
+  override render() {
+    return html`<div class="gr-form-styles">
+        <fieldset class="space">
+          <table>
+            <thead>
+              <tr>
+                <th class="statusHeader">Status</th>
+                <th class="emailAddressHeader">Email Address</th>
+                <th class="identityHeader">Identity</th>
+                <th class="deleteHeader"></th>
+              </tr>
+            </thead>
+            <tbody>
+              ${this.getIdentities().map((account, index) =>
+                this.renderIdentity(account, index)
+              )}
+            </tbody>
+          </table>
+        </fieldset>
+        ${when(
+          this.showLinkAnotherIdentity,
+          () => html`<fieldset>
+            <a href=${this.computeLinkAnotherIdentity()}>
+              <gr-button id="linkAnotherIdentity" link=""
+                >Link Another Identity</gr-button
+              >
+            </a>
+          </fieldset>`
+        )}
+      </div>
+      <gr-overlay id="overlay" with-backdrop>
+        <gr-confirm-delete-item-dialog
+          class="confirmDialog"
+          @confirm=${this.handleDeleteItemConfirm}
+          @cancel=${this.handleConfirmDialogCancel}
+          .item=${this.idName}
+          itemtypename="ID"
+        ></gr-confirm-delete-item-dialog>
+      </gr-overlay>`;
+  }
+
+  private renderIdentity(account: AccountExternalIdInfo, index: number) {
+    return html`<tr>
+      <td class="statusColumn">${account.trusted ? '' : 'Untrusted'}</td>
+      <td class="emailAddressColumn">${account.email_address}</td>
+      <td class="identityColumn">
+        ${account.identity.startsWith('mailto:') ? '' : account.identity}
+      </td>
+      <td class="deleteColumn">
+        <gr-button
+          data-index=${index}
+          class=${classMap({
+            deleteButton: true,
+            show: !!account.can_delete,
+          })}
+          @click=${() => this.handleDeleteItem(account.identity)}
+        >
+          Delete
+        </gr-button>
+      </td>
+    </tr>`;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('serverConfig')) {
+      this.showLinkAnotherIdentity = this.computeShowLinkAnotherIdentity();
+    }
+  }
+
+  // private but used in test
+  getIdentities() {
+    return this.identities.filter(
+      account => !account.identity.startsWith('username:')
+    );
+  }
+
   loadData() {
-    return this.restApiService.getExternalIds().then(id => {
-      this._identities = id ?? [];
+    return this.restApiService.getExternalIds().then(ids => {
+      this.identities = ids ?? [];
     });
   }
 
-  _computeIdentity(id: string) {
-    return id && id.startsWith('mailto:') ? '' : id;
-  }
-
+  // private but used in test
   _computeHideDeleteClass(canDelete?: boolean) {
     return canDelete ? 'show' : '';
   }
 
-  _handleDeleteItemConfirm() {
-    this.$.overlay.close();
-    return this.restApiService
-      .deleteAccountIdentity([this._idName!])
-      .then(() => {
-        this.loadData();
-      });
+  handleDeleteItemConfirm() {
+    this.overlay?.close();
+    assertIsDefined(this.idName);
+    return this.restApiService.deleteAccountIdentity([this.idName]).then(() => {
+      this.loadData();
+    });
   }
 
-  _handleConfirmDialogCancel() {
-    this.$.overlay.close();
+  private handleConfirmDialogCancel() {
+    this.overlay?.close();
   }
 
-  _handleDeleteItem(e: PolymerDomRepeatEvent<AccountExternalIdInfo>) {
-    const name = e.model.item.identity;
-    if (!name) {
-      return;
-    }
-    this._idName = name;
-    this.$.overlay.open();
+  private handleDeleteItem(name: string) {
+    this.idName = name;
+    this.overlay?.open();
   }
 
-  _computeIsTrusted(item?: boolean) {
-    return item ? '' : 'Untrusted';
-  }
-
-  filterIdentities(item: AccountExternalIdInfo) {
-    return !item.identity.startsWith('username:');
-  }
-
-  _computeShowLinkAnotherIdentity(config?: ServerInfo) {
-    if (config?.auth?.auth_type) {
-      return AUTH.includes(config.auth.auth_type);
+  // private but used in test
+  computeShowLinkAnotherIdentity() {
+    if (this.serverConfig?.auth?.auth_type) {
+      return AUTH.includes(this.serverConfig.auth.auth_type);
     }
 
     return false;
   }
 
-  _computeLinkAnotherIdentity() {
+  private computeLinkAnotherIdentity() {
     const baseUrl = getBaseUrl() || '';
     let pathname = window.location.pathname;
     if (baseUrl) {
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts
deleted file mode 100644
index a30840c..0000000
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_html.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    tr th.emailAddressHeader,
-    tr th.identityHeader {
-      width: 15em;
-      padding: 0 10px;
-    }
-    tr td.statusColumn,
-    tr td.emailAddressColumn,
-    tr td.identityColumn {
-      word-break: break-word;
-    }
-    tr td.emailAddressColumn,
-    tr td.identityColumn {
-      padding: 4px 10px;
-      width: 15em;
-    }
-    .deleteButton {
-      float: right;
-    }
-    .deleteButton:not(.show) {
-      display: none;
-    }
-    .space {
-      margin-bottom: var(--spacing-l);
-    }
-  </style>
-  <div class="gr-form-styles">
-    <fieldset class="space">
-      <table>
-        <thead>
-          <tr>
-            <th class="statusHeader">Status</th>
-            <th class="emailAddressHeader">Email Address</th>
-            <th class="identityHeader">Identity</th>
-            <th class="deleteHeader"></th>
-          </tr>
-        </thead>
-        <tbody>
-          <template
-            is="dom-repeat"
-            items="[[_identities]]"
-            filter="filterIdentities"
-          >
-            <tr>
-              <td class="statusColumn">[[_computeIsTrusted(item.trusted)]]</td>
-              <td class="emailAddressColumn">[[item.email_address]]</td>
-              <td class="identityColumn">
-                [[_computeIdentity(item.identity)]]
-              </td>
-              <td class="deleteColumn">
-                <gr-button
-                  class$="deleteButton [[_computeHideDeleteClass(item.can_delete)]]"
-                  on-click="_handleDeleteItem"
-                >
-                  Delete
-                </gr-button>
-              </td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-    </fieldset>
-    <template is="dom-if" if="[[_showLinkAnotherIdentity]]">
-      <fieldset>
-        <a href$="[[_computeLinkAnotherIdentity()]]">
-          <gr-button id="linkAnotherIdentity" link=""
-            >Link Another Identity</gr-button
-          >
-        </a>
-      </fieldset>
-    </template>
-  </div>
-  <gr-overlay id="overlay" with-backdrop="">
-    <gr-confirm-delete-item-dialog
-      class="confirmDialog"
-      on-confirm="_handleDeleteItemConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      item="[[_idName]]"
-      itemTypeName="ID"
-    ></gr-confirm-delete-item-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
index ecc322ef7..8ee84bf 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
@@ -23,9 +23,8 @@
 import {ServerInfo} from '../../../types/common';
 import {createServerInfo} from '../../../test/test-data-generators';
 import {queryAll, queryAndAssert} from '../../../test/test-utils';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-
-const basicFixture = fixtureFromElement('gr-identities');
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-identities tests', () => {
   let element: GrIdentities;
@@ -51,9 +50,72 @@
   setup(async () => {
     stubRestApi('getExternalIds').returns(Promise.resolve(ids));
 
-    element = basicFixture.instantiate();
+    element = await fixture<GrIdentities>(
+      html`<gr-identities></gr-identities>`
+    );
     await element.loadData();
-    await flush();
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `<div class="gr-form-styles">
+        <fieldset class="space">
+          <table>
+            <thead>
+              <tr>
+                <th class="statusHeader">Status</th>
+                <th class="emailAddressHeader">Email Address</th>
+                <th class="identityHeader">Identity</th>
+                <th class="deleteHeader"></th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td class="statusColumn">Untrusted</td>
+                <td class="emailAddressColumn">gerrit@example.com</td>
+                <td class="identityColumn">gerrit:gerrit</td>
+                <td class="deleteColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="deleteButton"
+                    data-index="0"
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+              <tr>
+                <td class="statusColumn"></td>
+                <td class="emailAddressColumn">gerrit2@example.com</td>
+                <td class="identityColumn"></td>
+                <td class="deleteColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="deleteButton show"
+                    data-index="1"
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </fieldset>
+      </div>
+      <gr-overlay
+        aria-hidden="true"
+        id="overlay"
+        style="outline: none; display: none;"
+        tabindex="-1"
+        with-backdrop=""
+      >
+        <gr-confirm-delete-item-dialog class="confirmDialog" itemtypename="ID">
+        </gr-confirm-delete-item-dialog
+      ></gr-overlay>`);
   });
 
   test('renders', () => {
@@ -78,70 +140,71 @@
     assert.equal(nameCells[1]!, 'gerrit2@example.com');
   });
 
-  test('_computeIdentity', () => {
-    assert.equal(element._computeIdentity(ids[0].identity), 'username:john');
-    assert.equal(element._computeIdentity(ids[2].identity), '');
-  });
-
   test('filterIdentities', () => {
-    assert.isFalse(element.filterIdentities(ids[0]));
-
-    assert.isTrue(element.filterIdentities(ids[1]));
+    assert.notInclude(element.getIdentities(), ids[0]);
+    assert.include(element.getIdentities(), ids[1]);
   });
 
   test('delete id', async () => {
-    element._idName = 'mailto:gerrit2@example.com';
+    element.idName = 'mailto:gerrit2@example.com';
     const loadDataStub = sinon.stub(element, 'loadData');
-    await element._handleDeleteItemConfirm();
+    await element.handleDeleteItemConfirm();
     assert.isTrue(loadDataStub.called);
   });
 
-  test('_handleDeleteItem opens modal', () => {
-    const deleteBtn = queryAndAssert(element, '.deleteButton');
-    const deleteItem = sinon.stub(element, '_handleDeleteItem');
-    MockInteractions.tap(deleteBtn);
-    assert.isTrue(deleteItem.called);
+  test('handleDeleteItem opens modal', async () => {
+    const deleteBtn = queryAndAssert<GrButton>(element, '.deleteButton');
+    deleteBtn.click();
+    await element.updateComplete;
+    assert.isTrue(element.overlay?.opened);
   });
 
-  test('_computeShowLinkAnotherIdentity', () => {
+  test('computeShowLinkAnotherIdentity', () => {
     const config: ServerInfo = {
       ...createServerInfo(),
     };
 
     config.auth.auth_type = AuthType.OAUTH;
-    assert.isTrue(element._computeShowLinkAnotherIdentity(config));
+    element.serverConfig = config;
+    assert.isTrue(element.computeShowLinkAnotherIdentity());
 
     config.auth.auth_type = AuthType.OPENID;
-    assert.isTrue(element._computeShowLinkAnotherIdentity(config));
+    element.serverConfig = config;
+    assert.isTrue(element.computeShowLinkAnotherIdentity());
 
     config.auth.auth_type = AuthType.HTTP_LDAP;
-    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+    element.serverConfig = config;
+    assert.isFalse(element.computeShowLinkAnotherIdentity());
 
     config.auth.auth_type = AuthType.LDAP;
-    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+    element.serverConfig = config;
+    assert.isFalse(element.computeShowLinkAnotherIdentity());
 
     config.auth.auth_type = AuthType.HTTP;
-    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+    element.serverConfig = config;
+    assert.isFalse(element.computeShowLinkAnotherIdentity());
 
-    assert.isFalse(element._computeShowLinkAnotherIdentity(undefined));
+    element.serverConfig = undefined;
+    assert.isFalse(element.computeShowLinkAnotherIdentity());
   });
 
-  test('_showLinkAnotherIdentity', () => {
+  test('showLinkAnotherIdentity', async () => {
     let config: ServerInfo = {
       ...createServerInfo(),
     };
     config.auth.auth_type = AuthType.OAUTH;
-
     element.serverConfig = config;
+    await element.updateComplete;
 
-    assert.isTrue(element._showLinkAnotherIdentity);
+    assert.isTrue(element.showLinkAnotherIdentity);
 
     config = {
       ...createServerInfo(),
     };
     config.auth.auth_type = AuthType.LDAP;
     element.serverConfig = config;
+    await element.updateComplete;
 
-    assert.isFalse(element._showLinkAnotherIdentity);
+    assert.isFalse(element.showLinkAnotherIdentity);
   });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
index c392a13..845b30c 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
@@ -1,98 +1,235 @@
 /**
  * @license
- * Copyright (C) 2016 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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
-import '../../../styles/shared-styles';
-import '../../../styles/gr-form-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-menu-editor_html';
-import {customElement, property} from '@polymer/decorators';
-import {TopMenuItemInfo} from '../../../types/common';
+import {PreferencesInfo, TopMenuItemInfo} from '../../../types/common';
+import {css, html, LitElement} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {state, customElement} from 'lit/decorators';
+import {BindValueChangeEvent} from '../../../types/events';
+import {subscribe} from '../../lit/subscription-controller';
+import {getAppContext} from '../../../services/app-context';
+import {deepEqual} from '../../../utils/deep-util';
+import {createDefaultPreferences} from '../../../constants/constants';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {classMap} from 'lit/directives/class-map';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
 
 @customElement('gr-menu-editor')
-export class GrMenuEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrMenuEditor extends LitElement {
+  @state()
+  menuItems: TopMenuItemInfo[] = [];
+
+  @state()
+  originalPrefs: PreferencesInfo = createDefaultPreferences();
+
+  @state()
+  newName = '';
+
+  @state()
+  newUrl = '';
+
+  private readonly userModel = getAppContext().userModel;
+
+  override connectedCallback() {
+    super.connectedCallback();
+    subscribe(this, this.userModel.preferences$, prefs => {
+      this.originalPrefs = prefs;
+      this.menuItems = [...prefs.my];
+    });
   }
 
-  @property({type: Array})
-  menuItems!: TopMenuItemInfo[];
+  static override styles = [
+    formStyles,
+    sharedStyles,
+    fontStyles,
+    menuPageStyles,
+    css`
+      .buttonColumn {
+        width: 2em;
+      }
+      .moveUpButton,
+      .moveDownButton {
+        width: 100%;
+      }
+      tbody tr:first-of-type td .moveUpButton,
+      tbody tr:last-of-type td .moveDownButton {
+        display: none;
+      }
+      td.urlCell {
+        word-break: break-word;
+      }
+      .newUrlInput {
+        min-width: 23em;
+      }
+    `,
+  ];
 
-  @property({type: String})
-  _newName?: string;
-
-  @property({type: String})
-  _newUrl?: string;
-
-  _handleMoveUpButton(e: Event) {
-    const target = (dom(e) as EventApi).localTarget;
-    if (!(target instanceof HTMLElement)) return;
-    const index = Number(target.dataset['index']);
-    if (index === 0) {
-      return;
-    }
-    const row = this.menuItems[index];
-    const prev = this.menuItems[index - 1];
-    this.splice('menuItems', index - 1, 2, row, prev);
+  override render() {
+    const unchanged = deepEqual(this.menuItems, this.originalPrefs.my);
+    const classes = {
+      'heading-2': true,
+      edited: !unchanged,
+    };
+    return html`
+      <div class="gr-form-styles">
+        <h2 id="Menu" class=${classMap(classes)}>Menu</h2>
+        <fieldset id="menu">
+          <table>
+            <thead>
+              <tr>
+                <th>Name</th>
+                <th>URL</th>
+              </tr>
+            </thead>
+            <tbody>
+              ${this.menuItems.map((item, index) =>
+                this.renderMenuItemRow(item, index)
+              )}
+            </tbody>
+            <tfoot>
+              ${this.renderFooterRow()}
+            </tfoot>
+          </table>
+          <gr-button id="save" @click=${this.handleSave} ?disabled=${unchanged}
+            >Save changes</gr-button
+          >
+          <gr-button id="reset" link @click=${this.handleReset}
+            >Reset</gr-button
+          >
+        </fieldset>
+      </div>
+    `;
   }
 
-  _handleMoveDownButton(e: Event) {
-    const target = (dom(e) as EventApi).localTarget;
-    if (!(target instanceof HTMLElement)) return;
-    const index = Number(target.dataset['index']);
-    if (index === this.menuItems.length - 1) {
-      return;
-    }
-    const row = this.menuItems[index];
-    const next = this.menuItems[index + 1];
-    this.splice('menuItems', index, 2, next, row);
+  private renderMenuItemRow(item: TopMenuItemInfo, index: number) {
+    return html`
+      <tr>
+        <td>${item.name}</td>
+        <td class="urlCell">${item.url}</td>
+        <td class="buttonColumn">
+          <gr-button
+            link
+            data-index=${index}
+            @click=${() => this.swapItems(index, index - 1)}
+            class="moveUpButton"
+            >↑</gr-button
+          >
+        </td>
+        <td class="buttonColumn">
+          <gr-button
+            link
+            data-index=${index}
+            @click=${() => this.swapItems(index, index + 1)}
+            class="moveDownButton"
+            >↓</gr-button
+          >
+        </td>
+        <td>
+          <gr-button
+            link
+            data-index=${index}
+            @click=${() => {
+              this.menuItems.splice(index, 1);
+              this.requestUpdate('menuItems');
+            }}
+            class="remove-button"
+            >Delete</gr-button
+          >
+        </td>
+      </tr>
+    `;
   }
 
-  _handleDeleteButton(e: Event) {
-    const target = (dom(e) as EventApi).localTarget;
-    if (!(target instanceof HTMLElement)) return;
-    const index = Number(target.dataset['index']);
-    this.splice('menuItems', index, 1);
+  private renderFooterRow() {
+    return html`
+      <tr>
+        <th>
+          <iron-input
+            .bindValue=${this.newName}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.newName = e.detail.value ?? '';
+            }}
+          >
+            <input
+              is="iron-input"
+              placeholder="New Title"
+              @keydown=${this.handleInputKeydown}
+            />
+          </iron-input>
+        </th>
+        <th>
+          <iron-input
+            .bindValue=${this.newUrl}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.newUrl = e.detail.value ?? '';
+            }}
+          >
+            <input
+              class="newUrlInput"
+              placeholder="New URL"
+              @keydown=${this.handleInputKeydown}
+            />
+          </iron-input>
+        </th>
+        <th></th>
+        <th></th>
+        <th>
+          <gr-button
+            id="add"
+            link
+            ?disabled=${this.newName.length === 0 || this.newUrl.length === 0}
+            @click=${this.handleAddButton}
+            >Add</gr-button
+          >
+        </th>
+      </tr>
+    `;
   }
 
-  _handleAddButton() {
-    if (this._computeAddDisabled(this._newName, this._newUrl)) {
-      return;
-    }
+  private handleSave() {
+    this.userModel.updatePreferences({
+      ...this.originalPrefs,
+      my: this.menuItems,
+    });
+  }
 
-    this.splice('menuItems', this.menuItems.length, 0, {
-      name: this._newName,
-      url: this._newUrl,
+  private handleReset() {
+    this.menuItems = [...this.originalPrefs.my];
+  }
+
+  private swapItems(i: number, j: number) {
+    const max = this.menuItems.length - 1;
+    if (i < 0 || j < 0) return;
+    if (i > max || j > max) return;
+    const x = this.menuItems[i];
+    this.menuItems[i] = this.menuItems[j];
+    this.menuItems[j] = x;
+    this.requestUpdate('menuItems');
+  }
+
+  // visible for testing
+  handleAddButton() {
+    if (this.newName.length === 0 || this.newUrl.length === 0) return;
+
+    this.menuItems.push({
+      name: this.newName,
+      url: this.newUrl,
       target: '_blank',
     });
-
-    this._newName = '';
-    this._newUrl = '';
+    this.newName = '';
+    this.newUrl = '';
+    this.requestUpdate('menuItems');
   }
 
-  _computeAddDisabled(newName?: string, newUrl?: string) {
-    return !newName?.length || !newUrl?.length;
-  }
-
-  _handleInputKeydown(e: KeyboardEvent) {
+  private handleInputKeydown(e: KeyboardEvent) {
     if (e.keyCode === 13) {
       e.stopPropagation();
-      this._handleAddButton();
+      this.handleAddButton();
     }
   }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.ts
deleted file mode 100644
index e4d66e2..0000000
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_html.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    .buttonColumn {
-      width: 2em;
-    }
-    .moveUpButton,
-    .moveDownButton {
-      width: 100%;
-    }
-    tbody tr:first-of-type td .moveUpButton,
-    tbody tr:last-of-type td .moveDownButton {
-      display: none;
-    }
-    td.urlCell {
-      word-break: break-word;
-    }
-    .newUrlInput {
-      min-width: 23em;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="gr-form-styles">
-    <table>
-      <thead>
-        <tr>
-          <th class="nameHeader">Name</th>
-          <th class="url-header">URL</th>
-        </tr>
-      </thead>
-      <tbody>
-        <template is="dom-repeat" items="[[menuItems]]">
-          <tr>
-            <td>[[item.name]]</td>
-            <td class="urlCell">[[item.url]]</td>
-            <td class="buttonColumn">
-              <gr-button
-                link=""
-                data-index$="[[index]]"
-                on-click="_handleMoveUpButton"
-                class="moveUpButton"
-                >↑</gr-button
-              >
-            </td>
-            <td class="buttonColumn">
-              <gr-button
-                link=""
-                data-index$="[[index]]"
-                on-click="_handleMoveDownButton"
-                class="moveDownButton"
-                >↓</gr-button
-              >
-            </td>
-            <td>
-              <gr-button
-                link=""
-                data-index$="[[index]]"
-                on-click="_handleDeleteButton"
-                class="remove-button"
-                >Delete</gr-button
-              >
-            </td>
-          </tr>
-        </template>
-      </tbody>
-      <tfoot>
-        <tr>
-          <th>
-            <iron-input
-              placeholder="New Title"
-              on-keydown="_handleInputKeydown"
-              bind-value="{{_newName}}"
-            >
-              <input
-                is="iron-input"
-                placeholder="New Title"
-                on-keydown="_handleInputKeydown"
-                bind-value="{{_newName}}"
-              />
-            </iron-input>
-          </th>
-          <th>
-            <iron-input
-              class="newUrlInput"
-              placeholder="New URL"
-              on-keydown="_handleInputKeydown"
-              bind-value="{{_newUrl}}"
-            >
-              <input
-                class="newUrlInput"
-                is="iron-input"
-                placeholder="New URL"
-                on-keydown="_handleInputKeydown"
-                bind-value="{{_newUrl}}"
-              />
-            </iron-input>
-          </th>
-          <th></th>
-          <th></th>
-          <th>
-            <gr-button
-              link=""
-              disabled$="[[_computeAddDisabled(_newName, _newUrl)]]"
-              on-click="_handleAddButton"
-              >Add</gr-button
-            >
-          </th>
-        </tr>
-      </tfoot>
-    </table>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts
index 9785ccb..c6130df 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.ts
@@ -1,29 +1,18 @@
 /**
  * @license
- * Copyright (C) 2016 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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../../test/common-test-setup-karma';
 import './gr-menu-editor';
 import {GrMenuEditor} from './gr-menu-editor';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {query, queryAll} from '../../../test/test-utils';
+import {query, queryAndAssert, waitUntil} from '../../../test/test-utils';
 import {PaperButtonElement} from '@polymer/paper-button';
 import {TopMenuItemInfo} from '../../../types/common';
-
-const basicFixture = fixtureFromElement('gr-menu-editor');
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {fixture, html} from '@open-wc/testing-helpers';
+import {createDefaultPreferences} from '../../../constants/constants';
 
 suite('gr-menu-editor tests', () => {
   let element: GrMenuEditor;
@@ -53,52 +42,229 @@
   }
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = await fixture<GrMenuEditor>(
+      html`<gr-menu-editor></gr-menu-editor>`
+    );
     menu = [
       {url: '/first/url', name: 'first name', target: '_blank'},
       {url: '/second/url', name: 'second name', target: '_blank'},
       {url: '/third/url', name: 'third name', target: '_blank'},
     ];
-    element.set('menuItems', menu);
-    await flush();
+    element.originalPrefs = {...createDefaultPreferences(), my: menu};
+    element.menuItems = [...menu];
+    await element.updateComplete;
   });
 
   test('renders', () => {
-    const rows = queryAll(query<HTMLElement>(element, 'tbody')!, 'tr');
-    let tds;
-
-    assert.equal(rows.length, menu.length);
-    for (let i = 0; i < menu.length; i++) {
-      tds = rows[i].querySelectorAll('td');
-      assert.equal(tds[0].textContent, menu[i].name);
-      assert.equal(tds[1].textContent, menu[i].url);
-    }
-
-    assert.isTrue(
-      element._computeAddDisabled(element._newName, element._newUrl)
-    );
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <div class="gr-form-styles">
+        <h2 class="heading-2" id="Menu">Menu</h2>
+        <fieldset id="menu">
+          <table>
+            <thead>
+              <tr>
+                <th>Name</th>
+                <th>URL</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td>first name</td>
+                <td class="urlCell">/first/url</td>
+                <td class="buttonColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="moveUpButton"
+                    data-index="0"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    ↑
+                  </gr-button>
+                </td>
+                <td class="buttonColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="moveDownButton"
+                    data-index="0"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    ↓
+                  </gr-button>
+                </td>
+                <td>
+                  <gr-button
+                    aria-disabled="false"
+                    class="remove-button"
+                    data-index="0"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+              <tr>
+                <td>second name</td>
+                <td class="urlCell">/second/url</td>
+                <td class="buttonColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="moveUpButton"
+                    data-index="1"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    ↑
+                  </gr-button>
+                </td>
+                <td class="buttonColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="moveDownButton"
+                    data-index="1"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    ↓
+                  </gr-button>
+                </td>
+                <td>
+                  <gr-button
+                    aria-disabled="false"
+                    class="remove-button"
+                    data-index="1"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+              <tr>
+                <td>third name</td>
+                <td class="urlCell">/third/url</td>
+                <td class="buttonColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="moveUpButton"
+                    data-index="2"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    ↑
+                  </gr-button>
+                </td>
+                <td class="buttonColumn">
+                  <gr-button
+                    aria-disabled="false"
+                    class="moveDownButton"
+                    data-index="2"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    ↓
+                  </gr-button>
+                </td>
+                <td>
+                  <gr-button
+                    aria-disabled="false"
+                    class="remove-button"
+                    data-index="2"
+                    link=""
+                    role="button"
+                    tabindex="0"
+                  >
+                    Delete
+                  </gr-button>
+                </td>
+              </tr>
+            </tbody>
+            <tfoot>
+              <tr>
+                <th>
+                  <iron-input>
+                    <input is="iron-input" placeholder="New Title" />
+                  </iron-input>
+                </th>
+                <th>
+                  <iron-input>
+                    <input class="newUrlInput" placeholder="New URL" />
+                  </iron-input>
+                </th>
+                <th></th>
+                <th></th>
+                <th>
+                  <gr-button
+                    aria-disabled="true"
+                    disabled=""
+                    id="add"
+                    link=""
+                    role="button"
+                    tabindex="-1"
+                  >
+                    Add
+                  </gr-button>
+                </th>
+              </tr>
+            </tfoot>
+          </table>
+          <gr-button
+            aria-disabled="true"
+            disabled=""
+            id="save"
+            role="button"
+            tabindex="-1"
+          >
+            Save changes
+          </gr-button>
+          <gr-button
+            aria-disabled="false"
+            id="reset"
+            link=""
+            role="button"
+            tabindex="0"
+          >
+            Reset
+          </gr-button>
+        </fieldset>
+      </div>
+    `);
   });
 
-  test('_computeAddDisabled', () => {
-    assert.isTrue(element._computeAddDisabled('', ''));
-    assert.isTrue(element._computeAddDisabled('name', ''));
-    assert.isTrue(element._computeAddDisabled('', 'url'));
-    assert.isFalse(element._computeAddDisabled('name', 'url'));
+  test('add button disabled', async () => {
+    element.newName = 'test-name';
+    await element.updateComplete;
+    let addButton = queryAndAssert<GrButton>(element, 'gr-button#add');
+    assert.isTrue(addButton.hasAttribute('disabled'));
+
+    element.newUrl = 'test-url';
+    await element.updateComplete;
+    addButton = queryAndAssert<GrButton>(element, 'gr-button#add');
+    assert.isFalse(addButton.hasAttribute('disabled'));
   });
 
-  test('add a new menu item', () => {
+  test('add a new menu item', async () => {
     const newName = 'new name';
     const newUrl = 'new url';
-
-    element._newName = newName;
-    element._newUrl = newUrl;
-    assert.isFalse(
-      element._computeAddDisabled(element._newName, element._newUrl)
-    );
-
     const originalMenuLength = element.menuItems.length;
 
-    element._handleAddButton();
+    element.newName = newName;
+    element.newUrl = newUrl;
+    await element.updateComplete;
+
+    const addButton = queryAndAssert<GrButton>(element, 'gr-button#add');
+    assert.isFalse(addButton.hasAttribute('disabled'));
+    addButton.click();
 
     assert.equal(element.menuItems.length, originalMenuLength + 1);
     assert.equal(element.menuItems[element.menuItems.length - 1].name, newName);
@@ -117,6 +283,37 @@
     assertMenuNamesEqual(element, ['first name', 'third name', 'second name']);
   });
 
+  test('move item down and save', async () => {
+    assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
+    const saveButton = queryAndAssert<GrButton>(element, 'gr-button#save');
+    assert.isTrue(saveButton.hasAttribute('disabled'));
+
+    move(element, 1, 'Down');
+    await element.updateComplete;
+    assertMenuNamesEqual(element, ['first name', 'third name', 'second name']);
+    assert.isFalse(saveButton.hasAttribute('disabled'));
+
+    saveButton.click();
+    await waitUntil(() => element.originalPrefs.my[1].name === 'third name');
+    await element.updateComplete;
+
+    assertMenuNamesEqual(element, ['first name', 'third name', 'second name']);
+    assert.isTrue(saveButton.hasAttribute('disabled'));
+  });
+
+  test('move item down and reset', async () => {
+    assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
+
+    move(element, 1, 'Down');
+    assertMenuNamesEqual(element, ['first name', 'third name', 'second name']);
+
+    const resetButton = queryAndAssert<GrButton>(element, 'gr-button#reset');
+    resetButton.click();
+    await element.updateComplete;
+
+    assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
+  });
+
   test('move items up', () => {
     assertMenuNamesEqual(element, ['first name', 'second name', 'third name']);
 
@@ -161,9 +358,9 @@
     assertMenuNamesEqual(element, []);
 
     // Add item to empty menu.
-    element._newName = 'new name';
-    element._newUrl = 'new url';
-    element._handleAddButton();
+    element.newName = 'new name';
+    element.newUrl = 'new url';
+    element.handleAddButton();
     assertMenuNamesEqual(element, ['new name']);
   });
 });
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
index 64409d5..e132128 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
@@ -46,6 +46,6 @@
 
   override render() {
     const anchor = this.anchor ?? '';
-    return html`<h2 id="${anchor}" class="heading-2">${this.title}</h2>`;
+    return html`<h2 id=${anchor} class="heading-2">${this.title}</h2>`;
   }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
index 9e4ea0a..18c8bea 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
@@ -40,7 +40,7 @@
   override render() {
     const href = this.href ?? '';
     return html` <div class="navStyles">
-      <li><a href="${href}">${this.title}</a></li>
+      <li><a href=${href}>${this.title}</a></li>
     </div>`;
   }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index 5da726c..7a8a771 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -25,7 +25,6 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../gr-change-table-editor/gr-change-table-editor';
 import '../../shared/gr-button/gr-button';
-import {GrButton} from '../../shared/gr-button/gr-button';
 import '../../shared/gr-diff-preferences/gr-diff-preferences';
 import '../../shared/gr-page-nav/gr-page-nav';
 import '../../shared/gr-select/gr-select';
@@ -50,11 +49,7 @@
 import {GrGroupList} from '../gr-group-list/gr-group-list';
 import {GrIdentities} from '../gr-identities/gr-identities';
 import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
-import {
-  PreferencesInput,
-  ServerInfo,
-  TopMenuItemInfo,
-} from '../../../types/common';
+import {PreferencesInput, ServerInfo} from '../../../types/common';
 import {GrSshEditor} from '../gr-ssh-editor/gr-ssh-editor';
 import {GrGpgEditor} from '../gr-gpg-editor/gr-gpg-editor';
 import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
@@ -103,8 +98,6 @@
   LocalPrefsToPrefs,
 }
 
-type LocalMenuItemInfo = Omit<TopMenuItemInfo, 'id'>;
-
 export interface GrSettingsView {
   $: {
     accountInfo: GrAccountInfo;
@@ -129,8 +122,6 @@
     emailFormatSelect: HTMLInputElement;
     defaultBaseForMergesSelect: HTMLInputElement;
     diffViewSelect: HTMLInputElement;
-    menu: HTMLFieldSetElement;
-    resetButton: GrButton;
   };
 }
 
@@ -167,9 +158,6 @@
   @property({type: Array})
   _localChangeTableColumns: string[] = [];
 
-  @property({type: Array})
-  _localMenu: LocalMenuItemInfo[] = [];
-
   @property({type: Boolean})
   _loading = true;
 
@@ -183,9 +171,6 @@
   _diffPrefsChanged = false;
 
   @property({type: Boolean})
-  _menuChanged = false;
-
-  @property({type: Boolean})
   _watchedProjectsChanged = false;
 
   @property({type: Boolean})
@@ -247,7 +232,6 @@
         this.prefs = prefs;
         this._showNumber = !!prefs.legacycid_in_change_table;
         this._copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
-        this._localMenu = this._cloneMenu(prefs.my);
         this._localChangeTableColumns =
           prefs.change_table.length === 0
             ? columnNames
@@ -316,6 +300,14 @@
     super.disconnectedCallback();
   }
 
+  handleUnsavedChangesChanged(e: ValueChangedEvent) {
+    this._keysChanged = !!e.detail.value;
+  }
+
+  _handleGpgEditorHasSavedChanges(e: ValueChangedEvent<boolean>) {
+    this._gpgKeysChanged = e.detail.value;
+  }
+
   private readonly handleLocationChange = () => {
     // Handle anchor tag after dom attached
     const urlHash = window.location.hash;
@@ -351,11 +343,6 @@
     }
   }
 
-  _cloneMenu(prefs: TopMenuItemInfo[]) {
-    // eslint-disable-next-line @typescript-eslint/no-unused-vars
-    return prefs.map(({id, ...item}) => item);
-  }
-
   @observe('_localChangeTableColumns', '_showNumber')
   _handleChangeTableChanged() {
     if (this._isLoading()) {
@@ -418,14 +405,6 @@
     this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
   }
 
-  @observe('_localMenu.splices')
-  _handleMenuChanged() {
-    if (this._isLoading()) {
-      return;
-    }
-    this._menuChanged = true;
-  }
-
   _handleSaveAccountInfo() {
     this.$.accountInfo.save();
   }
@@ -450,21 +429,6 @@
     this.$.diffPrefs.save();
   }
 
-  _handleSaveMenu() {
-    this.set('prefs.my', this._localMenu);
-    return this.restApiService.savePreferences(this.prefs).then(() => {
-      this._menuChanged = false;
-    });
-  }
-
-  _handleResetMenuButton() {
-    return this.restApiService.getDefaultPreferences().then(data => {
-      if (data?.my) {
-        this._localMenu = this._cloneMenu(data.my);
-      }
-    });
-  }
-
   _handleSaveWatchedProjects() {
     this.$.watchedProjectsEditor.save();
   }
@@ -510,6 +474,22 @@
     });
   }
 
+  _handleShowNumberChanged(e: ValueChangedEvent<boolean>) {
+    this._showNumber = e.detail.value;
+  }
+
+  _handleDisplayedColumnsChanged(e: ValueChangedEvent<string[]>) {
+    this._localChangeTableColumns = e.detail.value;
+  }
+
+  _handleHasEmailsChanged(e: ValueChangedEvent<boolean>) {
+    this._emailsChanged = e.detail.value;
+  }
+
+  _handleHasProjectsChanged(e: ValueChangedEvent<boolean>) {
+    this._watchedProjectsChanged = e.detail.value;
+  }
+
   _getFilterDocsLink(docsBaseUrl?: string | null) {
     let base = docsBaseUrl;
     if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
index 394527b..00f85d8 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
@@ -361,19 +361,7 @@
         >
       </fieldset>
       <gr-edit-preferences id="editPrefs"></gr-edit-preferences>
-      <h2 id="Menu" class$="[[_computeHeaderClass(_menuChanged)]]">Menu</h2>
-      <fieldset id="menu">
-        <gr-menu-editor menu-items="{{_localMenu}}"></gr-menu-editor>
-        <gr-button
-          id="saveMenu"
-          on-click="_handleSaveMenu"
-          disabled="[[!_menuChanged]]"
-          >Save changes</gr-button
-        >
-        <gr-button id="resetButton" link="" on-click="_handleResetMenuButton"
-          >Reset</gr-button
-        >
-      </fieldset>
+      <gr-menu-editor></gr-menu-editor>
       <h2
         id="ChangeTableColumns"
         class$="[[_computeHeaderClass(_changeTableChanged)]]"
@@ -382,9 +370,11 @@
       </h2>
       <fieldset id="changeTableColumns">
         <gr-change-table-editor
-          show-number="{{_showNumber}}"
+          show-number="[[_showNumber]]"
+          on-show-number-changed="_handleShowNumberChanged"
           server-config="[[_serverConfig]]"
-          displayed-columns="{{_localChangeTableColumns}}"
+          displayed-columns="[[_localChangeTableColumns]]"
+          on-displayed-columns-changed="_handleDisplayedColumnsChanged"
         >
         </gr-change-table-editor>
         <gr-button
@@ -402,7 +392,8 @@
       </h2>
       <fieldset id="watchedProjects">
         <gr-watched-projects-editor
-          has-unsaved-changes="{{_watchedProjectsChanged}}"
+          has-unsaved-changes="[[_watchedProjectsChanged]]"
+          on-has-unsaved-changes-changed="_handleHasProjectsChanged"
           id="watchedProjectsEditor"
         ></gr-watched-projects-editor>
         <gr-button
@@ -418,7 +409,8 @@
       <fieldset id="email">
         <gr-email-editor
           id="emailEditor"
-          has-unsaved-changes="{{_emailsChanged}}"
+          has-unsaved-changes="[[_emailsChanged]]"
+          on-has-unsaved-changes-changed="_handleHasEmailsChanged"
         ></gr-email-editor>
         <gr-button on-click="_handleSaveEmails" disabled$="[[!_emailsChanged]]"
           >Save changes</gr-button
@@ -474,7 +466,7 @@
         </h2>
         <gr-ssh-editor
           id="sshEditor"
-          has-unsaved-changes="{{_keysChanged}}"
+          has-unsaved-changes-changed="handleUnsavedChangesChanged"
         ></gr-ssh-editor>
       </div>
       <div hidden$="[[!_serverConfig.receive.enable_signed_push]]">
@@ -483,7 +475,8 @@
         </h2>
         <gr-gpg-editor
           id="gpgEditor"
-          has-unsaved-changes="{{_gpgKeysChanged}}"
+          has-unsaved-changes="[[_gpgKeysChanged]]"
+          on-has-unsaved-changes-changed="_handleGpgEditorHasSavedChanges"
         ></gr-gpg-editor>
       </div>
       <h2 id="Groups">Groups</h2>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
index 8049124..7f81f42 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -19,7 +19,7 @@
 import './gr-settings-view';
 import {GrSettingsView} from './gr-settings-view';
 import {GerritView} from '../../../services/router/router-model';
-import {queryAll, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {
   AuthInfo,
   AccountDetailInfo,
@@ -259,7 +259,6 @@
     );
 
     assert.isFalse(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
 
     const publishOnPush = valueOf('Publish comments on push', 'preferences')!
       .firstElementChild!;
@@ -267,7 +266,6 @@
     MockInteractions.tap(publishOnPush);
 
     assert.isTrue(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
 
     stubRestApi('savePreferences').callsFake(prefs => {
       assertMenusEqual(prefs.my, preferences.my);
@@ -278,7 +276,6 @@
     // Save the change.
     await element._handleSavePreferences();
     assert.isFalse(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
   });
 
   test('publish comments on push', async () => {
@@ -288,7 +285,6 @@
     )!.firstElementChild!;
     MockInteractions.tap(publishCommentsOnPush);
 
-    assert.isFalse(element._menuChanged);
     assert.isTrue(element._prefsChanged);
 
     stubRestApi('savePreferences').callsFake(prefs => {
@@ -299,7 +295,6 @@
     // Save the change.
     await element._handleSavePreferences();
     assert.isFalse(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
   });
 
   test('set new changes work-in-progress', async () => {
@@ -309,7 +304,6 @@
     )!.firstElementChild!;
     MockInteractions.tap(newChangesWorkInProgress);
 
-    assert.isFalse(element._menuChanged);
     assert.isTrue(element._prefsChanged);
 
     stubRestApi('savePreferences').callsFake(prefs => {
@@ -320,40 +314,6 @@
     // Save the change.
     await element._handleSavePreferences();
     assert.isFalse(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
-  });
-
-  test('menu', async () => {
-    assert.isFalse(element._menuChanged);
-    assert.isFalse(element._prefsChanged);
-
-    assertMenusEqual(element._localMenu, preferences.my);
-
-    const menu = element.$.menu.firstElementChild!;
-    let tableRows = queryAll(menu, 'tbody tr');
-    // let tableRows = menu.root.querySelectorAll('tbody tr');
-    assert.equal(tableRows.length, preferences.my.length);
-
-    // Add a menu item:
-    element.splice('_localMenu', 1, 0, {name: 'foo', url: 'bar', target: ''});
-    flush();
-
-    // tableRows = menu.root.querySelectorAll('tbody tr');
-    tableRows = queryAll(menu, 'tbody tr');
-    assert.equal(tableRows.length, preferences.my.length + 1);
-
-    assert.isTrue(element._menuChanged);
-    assert.isFalse(element._prefsChanged);
-
-    stubRestApi('savePreferences').callsFake(prefs => {
-      assertMenusEqual(prefs.my, element._localMenu);
-      return Promise.resolve(createDefaultPreferences());
-    });
-
-    await element._handleSaveMenu();
-    assert.isFalse(element._menuChanged);
-    assert.isFalse(element._prefsChanged);
-    assertMenusEqual(element.prefs.my, element._localMenu);
   });
 
   test('add email validation', () => {
@@ -445,39 +405,6 @@
     assert.isTrue(element.prefs.legacycid_in_change_table);
   });
 
-  test('reset menu item back to default', async () => {
-    const originalMenu = {
-      ...createDefaultPreferences(),
-      my: [
-        {url: '/first/url', name: 'first name', target: '_blank'},
-        {url: '/second/url', name: 'second name', target: '_blank'},
-        {url: '/third/url', name: 'third name', target: '_blank'},
-      ] as TopMenuItemInfo[],
-    };
-
-    stubRestApi('getDefaultPreferences').returns(Promise.resolve(originalMenu));
-
-    const updatedMenu = [
-      {url: '/first/url', name: 'first name', target: '_blank'},
-      {url: '/second/url', name: 'second name', target: '_blank'},
-      {url: '/third/url', name: 'third name', target: '_blank'},
-      {url: '/fourth/url', name: 'fourth name', target: '_blank'},
-    ];
-
-    element.set('_localMenu', updatedMenu);
-
-    await element._handleResetMenuButton();
-    assertMenusEqual(element._localMenu, originalMenu.my);
-  });
-
-  test('test that reset button is called', () => {
-    const overlayOpen = sinon.stub(element, '_handleResetMenuButton');
-
-    MockInteractions.tap(element.$.resetButton);
-
-    assert.isTrue(overlayOpen.called);
-  });
-
   test('_showHttpAuth', () => {
     const serverConfig: ServerInfo = {
       ...createServerInfo(),
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
index 95d15b8..72c87b2 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
@@ -21,22 +21,19 @@
 import '../../shared/gr-overlay/gr-overlay';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-ssh-editor_html';
-import {property, customElement} from '@polymer/decorators';
 import {SshKeyInfo} from '../../../types/common';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {getAppContext} from '../../../services/app-context';
-
-export interface GrSshEditor {
-  $: {
-    addButton: GrButton;
-    newKey: IronAutogrowTextareaElement;
-    viewKeyOverlay: GrOverlay;
-  };
-}
+import {LitElement, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {css} from 'lit';
+import {BindValueChangeEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
+import {PropertyValues} from 'lit';
+import {formStyles} from '../../../styles/gr-form-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -44,85 +41,236 @@
   }
 }
 @customElement('gr-ssh-editor')
-export class GrSshEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Boolean, notify: true})
+export class GrSshEditor extends LitElement {
+  @property({type: Boolean})
   hasUnsavedChanges = false;
 
   @property({type: Array})
-  _keys: SshKeyInfo[] = [];
+  keys: SshKeyInfo[] = [];
 
   @property({type: Object})
-  _keyToView?: SshKeyInfo;
+  keyToView?: SshKeyInfo;
 
   @property({type: String})
-  _newKey = '';
+  newKey = '';
 
   @property({type: Array})
-  _keysToRemove: SshKeyInfo[] = [];
+  keysToRemove: SshKeyInfo[] = [];
+
+  @state() prevHasUnsavedChanges = false;
+
+  @query('#addButton') addButton!: GrButton;
+
+  @query('#newKey') newKeyEditor!: IronAutogrowTextareaElement;
+
+  @query('#viewKeyOverlay') viewKeyOverlay!: GrOverlay;
 
   private readonly restApiService = getAppContext().restApiService;
 
+  static override get styles() {
+    return [
+      sharedStyles,
+      formStyles,
+      css`
+        .statusHeader {
+          width: 4em;
+        }
+        .keyHeader {
+          width: 7.5em;
+        }
+        #viewKeyOverlay {
+          padding: var(--spacing-xxl);
+          width: 50em;
+        }
+        .publicKey {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+          overflow-x: scroll;
+          overflow-wrap: break-word;
+          width: 30em;
+        }
+        .closeButton {
+          bottom: 2em;
+          position: absolute;
+          right: 2em;
+        }
+        #existing {
+          margin-bottom: var(--spacing-l);
+        }
+        #existing .commentColumn {
+          min-width: 27em;
+          width: auto;
+        }
+      `,
+    ];
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('hasUnsavedChanges')) {
+      if (this.prevHasUnsavedChanges === this.hasUnsavedChanges) return;
+      this.prevHasUnsavedChanges = this.hasUnsavedChanges;
+      fire(this, 'has-unsaved-changes-changed', {
+        value: this.hasUnsavedChanges,
+      });
+    }
+  }
+
+  override render() {
+    return html`
+      <div class="gr-form-styles">
+        <fieldset id="existing">
+          <table>
+            <thead>
+              <tr>
+                <th class="commentColumn">Comment</th>
+                <th class="statusHeader">Status</th>
+                <th class="keyHeader">Public key</th>
+                <th></th>
+                <th></th>
+              </tr>
+            </thead>
+            <tbody>
+              ${this.keys.map((key, index) => this.renderKey(key, index))}
+            </tbody>
+          </table>
+          <gr-overlay id="viewKeyOverlay" with-backdrop="">
+            <fieldset>
+              <section>
+                <span class="title">Algorithm</span>
+                <span class="value">${this.keyToView?.algorithm}</span>
+              </section>
+              <section>
+                <span class="title">Public key</span>
+                <span class="value publicKey"
+                  >${this.keyToView?.encoded_key}</span
+                >
+              </section>
+              <section>
+                <span class="title">Comment</span>
+                <span class="value">${this.keyToView?.comment}</span>
+              </section>
+            </fieldset>
+            <gr-button
+              class="closeButton"
+              @click=${() => this.viewKeyOverlay.close()}
+              >Close</gr-button
+            >
+          </gr-overlay>
+          <gr-button
+            @click=${() => this.save()}
+            ?disabled=${!this.hasUnsavedChanges}
+            >Save changes</gr-button
+          >
+        </fieldset>
+        <fieldset>
+          <section>
+            <span class="title">New SSH key</span>
+            <span class="value">
+              <iron-autogrow-textarea
+                id="newKey"
+                autocomplete="on"
+                .bindValue=${this.newKey}
+                placeholder="New SSH Key"
+                @bind-value-changed=${(e: BindValueChangeEvent) => {
+                  this.newKey = e.detail.value;
+                }}
+              ></iron-autogrow-textarea>
+            </span>
+          </section>
+          <gr-button
+            id="addButton"
+            link=""
+            ?disabled=${!this.newKey.length}
+            @click=${() => this.handleAddKey()}
+            >Add new SSH key</gr-button
+          >
+        </fieldset>
+      </div>
+    `;
+  }
+
+  private renderKey(key: SshKeyInfo, index: number) {
+    return html` <tr>
+      <td class="commentColumn">${key.comment}</td>
+      <td>${key.valid ? 'Valid' : 'Invalid'}</td>
+      <td>
+        <gr-button
+          link=""
+          @click=${(e: Event) => this.showKey(e)}
+          data-index=${index}
+          >Click to View</gr-button
+        >
+      </td>
+      <td>
+        <gr-copy-clipboard
+          hasTooltip=""
+          .buttonTitle=${'Copy SSH public key to clipboard'}
+          hideInput=""
+          .text=${key.ssh_public_key}
+        >
+        </gr-copy-clipboard>
+      </td>
+      <td>
+        <gr-button
+          link=""
+          data-index=${index}
+          @click=${(e: Event) => this.handleDeleteKey(e)}
+          >Delete</gr-button
+        >
+      </td>
+    </tr>`;
+  }
+
   loadData() {
     return this.restApiService.getAccountSSHKeys().then(keys => {
       if (!keys) return;
-      this._keys = keys;
+      this.keys = keys;
     });
   }
 
+  // private but used in tests
   save() {
-    const promises = this._keysToRemove.map(key =>
+    const promises = this.keysToRemove.map(key =>
       this.restApiService.deleteAccountSSHKey(`${key.seq}`)
     );
     return Promise.all(promises).then(() => {
-      this._keysToRemove = [];
+      this.keysToRemove = [];
       this.hasUnsavedChanges = false;
     });
   }
 
-  _getStatusLabel(isValid: boolean) {
-    return isValid ? 'Valid' : 'Invalid';
-  }
-
-  _showKey(e: Event) {
+  private showKey(e: Event) {
     const el = (dom(e) as EventApi).localTarget as GrButton;
     const index = Number(el.getAttribute('data-index')!);
-    this._keyToView = this._keys[index];
-    this.$.viewKeyOverlay.open();
+    this.keyToView = this.keys[index];
+    this.viewKeyOverlay.open();
   }
 
-  _closeOverlay() {
-    this.$.viewKeyOverlay.close();
-  }
-
-  _handleDeleteKey(e: Event) {
+  private handleDeleteKey(e: Event) {
     const el = (dom(e) as EventApi).localTarget as GrButton;
     const index = Number(el.getAttribute('data-index')!);
-    this.push('_keysToRemove', this._keys[index]);
-    this.splice('_keys', index, 1);
+    this.keysToRemove.push(this.keys[index]);
+    this.keys.splice(index, 1);
+    this.requestUpdate();
     this.hasUnsavedChanges = true;
   }
 
-  _handleAddKey() {
-    this.$.addButton.disabled = true;
-    this.$.newKey.disabled = true;
+  // private but used in tests
+  handleAddKey() {
+    this.addButton.disabled = true;
+    this.newKeyEditor.disabled = true;
     return this.restApiService
-      .addAccountSSHKey(this._newKey.trim())
+      .addAccountSSHKey(this.newKey.trim())
       .then(key => {
-        this.$.newKey.disabled = false;
-        this._newKey = '';
-        this.push('_keys', key);
+        this.newKeyEditor.disabled = false;
+        this.newKey = '';
+        this.keys.push(key);
+        this.requestUpdate();
       })
       .catch(() => {
-        this.$.addButton.disabled = false;
-        this.$.newKey.disabled = false;
+        this.addButton.disabled = false;
+        this.newKeyEditor.disabled = false;
       });
   }
-
-  _computeAddButtonDisabled(newKey: string) {
-    return !newKey.length;
-  }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
deleted file mode 100644
index e853b58..0000000
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_html.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    .statusHeader {
-      width: 4em;
-    }
-    .keyHeader {
-      width: 7.5em;
-    }
-    #viewKeyOverlay {
-      padding: var(--spacing-xxl);
-      width: 50em;
-    }
-    .publicKey {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      overflow-x: scroll;
-      overflow-wrap: break-word;
-      width: 30em;
-    }
-    .closeButton {
-      bottom: 2em;
-      position: absolute;
-      right: 2em;
-    }
-    #existing {
-      margin-bottom: var(--spacing-l);
-    }
-    #existing .commentColumn {
-      min-width: 27em;
-      width: auto;
-    }
-  </style>
-  <div class="gr-form-styles">
-    <fieldset id="existing">
-      <table>
-        <thead>
-          <tr>
-            <th class="commentColumn">Comment</th>
-            <th class="statusHeader">Status</th>
-            <th class="keyHeader">Public key</th>
-            <th></th>
-            <th></th>
-          </tr>
-        </thead>
-        <tbody>
-          <template is="dom-repeat" items="[[_keys]]" as="key">
-            <tr>
-              <td class="commentColumn">[[key.comment]]</td>
-              <td>[[_getStatusLabel(key.valid)]]</td>
-              <td>
-                <gr-button link="" on-click="_showKey" data-index$="[[index]]"
-                  >Click to View</gr-button
-                >
-              </td>
-              <td>
-                <gr-copy-clipboard
-                  hasTooltip=""
-                  buttonTitle="Copy SSH public key to clipboard"
-                  hideInput=""
-                  text="[[key.ssh_public_key]]"
-                >
-                </gr-copy-clipboard>
-              </td>
-              <td>
-                <gr-button
-                  link=""
-                  data-index$="[[index]]"
-                  on-click="_handleDeleteKey"
-                  >Delete</gr-button
-                >
-              </td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
-      <gr-overlay id="viewKeyOverlay" with-backdrop="">
-        <fieldset>
-          <section>
-            <span class="title">Algorithm</span>
-            <span class="value">[[_keyToView.algorithm]]</span>
-          </section>
-          <section>
-            <span class="title">Public key</span>
-            <span class="value publicKey">[[_keyToView.encoded_key]]</span>
-          </section>
-          <section>
-            <span class="title">Comment</span>
-            <span class="value">[[_keyToView.comment]]</span>
-          </section>
-        </fieldset>
-        <gr-button class="closeButton" on-click="_closeOverlay"
-          >Close</gr-button
-        >
-      </gr-overlay>
-      <gr-button on-click="save" disabled$="[[!hasUnsavedChanges]]"
-        >Save changes</gr-button
-      >
-    </fieldset>
-    <fieldset>
-      <section>
-        <span class="title">New SSH key</span>
-        <span class="value">
-          <iron-autogrow-textarea
-            id="newKey"
-            autocomplete="on"
-            bind-value="{{_newKey}}"
-            placeholder="New SSH Key"
-          ></iron-autogrow-textarea>
-        </span>
-      </section>
-      <gr-button
-        id="addButton"
-        link=""
-        disabled$="[[_computeAddButtonDisabled(_newKey)]]"
-        on-click="_handleAddKey"
-        >Add new SSH key</gr-button
-      >
-    </fieldset>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
index 6300b76..0f81e9f 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
@@ -81,7 +81,7 @@
       Promise.resolve()
     );
 
-    assert.equal(element._keysToRemove.length, 0);
+    assert.equal(element.keysToRemove.length, 0);
     assert.isFalse(element.hasUnsavedChanges);
 
     // Get the delete button for the last row.
@@ -92,21 +92,21 @@
 
     MockInteractions.tap(button!);
 
-    assert.equal(element._keys.length, 1);
-    assert.equal(element._keysToRemove.length, 1);
-    assert.equal(element._keysToRemove[0], lastKey);
+    assert.equal(element.keys.length, 1);
+    assert.equal(element.keysToRemove.length, 1);
+    assert.equal(element.keysToRemove[0], lastKey);
     assert.isTrue(element.hasUnsavedChanges);
     assert.isFalse(saveStub.called);
 
     await element.save();
     assert.isTrue(saveStub.called);
     assert.equal(saveStub.lastCall.args[0], `${lastKey.seq}`);
-    assert.equal(element._keysToRemove.length, 0);
+    assert.equal(element.keysToRemove.length, 0);
     assert.isFalse(element.hasUnsavedChanges);
   });
 
   test('show key', () => {
-    const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
+    const openSpy = sinon.spy(element.viewKeyOverlay, 'open');
 
     // Get the show button for the last row.
     const button = query<GrButton>(
@@ -116,7 +116,7 @@
 
     MockInteractions.tap(button!);
 
-    assert.equal(element._keyToView, keys[1]);
+    assert.equal(element.keyToView, keys[1]);
     assert.isTrue(openSpy.called);
   });
 
@@ -133,21 +133,23 @@
 
     const addStub = stubRestApi('addAccountSSHKey').resolves(newKeyObject);
 
-    element._newKey = newKeyString;
+    element.newKey = newKeyString;
 
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
+    await element.updateComplete;
+
+    assert.isFalse(element.addButton.disabled);
+    assert.isFalse(element.newKeyEditor.disabled);
 
     const promise = mockPromise();
-    element._handleAddKey().then(() => {
-      assert.isTrue(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 3);
+    element.handleAddKey().then(() => {
+      assert.isTrue(element.addButton.disabled);
+      assert.isFalse(element.newKeyEditor.disabled);
+      assert.equal(element.keys.length, 3);
       promise.resolve();
     });
 
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
+    assert.isTrue(element.addButton.disabled);
+    assert.isTrue(element.newKeyEditor.disabled);
 
     assert.isTrue(addStub.called);
     assert.equal(addStub.lastCall.args[0], newKeyString);
@@ -159,21 +161,23 @@
 
     const addStub = stubRestApi('addAccountSSHKey').rejects(new Error('error'));
 
-    element._newKey = newKeyString;
+    element.newKey = newKeyString;
 
-    assert.isFalse(element.$.addButton.disabled);
-    assert.isFalse(element.$.newKey.disabled);
+    await element.updateComplete;
+
+    assert.isFalse(element.addButton.disabled);
+    assert.isFalse(element.newKeyEditor.disabled);
 
     const promise = mockPromise();
-    element._handleAddKey().then(() => {
-      assert.isFalse(element.$.addButton.disabled);
-      assert.isFalse(element.$.newKey.disabled);
-      assert.equal(element._keys.length, 2);
+    element.handleAddKey().then(() => {
+      assert.isFalse(element.addButton.disabled);
+      assert.isFalse(element.newKeyEditor.disabled);
+      assert.equal(element.keys.length, 2);
       promise.resolve();
     });
 
-    assert.isTrue(element.$.addButton.disabled);
-    assert.isTrue(element.$.newKey.disabled);
+    assert.isTrue(element.addButton.disabled);
+    assert.isTrue(element.newKeyEditor.disabled);
 
     assert.isTrue(addStub.called);
     assert.equal(addStub.lastCall.args[0], newKeyString);
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
index 32ca2c5..2eb06a5 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
@@ -17,23 +17,28 @@
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-button/gr-button';
-import '../../../styles/gr-form-styles';
-import '../../../styles/shared-styles';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-watched-projects-editor_html';
-import {customElement, property} from '@polymer/decorators';
+import {customElement, property, query} from 'lit/decorators';
 import {
   AutocompleteQuery,
   GrAutocomplete,
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
-import {hasOwnProperty} from '../../../utils/common-util';
-import {ProjectWatchInfo} from '../../../types/common';
+import {assertIsDefined} from '../../../utils/common-util';
+import {ProjectWatchInfo, RepoName} from '../../../types/common';
 import {getAppContext} from '../../../services/app-context';
-import {IronInputElement} from '@polymer/iron-input';
+import {css, html, LitElement} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {when} from 'lit/directives/when';
+import {fire} from '../../../utils/event-util';
 
-const NOTIFICATION_TYPES = [
+type PropertiesOfType<Source, Type> = {
+  [K in keyof Source]: Source[K] extends Type ? K : never;
+}[keyof Source];
+
+type NotificationKey = PropertiesOfType<Required<ProjectWatchInfo>, boolean>;
+
+const NOTIFICATION_TYPES: Array<{name: string; key: NotificationKey}> = [
   {name: 'Changes', key: 'notify_new_changes'},
   {name: 'Patches', key: 'notify_new_patch_sets'},
   {name: 'Comments', key: 'notify_all_comments'},
@@ -41,50 +46,145 @@
   {name: 'Abandons', key: 'notify_abandoned_changes'},
 ];
 
-export interface GrWatchedProjectsEditor {
-  $: {
-    newFilter: HTMLInputElement;
-    newFilterInput: IronInputElement;
-    newProject: GrAutocomplete;
-  };
-}
-
 @customElement('gr-watched-projects-editor')
-export class GrWatchedProjectsEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrWatchedProjectsEditor extends LitElement {
+  // Private but used in tests.
+  @query('#newFilter')
+  newFilter?: HTMLInputElement;
 
-  @property({type: Boolean, notify: true})
+  // Private but used in tests.
+  @query('#newProject')
+  newProject?: GrAutocomplete;
+
+  @property({type: Boolean})
   hasUnsavedChanges = false;
 
   @property({type: Array})
-  _projects?: ProjectWatchInfo[];
+  projects?: ProjectWatchInfo[];
 
   @property({type: Array})
-  _projectsToRemove: ProjectWatchInfo[] = [];
+  projectsToRemove: ProjectWatchInfo[] = [];
 
-  @property({type: Object})
-  _query: AutocompleteQuery;
+  private readonly query: AutocompleteQuery = input =>
+    this.getProjectSuggestions(input);
 
   private readonly restApiService = getAppContext().restApiService;
 
-  constructor() {
-    super();
-    this._query = input => this._getProjectSuggestions(input);
+  static override get styles() {
+    return [
+      sharedStyles,
+      formStyles,
+      css`
+        #watchedProjects .notifType {
+          text-align: center;
+          padding: 0 var(--spacing-s);
+        }
+        .notifControl {
+          cursor: pointer;
+          text-align: center;
+        }
+        .notifControl:hover {
+          outline: 1px solid var(--border-color);
+        }
+        .projectFilter {
+          color: var(--deemphasized-text-color);
+          font-style: italic;
+          margin-left: var(--spacing-l);
+        }
+        .newFilterInput {
+          width: 100%;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const types = NOTIFICATION_TYPES;
+    return html` <div class="gr-form-styles">
+      <table id="watchedProjects">
+        <thead>
+          <tr>
+            <th>Repo</th>
+            ${types.map(type => html`<th class="notifType">${type.name}</th>`)}
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          ${(this.projects ?? []).map(project => this.renderProject(project))}
+        </tbody>
+        <tfoot>
+          <tr>
+            <th>
+              <gr-autocomplete
+                id="newProject"
+                query=${this.query}
+                threshold="1"
+                allow-non-suggested-values=""
+                tab-complete=""
+                placeholder="Repo"
+              ></gr-autocomplete>
+            </th>
+            <th colspan=${types.length}>
+              <iron-input id="newFilterInput" class="newFilterInput">
+                <input
+                  id="newFilter"
+                  class="newFilterInput"
+                  placeholder="branch:name, or other search expression"
+                />
+              </iron-input>
+            </th>
+            <th>
+              <gr-button link="" @click=${this.handleAddProject}>Add</gr-button>
+            </th>
+          </tr>
+        </tfoot>
+      </table>
+    </div>`;
+  }
+
+  private renderProject(project: ProjectWatchInfo) {
+    const types = NOTIFICATION_TYPES;
+    return html` <tr>
+      <td>
+        ${project.project}
+        ${when(
+          project.filter,
+          () => html`<div class="projectFilter">${project.filter}</div>`
+        )}
+      </td>
+      ${types.map(type => this.renderNotifyControl(project, type.key))}
+      <td>
+        <gr-button
+          link=""
+          @click=${(_e: Event) => this.handleRemoveProject(project)}
+          >Delete</gr-button
+        >
+      </td>
+    </tr>`;
+  }
+
+  private renderNotifyControl(project: ProjectWatchInfo, key: NotificationKey) {
+    return html` <td class="notifControl" @click=${this.handleNotifCellClick}>
+      <input
+        type="checkbox"
+        data-key=${key}
+        @change=${(e: Event) => this.handleCheckboxChange(project, key, e)}
+        ?checked=${!!project[key]}
+      />
+    </td>`;
   }
 
   loadData() {
     return this.restApiService.getWatchedProjects().then(projs => {
-      this._projects = projs;
+      this.projects = projs;
     });
   }
 
   save() {
     let deletePromise: Promise<Response | undefined>;
-    if (this._projectsToRemove.length) {
+    if (this.projectsToRemove.length) {
       deletePromise = this.restApiService.deleteWatchedProjects(
-        this._projectsToRemove
+        this.projectsToRemove
       );
     } else {
       deletePromise = Promise.resolve(undefined);
@@ -92,32 +192,21 @@
 
     return deletePromise
       .then(() => {
-        if (this._projects) {
-          return this.restApiService.saveWatchedProjects(this._projects);
+        if (this.projects) {
+          return this.restApiService.saveWatchedProjects(this.projects);
         } else {
           return Promise.resolve(undefined);
         }
       })
       .then(projects => {
-        this._projects = projects;
-        this._projectsToRemove = [];
-        this.hasUnsavedChanges = false;
+        this.projects = projects;
+        this.projectsToRemove = [];
+        this.setHasUnsavedChanges(false);
       });
   }
 
-  _getTypes() {
-    return NOTIFICATION_TYPES;
-  }
-
-  _getTypeCount() {
-    return this._getTypes().length;
-  }
-
-  _computeCheckboxChecked(project: ProjectWatchInfo, key: string) {
-    return hasOwnProperty(project, key);
-  }
-
-  _getProjectSuggestions(input: string) {
+  // private but used in tests.
+  getProjectSuggestions(input: string) {
     return this.restApiService.getSuggestedProjects(input).then(response => {
       const projects: AutocompleteSuggestion[] = [];
       for (const [name, project] of Object.entries(response ?? {})) {
@@ -127,18 +216,18 @@
     });
   }
 
-  _handleRemoveProject(e: Event) {
-    const el = (dom(e) as EventApi).localTarget as HTMLInputElement;
-    const dataIndex = el.getAttribute('data-index');
-    if (dataIndex === null || !this._projects) return;
-    const index = Number(dataIndex);
-    const project = this._projects[index];
-    this.splice('_projects', index, 1);
-    this.push('_projectsToRemove', project);
-    this.hasUnsavedChanges = true;
+  private handleRemoveProject(project: ProjectWatchInfo) {
+    if (!this.projects) return;
+    const index = this.projects.indexOf(project);
+    if (index < 0) return;
+    this.projects.splice(index, 1);
+    this.projectsToRemove.push(project);
+    this.requestUpdate();
+    this.setHasUnsavedChanges(true);
   }
 
-  _canAddProject(
+  // private but used in tests.
+  canAddProject(
     project: string | null,
     text: string | null,
     filter: string | null
@@ -152,12 +241,12 @@
       return true;
     }
 
-    if (!this._projects) return true;
+    if (!this.projects) return true;
     // Check if the project with filter is already in the list.
-    for (let i = 0; i < this._projects.length; i++) {
+    for (let i = 0; i < this.projects.length; i++) {
       if (
-        this._projects[i].project === project &&
-        this.areFiltersEqual(this._projects[i].filter, filter)
+        this.projects[i].project === project &&
+        this.areFiltersEqual(this.projects[i].filter, filter)
       ) {
         return false;
       }
@@ -166,14 +255,15 @@
     return true;
   }
 
-  _getNewProjectIndex(name: string, filter: string | null) {
-    if (!this._projects) return;
+  // private but used in tests.
+  getNewProjectIndex(name: string, filter: string | null) {
+    if (!this.projects) return;
     let i;
-    for (i = 0; i < this._projects.length; i++) {
-      const projectFilter = this._projects[i].filter;
+    for (i = 0; i < this.projects.length; i++) {
+      const projectFilter = this.projects[i].filter;
       if (
-        this._projects[i].project > name ||
-        (this._projects[i].project === name &&
+        this.projects[i].project > name ||
+        (this.projects[i].project === name &&
           this.isFilterDefined(projectFilter) &&
           this.isFilterDefined(filter) &&
           projectFilter! > filter!)
@@ -184,43 +274,47 @@
     return i;
   }
 
-  _handleAddProject() {
-    const newProject = this.$.newProject.value;
-    const newProjectName = this.$.newProject.text;
-    const filter = this.$.newFilter.value || null;
+  // Private but used in tests.
+  handleAddProject() {
+    assertIsDefined(this.newProject, 'newProject');
+    assertIsDefined(this.newFilter, 'newFilter');
+    const newProject = this.newProject.value;
+    const newProjectName = this.newProject.text as RepoName;
+    const filter = this.newFilter.value;
 
-    if (!this._canAddProject(newProject, newProjectName, filter)) {
+    if (!this.canAddProject(newProject, newProjectName, filter)) {
       return;
     }
 
-    const insertIndex = this._getNewProjectIndex(newProjectName, filter);
+    const insertIndex = this.getNewProjectIndex(newProjectName, filter);
 
     if (insertIndex !== undefined) {
-      this.splice('_projects', insertIndex, 0, {
+      this.projects?.splice(insertIndex, 0, {
         project: newProjectName,
         filter,
         _is_local: true,
       });
+      this.requestUpdate();
     }
 
-    this.$.newProject.clear();
-    this.$.newFilter.value = '';
-    this.hasUnsavedChanges = true;
+    this.newProject.clear();
+    this.newFilter.value = '';
+    this.setHasUnsavedChanges(true);
   }
 
-  _handleCheckboxChange(e: Event) {
-    const el = (dom(e) as EventApi).localTarget as HTMLInputElement;
-    if (el === null) return;
-    const dataIndex = el.getAttribute('data-index');
-    const key = el.getAttribute('data-key');
-    if (dataIndex === null || key === null) return;
-    const index = Number(dataIndex);
+  private handleCheckboxChange(
+    project: ProjectWatchInfo,
+    key: NotificationKey,
+    e: Event
+  ) {
+    const el = e.target as HTMLInputElement;
     const checked = el.checked;
-    this.set(['_projects', index, key], !!checked);
-    this.hasUnsavedChanges = true;
+    project[key] = !!checked;
+    this.requestUpdate();
+    this.setHasUnsavedChanges(true);
   }
 
-  _handleNotifCellClick(e: Event) {
+  private handleNotifCellClick(e: Event) {
     if (e.target === null) return;
     const checkbox = (e.target as HTMLElement).querySelector('input');
     if (checkbox) {
@@ -228,6 +322,11 @@
     }
   }
 
+  private setHasUnsavedChanges(value: boolean) {
+    this.hasUnsavedChanges = value;
+    fire(this, 'has-unsaved-changes-changed', {value});
+  }
+
   isFilterDefined(filter: string | null | undefined) {
     return filter !== null && filter !== undefined;
   }
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
deleted file mode 100644
index fb65a03..0000000
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    #watchedProjects .notifType {
-      text-align: center;
-      padding: 0 var(--spacing-s);
-    }
-    .notifControl {
-      cursor: pointer;
-      text-align: center;
-    }
-    .notifControl:hover {
-      outline: 1px solid var(--border-color);
-    }
-    .projectFilter {
-      color: var(--deemphasized-text-color);
-      font-style: italic;
-      margin-left: var(--spacing-l);
-    }
-    .newFilterInput {
-      width: 100%;
-    }
-  </style>
-  <div class="gr-form-styles">
-    <table id="watchedProjects">
-      <thead>
-        <tr>
-          <th>Repo</th>
-          <template is="dom-repeat" items="[[_getTypes()]]">
-            <th class="notifType">[[item.name]]</th>
-          </template>
-          <th></th>
-        </tr>
-      </thead>
-      <tbody>
-        <template
-          is="dom-repeat"
-          items="[[_projects]]"
-          as="project"
-          index-as="projectIndex"
-        >
-          <tr>
-            <td>
-              [[project.project]]
-              <template is="dom-if" if="[[project.filter]]">
-                <div class="projectFilter">[[project.filter]]</div>
-              </template>
-            </td>
-            <template is="dom-repeat" items="[[_getTypes()]]" as="type">
-              <td class="notifControl" on-click="_handleNotifCellClick">
-                <input
-                  type="checkbox"
-                  data-index$="[[projectIndex]]"
-                  data-key$="[[type.key]]"
-                  on-change="_handleCheckboxChange"
-                  checked$="[[_computeCheckboxChecked(project, type.key)]]"
-                />
-              </td>
-            </template>
-            <td>
-              <gr-button
-                link=""
-                data-index$="[[projectIndex]]"
-                on-click="_handleRemoveProject"
-                >Delete</gr-button
-              >
-            </td>
-          </tr>
-        </template>
-      </tbody>
-      <tfoot>
-        <tr>
-          <th>
-            <gr-autocomplete
-              id="newProject"
-              query="[[_query]]"
-              threshold="1"
-              allow-non-suggested-values=""
-              tab-complete=""
-              placeholder="Repo"
-            ></gr-autocomplete>
-          </th>
-          <th colspan$="[[_getTypeCount()]]">
-            <iron-input
-              id="newFilterInput"
-              class="newFilterInput"
-              placeholder="branch:name, or other search expression"
-            >
-              <input
-                id="newFilter"
-                class="newFilterInput"
-                placeholder="branch:name, or other search expression"
-              />
-            </iron-input>
-          </th>
-          <th>
-            <gr-button link="" on-click="_handleAddProject">Add</gr-button>
-          </th>
-        </tr>
-      </tfoot>
-    </table>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
index c0580f6..bc21460 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
@@ -22,6 +22,8 @@
 import {ProjectWatchInfo} from '../../../types/common';
 import {queryAll, queryAndAssert} from '../../../test/test-utils';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {IronInputElement} from '@polymer/iron-input';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const basicFixture = fixtureFromElement('gr-watched-projects-editor');
 
@@ -70,7 +72,7 @@
     element = basicFixture.instantiate();
 
     await element.loadData();
-    await flush();
+    await element.updateComplete;
   });
 
   test('renders', () => {
@@ -101,69 +103,70 @@
     assert.equal(checkedKeys[2], 'notify_all_comments');
   });
 
-  test('_getProjectSuggestions empty', async () => {
-    const projects = await element._getProjectSuggestions('nonexistent');
+  test('getProjectSuggestions empty', async () => {
+    const projects = await element.getProjectSuggestions('nonexistent');
     assert.equal(projects.length, 0);
   });
 
-  test('_getProjectSuggestions non-empty', async () => {
-    const projects = await element._getProjectSuggestions('the project');
+  test('getProjectSuggestions non-empty', async () => {
+    const projects = await element.getProjectSuggestions('the project');
     assert.equal(projects.length, 1);
     assert.equal(projects[0].name, 'the project');
   });
 
-  test('_getProjectSuggestions non-empty with two letter project', async () => {
-    const projects = await element._getProjectSuggestions('th');
+  test('getProjectSuggestions non-empty with two letter project', async () => {
+    const projects = await element.getProjectSuggestions('th');
     assert.equal(projects.length, 1);
     assert.equal(projects[0].name, 'the project');
   });
 
   test('_canAddProject', () => {
-    assert.isFalse(element._canAddProject(null, null, null));
+    assert.isFalse(element.canAddProject(null, null, null));
 
     // Can add a project that is not in the list.
-    assert.isTrue(element._canAddProject('project d', null, null));
-    assert.isTrue(element._canAddProject('project d', null, 'filter 3'));
+    assert.isTrue(element.canAddProject('project d', null, null));
+    assert.isTrue(element.canAddProject('project d', null, 'filter 3'));
 
     // Cannot add a project that is in the list with no filter.
-    assert.isFalse(element._canAddProject('project a', null, null));
+    assert.isFalse(element.canAddProject('project a', null, null));
 
     // Can add a project that is in the list if the filter differs.
-    assert.isTrue(element._canAddProject('project a', null, 'filter 4'));
+    assert.isTrue(element.canAddProject('project a', null, 'filter 4'));
 
     // Cannot add a project that is in the list with the same filter.
-    assert.isFalse(element._canAddProject('project b', null, 'filter 1'));
-    assert.isFalse(element._canAddProject('project b', null, 'filter 2'));
+    assert.isFalse(element.canAddProject('project b', null, 'filter 1'));
+    assert.isFalse(element.canAddProject('project b', null, 'filter 2'));
 
     // Can add a project that is in the list using a new filter.
-    assert.isTrue(element._canAddProject('project b', null, 'filter 3'));
+    assert.isTrue(element.canAddProject('project b', null, 'filter 3'));
 
     // Can add a project that is not added by the auto complete
-    assert.isTrue(element._canAddProject(null, 'test', null));
+    assert.isTrue(element.canAddProject(null, 'test', null));
   });
 
-  test('_getNewProjectIndex', () => {
+  test('getNewProjectIndex', () => {
     // Projects are sorted in ASCII order.
-    assert.equal(element._getNewProjectIndex('project A', 'filter'), 0);
-    assert.equal(element._getNewProjectIndex('project a', 'filter'), 1);
+    assert.equal(element.getNewProjectIndex('project A', 'filter'), 0);
+    assert.equal(element.getNewProjectIndex('project a', 'filter'), 1);
 
     // Projects are sorted by filter when the names are equal
-    assert.equal(element._getNewProjectIndex('project b', 'filter 0'), 1);
-    assert.equal(element._getNewProjectIndex('project b', 'filter 1.5'), 2);
-    assert.equal(element._getNewProjectIndex('project b', 'filter 3'), 3);
+    assert.equal(element.getNewProjectIndex('project b', 'filter 0'), 1);
+    assert.equal(element.getNewProjectIndex('project b', 'filter 1.5'), 2);
+    assert.equal(element.getNewProjectIndex('project b', 'filter 3'), 3);
 
     // Projects with filters follow those without
-    assert.equal(element._getNewProjectIndex('project c', 'filter'), 4);
+    assert.equal(element.getNewProjectIndex('project c', 'filter'), 4);
   });
 
-  test('_handleAddProject', () => {
-    element.$.newProject.value = 'project d';
-    element.$.newProject.setText('project d');
-    element.$.newFilterInput.bindValue = '';
+  test('handleAddProject', () => {
+    assertIsDefined(element.newProject, 'newProject');
+    element.newProject.value = 'project d';
+    element.newProject.setText('project d');
+    queryAndAssert<IronInputElement>(element, '#newFilterInput').bindValue = '';
 
-    element._handleAddProject();
+    element.handleAddProject();
 
-    const projects = element._projects!;
+    const projects = element.projects!;
     assert.equal(projects.length, 5);
     assert.equal(projects[4].project, 'project d');
     assert.isNotOk(projects[4].filter);
@@ -171,18 +174,21 @@
   });
 
   test('_handleAddProject with invalid inputs', () => {
-    element.$.newProject.value = 'project b';
-    element.$.newProject.setText('project b');
-    element.$.newFilterInput.bindValue = 'filter 1';
-    element.$.newFilter.value = 'filter 1';
+    assertIsDefined(element.newProject, 'newProject');
+    element.newProject.value = 'project b';
+    element.newProject.setText('project b');
+    queryAndAssert<IronInputElement>(element, '#newFilterInput').bindValue =
+      'filter 1';
+    assertIsDefined(element.newFilter, 'newFilter');
+    element.newFilter.value = 'filter 1';
 
-    element._handleAddProject();
+    element.handleAddProject();
 
-    assert.equal(element._projects!.length, 4);
+    assert.equal(element.projects!.length, 4);
   });
 
-  test('_handleRemoveProject', () => {
-    assert.deepEqual(element._projectsToRemove, []);
+  test('_handleRemoveProject', async () => {
+    assert.deepEqual(element.projectsToRemove, []);
 
     const button = queryAndAssert(
       element,
@@ -190,13 +196,13 @@
     );
     MockInteractions.tap(button);
 
-    flush();
+    await element.updateComplete;
 
     const rows = queryAndAssert(element, 'table tbody').querySelectorAll('tr');
 
     assert.equal(rows.length, 3);
 
-    assert.equal(element._projectsToRemove.length, 1);
-    assert.equal(element._projectsToRemove[0].project, 'project b');
+    assert.equal(element.projectsToRemove.length, 1);
+    assert.equal(element.projectsToRemove[0].project, 'project b');
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
index 28de23c..689a9fb 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
@@ -190,17 +190,17 @@
     `;
     return html`${customStyle}
       <div
-        class="${classMap({
+        class=${classMap({
           ...this.computeVoteClasses(),
           container: true,
           transparentBackground: this.transparentBackground,
           closeShown: this.removable,
-        })}"
+        })}
       >
         <div>
           <gr-account-label
-            .account="${this.account}"
-            .change="${this.change}"
+            .account=${this.account}
+            .change=${this.change}
             ?forceAttention=${this.forceAttention}
             ?highlightAttention=${this.highlightAttention}
             .voteableText=${this.voteableText}
@@ -214,10 +214,10 @@
           link=""
           ?hidden=${!this.removable}
           aria-label="Remove"
-          class="${classMap({
+          class=${classMap({
             remove: true,
             transparentBackground: this.transparentBackground,
-          })}"
+          })}
           @click=${this._handleRemoveTap}
         >
           <iron-icon icon="gr-icons:close"></iron-icon>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index f38f3f13..a505632 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -230,14 +230,14 @@
                 false,
                 this._selfAccount
               )}
-              title="${this.computeAttentionIconTitle(
+              title=${this.computeAttentionIconTitle(
                 highlightAttention,
                 account,
                 change,
                 forceAttention,
                 this.selected,
                 this._selfAccount
-              )}"
+              )}
             >
               <gr-button
                 id="attentionButton"
@@ -260,16 +260,13 @@
           : ''}
         ${this.maybeRenderLink(html`
           <span
-            class="${classMap({
+            class=${classMap({
               hovercardTargetWrapper: true,
               hasAttention: this.attentionIconShown,
-            })}"
+            })}
           >
             ${this.avatarShown
-              ? html`<gr-avatar
-                  .account="${account}"
-                  imageSize="32"
-                ></gr-avatar>`
+              ? html`<gr-avatar .account=${account} imageSize="32"></gr-avatar>`
               : ''}
             <span
               tabindex=${this.hideHovercard ? '-1' : '0'}
@@ -310,7 +307,7 @@
         `${this.account._account_id}`
     );
     if (!url) return span;
-    return html`<a class="ownerLink" href="${url}" tabindex="-1">${span}</a>`;
+    return html`<a class="ownerLink" href=${url} tabindex="-1">${span}</a>`;
   }
 
   private renderAccountStatusPlugins() {
@@ -324,7 +321,7 @@
       >
         <gr-endpoint-param
           name="accountId"
-          .value="${this.account._account_id}"
+          .value=${this.account._account_id}
         ></gr-endpoint-param>
         <span class="rightSidePadding"></span>
       </gr-endpoint-decorator>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index 5f0cf7a..c63a509 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -27,6 +27,8 @@
   AccountInfo,
   GroupInfo,
   EmailAddress,
+  SuggestedReviewerGroupInfo,
+  SuggestedReviewerAccountInfo,
 } from '../../../types/common';
 import {
   ReviewerSuggestionsProvider,
@@ -53,31 +55,23 @@
   };
 }
 
-/**
- * For item added with account info
- */
-export interface AccountObjectInput {
-  account: AccountInfo;
-}
-
-/**
- * For item added with group info
- */
-export interface GroupObjectInput {
-  group: GroupInfo;
-  confirm: boolean;
-}
-
 /** Supported input to be added */
-export type RawAccountInput = string | AccountObjectInput | GroupObjectInput;
+export type RawAccountInput =
+  | string
+  | SuggestedReviewerAccountInfo
+  | SuggestedReviewerGroupInfo;
 
-// type guards for AccountObjectInput and GroupObjectInput
-function isAccountObject(x: RawAccountInput): x is AccountObjectInput {
-  return !!(x as AccountObjectInput).account;
+// type guards for SuggestedReviewerAccountInfo and SuggestedReviewerGroupInfo
+function isAccountObject(
+  x: RawAccountInput
+): x is SuggestedReviewerAccountInfo {
+  return !!(x as SuggestedReviewerAccountInfo).account;
 }
 
-function isGroupObjectInput(x: RawAccountInput): x is GroupObjectInput {
-  return !!(x as GroupObjectInput).group;
+function isSuggestedReviewerGroupInfo(
+  x: RawAccountInput
+): x is SuggestedReviewerGroupInfo {
+  return !!(x as SuggestedReviewerGroupInfo).group;
 }
 
 // Internal input type with account info
@@ -150,7 +144,7 @@
    * Needed for template checking since value is initially set to null.
    */
   @property({type: Object, notify: true})
-  pendingConfirmation: GroupObjectInput | null = null;
+  pendingConfirmation: SuggestedReviewerGroupInfo | null = null;
 
   @property({type: Boolean})
   readonly = false;
@@ -225,7 +219,7 @@
       this.removeFromPendingRemoval(account);
       this.push('accounts', account);
       itemTypeAdded = 'account';
-    } else if (isGroupObjectInput(item)) {
+    } else if (isSuggestedReviewerGroupInfo(item)) {
       if (item.confirm) {
         this.pendingConfirmation = item;
         return;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
index 23e5a72..d77dec3 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
@@ -25,8 +25,9 @@
   AccountId,
   AccountInfo,
   EmailAddress,
+  GroupBaseInfo,
   GroupId,
-  GroupInfo,
+  GroupName,
   SuggestedReviewerAccountInfo,
   Suggestion,
 } from '../../../types/common';
@@ -64,11 +65,12 @@
       _account_id: accountId as AccountId,
     };
   };
-  const makeGroup: () => GroupInfo = function () {
+  const makeGroup: () => GroupBaseInfo = function () {
     const groupId = `group${++_nextAccountId}`;
     return {
       id: groupId as GroupId,
       _group: true,
+      name: 'abcd' as GroupName,
     };
   };
 
@@ -116,7 +118,7 @@
 
     // New accounts are added to end with pendingAdd class.
     const newAccount = makeAccount();
-    handleAdd({account: newAccount});
+    handleAdd({account: newAccount, count: 1});
     flush();
     chips = getChips();
     assert.equal(chips.length, 3);
@@ -160,7 +162,7 @@
 
     // New groups are added to end with pendingAdd and group classes.
     const newGroup = makeGroup();
-    handleAdd({group: newGroup, confirm: false});
+    handleAdd({group: newGroup, confirm: false, count: 1});
     flush();
     chips = getChips();
     assert.equal(chips.length, 2);
@@ -301,9 +303,9 @@
     assert.equal(element.additions().length, 0);
 
     const newAccount = makeAccount();
-    handleAdd({account: newAccount});
+    handleAdd({account: newAccount, count: 1});
     const newGroup = makeGroup();
-    handleAdd({group: newGroup, confirm: false});
+    handleAdd({group: newGroup, confirm: false, count: 1});
 
     assert.deepEqual(element.additions(), [
       {
@@ -317,6 +319,7 @@
           id: newGroup.id,
           _group: true,
           _pendingAdd: true,
+          name: 'abcd' as GroupName,
         },
       },
     ]);
@@ -346,6 +349,7 @@
           _group: true,
           _pendingAdd: true,
           confirmed: true,
+          name: 'abcd' as GroupName,
         },
       },
     ]);
@@ -362,7 +366,7 @@
   test('max-count', () => {
     element.maxCount = 1;
     const acct = makeAccount();
-    handleAdd({account: acct});
+    handleAdd({account: acct, count: 1});
     flush();
     assert.isTrue(element.$.entry.hasAttribute('hidden'));
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
index fa547dc..50ca7a7 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
@@ -99,7 +99,7 @@
         <gr-button
           link=""
           class="action"
-          ?hidden="${this._hideActionButton}"
+          ?hidden=${this._hideActionButton}
           @click=${this._handleActionTap}
           >${actionText}
         </gr-button>
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index 89bcbc7..7e7dca6 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -209,15 +209,15 @@
 
   override render() {
     return html`<paper-button
-      ?raised="${!this.link && !this.flatten}"
-      ?disabled="${this.disabled || this.loading}"
+      ?raised=${!this.link && !this.flatten}
+      ?disabled=${this.disabled || this.loading}
       role="button"
       tabindex="-1"
       part="paper-button"
-      class="${classMap({
+      class=${classMap({
         voteChip: this.voteChip && !this.isSubmitRequirementsUiEnabled,
         newVoteChip: this.voteChip && this.isSubmitRequirementsUiEnabled,
-      })}"
+      })}
     >
       ${this.loading ? html`<span class="loadingSpin"></span>` : ''}
       <slot></slot>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
index d6f6300..dd6077c 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
@@ -165,7 +165,7 @@
     }
 
     return html`
-      <a class="status-link" href="${this.getStatusLink()}">
+      <a class="status-link" href=${this.getStatusLink()}>
         <div class="chip" aria-label="Label: ${this.status}">
           ${this.computeStatusString()}
           ${this.showResolveIcon()
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 5a92361..471ebd6 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -438,9 +438,7 @@
     return html`
       ${this.renderFileName()}
       <div class="pathInfo">
-        ${href
-          ? html`<a href="${href}">${line}</a>`
-          : html`<span>${line}</span>`}
+        ${href ? html`<a href=${href}>${line}</a>` : html`<span>${line}</span>`}
       </div>
     `;
   }
@@ -455,9 +453,9 @@
     return html`
       <div class="fileName">
         ${href
-          ? html`<a href="${href}">${displayPath}</a>`
+          ? html`<a href=${href}>${displayPath}</a>`
           : html`<span>${displayPath}</span>`}
-        <gr-copy-clipboard hideInput .text="${displayPath}"></gr-copy-clipboard>
+        <gr-copy-clipboard hideInput .text=${displayPath}></gr-copy-clipboard>
       </div>
     `;
   }
@@ -481,21 +479,21 @@
             : !this.unresolved);
         return html`
           <gr-comment
-            .comment="${comment}"
-            .comments="${this.thread!.comments}"
-            ?initially-collapsed="${initiallyCollapsed}"
-            ?robot-button-disabled="${robotButtonDisabled}"
-            ?show-patchset="${this.showPatchset}"
-            ?show-ported-comment="${this.showPortedComment &&
-            comment.id === this.rootId}"
-            @create-fix-comment="${this.handleCommentFix}"
-            @copy-comment-link="${this.handleCopyLink}"
-            @comment-editing-changed="${(e: CustomEvent) => {
+            .comment=${comment}
+            .comments=${this.thread!.comments}
+            ?initially-collapsed=${initiallyCollapsed}
+            ?robot-button-disabled=${robotButtonDisabled}
+            ?show-patchset=${this.showPatchset}
+            ?show-ported-comment=${this.showPortedComment &&
+            comment.id === this.rootId}
+            @create-fix-comment=${this.handleCommentFix}
+            @copy-comment-link=${this.handleCopyLink}
+            @comment-editing-changed=${(e: CustomEvent) => {
               if (isDraftOrUnsaved(comment)) this.editing = e.detail;
-            }}"
-            @comment-unresolved-changed="${(e: CustomEvent) => {
+            }}
+            @comment-unresolved-changed=${(e: CustomEvent) => {
               if (isDraftOrUnsaved(comment)) this.unresolved = e.detail;
-            }}"
+            }}
           ></gr-comment>
         `;
       }
@@ -513,7 +511,7 @@
         <div id="actions">
           <iron-icon
               class="link-icon copy"
-              @click="${this.handleCopyLink}"
+              @click=${this.handleCopyLink}
               title="Copy link to this comment"
               icon="gr-icons:link"
               role="button"
@@ -524,16 +522,16 @@
               id="replyBtn"
               link
               class="action reply"
-              ?disabled="${this.saving}"
-              @click="${() => this.handleCommentReply(false)}"
+              ?disabled=${this.saving}
+              @click=${() => this.handleCommentReply(false)}
           >Reply</gr-button
           >
           <gr-button
               id="quoteBtn"
               link
               class="action quote"
-              ?disabled="${this.saving}"
-              @click="${() => this.handleCommentReply(true)}"
+              ?disabled=${this.saving}
+              @click=${() => this.handleCommentReply(true)}
           >Quote</gr-button
           >
           ${
@@ -543,16 +541,16 @@
                     id="ackBtn"
                     link
                     class="action ack"
-                    ?disabled="${this.saving}"
-                    @click="${this.handleCommentAck}"
+                    ?disabled=${this.saving}
+                    @click=${this.handleCommentAck}
                     >Ack</gr-button
                   >
                   <gr-button
                     id="doneBtn"
                     link
                     class="action done"
-                    ?disabled="${this.saving}"
-                    @click="${this.handleCommentDone}"
+                    ?disabled=${this.saving}
+                    @click=${this.handleCommentDone}
                     >Done</gr-button
                   >
                 `
@@ -572,16 +570,16 @@
       <div class="diff-container">
         <gr-diff
           id="diff"
-          .diff="${this.diff}"
-          .layers="${this.layers}"
-          .path="${this.thread.path}"
-          .prefs="${this.prefs}"
-          .renderPrefs="${this.renderPrefs}"
-          .highlightRange="${this.highlightRange}"
+          .diff=${this.diff}
+          .layers=${this.layers}
+          .path=${this.thread.path}
+          .prefs=${this.prefs}
+          .renderPrefs=${this.renderPrefs}
+          .highlightRange=${this.highlightRange}
         >
         </gr-diff>
         <div class="view-diff-container">
-          <a href="${href}">
+          <a href=${href}>
             <gr-button link class="view-diff-button">View Diff</gr-button>
           </a>
         </div>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 2af218d..c460ad7 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -454,11 +454,11 @@
     if (isUnsaved(this.comment) && !this.editing) return;
     const classes = {container: true, draft: isDraftOrUnsaved(this.comment)};
     return html`
-      <div id="container" class="${classMap(classes)}">
+      <div id="container" class=${classMap(classes)}>
         <div
           class="header"
           id="header"
-          @click="${() => (this.collapsed = !this.collapsed)}"
+          @click=${() => (this.collapsed = !this.collapsed)}
         >
           <div class="headerLeft">
             ${this.renderAuthor()} ${this.renderPortedCommentMessage()}
@@ -486,8 +486,8 @@
     const classes = {draft: isDraftOrUnsaved(this.comment)};
     return html`
       <gr-account-label
-        .account="${this.comment?.author ?? this.account}"
-        class="${classMap(classes)}"
+        .account=${this.comment?.author ?? this.account}
+        class=${classMap(classes)}
       >
       </gr-account-label>
     `;
@@ -497,8 +497,8 @@
     if (!this.showPortedComment) return;
     if (!this.comment?.patch_set) return;
     return html`
-      <a href="${this.getUrlForComment()}">
-        <span class="portedMessage" @click="${this.handlePortedMessageClick}">
+      <a href=${this.getUrlForComment()}>
+        <span class="portedMessage" @click=${this.handlePortedMessageClick}>
           From patchset ${this.comment?.patch_set}
         </span>
       </a>
@@ -520,7 +520,7 @@
       <gr-tooltip-content
         class="draftTooltip"
         has-tooltip
-        title="${tooltip}"
+        title=${tooltip}
         max-width="20em"
         show-icon
       >
@@ -542,7 +542,7 @@
     return html`
       <div class="runIdMessage message">
         <div class="runIdInformation">
-          <a class="robotRunLink" href="${this.comment.url}">
+          <a class="robotRunLink" href=${this.comment.url}>
             <span class="robotRun link">Run Details</span>
           </a>
         </div>
@@ -568,7 +568,7 @@
         title="Delete Comment"
         link
         class="action delete"
-        @click="${this.openDeleteCommentOverlay}"
+        @click=${this.openDeleteCommentOverlay}
       >
         <iron-icon id="icon" icon="gr-icons:delete"></iron-icon>
       </gr-button>
@@ -587,10 +587,10 @@
     if (!this.comment?.updated || this.collapsed) return;
     return html`
       <span class="separator"></span>
-      <span class="date" tabindex="0" @click="${this.handleAnchorClick}">
+      <span class="date" tabindex="0" @click=${this.handleAnchorClick}>
         <gr-date-formatter
           withTooltip
-          .dateStr="${this.comment.updated}"
+          .dateStr=${this.comment.updated}
         ></gr-date-formatter>
       </span>
     `;
@@ -603,14 +603,14 @@
     const ariaLabel = this.collapsed ? 'Expand' : 'Collapse';
     return html`
       <div class="show-hide" tabindex="0">
-        <label class="show-hide" aria-label="${ariaLabel}">
+        <label class="show-hide" aria-label=${ariaLabel}>
           <input
             type="checkbox"
             class="show-hide"
-            ?checked="${this.collapsed}"
-            @change="${() => (this.collapsed = !this.collapsed)}"
+            ?checked=${this.collapsed}
+            @change=${() => (this.collapsed = !this.collapsed)}
           />
-          <iron-icon id="icon" icon="${icon}"></iron-icon>
+          <iron-icon id="icon" icon=${icon}></iron-icon>
         </label>
       </div>
     `;
@@ -629,17 +629,17 @@
         class="editMessage"
         autocomplete="on"
         code=""
-        ?disabled="${this.saving}"
+        ?disabled=${this.saving}
         rows="4"
-        text="${this.messageText}"
-        @text-changed="${(e: ValueChangedEvent) => {
+        text=${this.messageText}
+        @text-changed=${(e: ValueChangedEvent) => {
           // TODO: This is causing a re-render of <gr-comment> on every key
           // press. Try to avoid always setting `this.messageText` or at least
           // debounce it. Most of the code can just inspect the current value
           // of the textare instead of needing a dedicated property.
           this.messageText = e.detail.value;
           this.autoSaveTrigger$.next();
-        }}"
+        }}
       ></gr-textarea>
     `;
   }
@@ -651,9 +651,9 @@
           gr-diff-selection.-->
       <gr-formatted-text
         class="message"
-        .content="${this.comment?.message}"
-        .config="${this.commentLinks}"
-        ?noTrailingMargin="${!isDraftOrUnsaved(this.comment)}"
+        .content=${this.comment?.message}
+        .config=${this.commentLinks}
+        ?noTrailingMargin=${!isDraftOrUnsaved(this.comment)}
       ></gr-formatted-text>
     `;
   }
@@ -664,7 +664,7 @@
     return html`
       <iron-icon
         class="copy link-icon"
-        @click="${this.handleCopyLink}"
+        @click=${this.handleCopyLink}
         title="Copy link to this comment"
         icon="gr-icons:link"
         role="button"
@@ -684,8 +684,8 @@
             <input
               type="checkbox"
               id="resolvedCheckbox"
-              ?checked="${!this.unresolved}"
-              @change="${this.handleToggleResolved}"
+              ?checked=${!this.unresolved}
+              @change=${this.handleToggleResolved}
             />
             Resolved
           </label>
@@ -711,9 +711,9 @@
     if (this.editing) return;
     return html`<gr-button
       link
-      ?disabled="${this.saving}"
+      ?disabled=${this.saving}
       class="action discard"
-      @click="${this.discard}"
+      @click=${this.discard}
       >Discard</gr-button
     >`;
   }
@@ -722,9 +722,9 @@
     if (this.editing) return;
     return html`<gr-button
       link
-      ?disabled="${this.saving}"
+      ?disabled=${this.saving}
       class="action edit"
-      @click="${this.edit}"
+      @click=${this.edit}
       >Edit</gr-button
     >`;
   }
@@ -734,9 +734,9 @@
     return html`
       <gr-button
         link
-        ?disabled="${this.saving}"
+        ?disabled=${this.saving}
         class="action cancel"
-        @click="${this.cancel}"
+        @click=${this.cancel}
         >Cancel</gr-button
       >
     `;
@@ -747,9 +747,9 @@
     return html`
       <gr-button
         link
-        ?disabled="${this.isSaveDisabled()}"
+        ?disabled=${this.isSaveDisabled()}
         class="action save"
-        @click="${this.save}"
+        @click=${this.save}
         >Save</gr-button
       >
     `;
@@ -759,7 +759,7 @@
     if (!this.account || !isRobot(this.comment)) return;
     const endpoint = html`
       <gr-endpoint-decorator name="robot-comment-controls">
-        <gr-endpoint-param name="comment" .value="${this.comment}">
+        <gr-endpoint-param name="comment" .value=${this.comment}>
         </gr-endpoint-param>
       </gr-endpoint-decorator>
     `;
@@ -778,8 +778,8 @@
         link
         secondary
         class="action show-fix"
-        ?disabled="${this.saving}"
-        @click="${this.handleShowFix}"
+        ?disabled=${this.saving}
+        @click=${this.handleShowFix}
       >
         Show Fix
       </gr-button>
@@ -791,9 +791,9 @@
     return html`
       <gr-button
         link
-        ?disabled="${this.robotButtonDisabled}"
+        ?disabled=${this.robotButtonDisabled}
         class="action fix"
-        @click="${this.handleFix}"
+        @click=${this.handleFix}
       >
         Please Fix
       </gr-button>
@@ -806,8 +806,8 @@
       <gr-overlay id="confirmDeleteOverlay" with-backdrop>
         <gr-confirm-delete-comment-dialog
           id="confirmDeleteComment"
-          @confirm="${this.handleConfirmDeleteComment}"
-          @cancel="${this.closeDeleteCommentOverlay}"
+          @confirm=${this.handleConfirmDeleteComment}
+          @cancel=${this.closeDeleteCommentOverlay}
         >
         </gr-confirm-delete-comment-dialog>
       </gr-overlay>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
index 0a9b9c3..f7f960f 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
@@ -15,32 +15,21 @@
  * limitations under the License.
  */
 import '../gr-dialog/gr-dialog';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-delete-comment-dialog_html';
-import {property, customElement} from '@polymer/decorators';
+import {css, html, LitElement} from 'lit';
+import {property, query, customElement} from 'lit/decorators';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {assertIsDefined} from '../../../utils/common-util';
+import {BindValueChangeEvent} from '../../../types/events';
 
 declare global {
   interface HTMLElementTagNameMap {
     'gr-confirm-delete-comment-dialog': GrConfirmDeleteCommentDialog;
   }
 }
-export interface GrConfirmDeleteCommentDialog {
-  $: {
-    messageInput: IronAutogrowTextareaElement;
-  };
-}
 
 @customElement('gr-confirm-delete-comment-dialog')
-export class GrConfirmDeleteCommentDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  static get is() {
-    return 'gr-confirm-delete-comment-dialog';
-  }
+export class GrConfirmDeleteCommentDialog extends LitElement {
   /**
    * Fired when the confirm button is pressed.
    *
@@ -53,14 +42,79 @@
    * @event cancel
    */
 
+  @query('#messageInput')
+  messageInput?: IronAutogrowTextareaElement;
+
   @property({type: String})
   message = '';
 
-  resetFocus() {
-    this.$.messageInput.textarea.focus();
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        :host([disabled]) {
+          opacity: 0.5;
+          pointer-events: none;
+        }
+        .main {
+          display: flex;
+          flex-direction: column;
+          width: 100%;
+        }
+        p {
+          margin-bottom: var(--spacing-l);
+        }
+        label {
+          cursor: pointer;
+          display: block;
+          width: 100%;
+        }
+        iron-autogrow-textarea {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+          width: 73ch; /* Add a char to account for the border. */
+        }
+      `,
+    ];
   }
 
-  _handleConfirmTap(e: Event) {
+  override render() {
+    return html` <gr-dialog
+      confirm-label="Delete"
+      @confirm=${this.handleConfirmTap}
+      @cancel=${this.handleCancelTap}
+    >
+      <div class="header" slot="header">Delete Comment</div>
+      <div class="main" slot="main">
+        <p>
+          This is an admin function. Please only use in exceptional
+          circumstances.
+        </p>
+        <label for="messageInput">Enter comment delete reason</label>
+        <iron-autogrow-textarea
+          id="messageInput"
+          class="message"
+          autocomplete="on"
+          placeholder="&lt;Insert reasoning here&gt;"
+          .bindValue=${this.message}
+          @bind-value-changed=${(e: BindValueChangeEvent) => {
+            this.message = e.detail.value;
+          }}
+        ></iron-autogrow-textarea>
+      </div>
+    </gr-dialog>`;
+  }
+
+  resetFocus() {
+    assertIsDefined(this.messageInput, 'messageInput');
+    this.messageInput.textarea.focus();
+  }
+
+  private handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -72,7 +126,7 @@
     );
   }
 
-  _handleCancelTap(e: Event) {
+  private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.ts
deleted file mode 100644
index 6876c1a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_html.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    .main {
-      display: flex;
-      flex-direction: column;
-      width: 100%;
-    }
-    p {
-      margin-bottom: var(--spacing-l);
-    }
-    label {
-      cursor: pointer;
-      display: block;
-      width: 100%;
-    }
-    iron-autogrow-textarea {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      width: 73ch; /* Add a char to account for the border. */
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Delete"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Delete Comment</div>
-    <div class="main" slot="main">
-      <p>
-        This is an admin function. Please only use in exceptional circumstances.
-      </p>
-      <label for="messageInput">Enter comment delete reason</label>
-      <iron-autogrow-textarea
-        id="messageInput"
-        class="message"
-        autocomplete="on"
-        placeholder="<Insert reasoning here>"
-        bind-value="{{message}}"
-      ></iron-autogrow-textarea>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
index 01422a9..46bb7d1 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -97,15 +97,15 @@
       <div class="text">
         <iron-input
           class="copyText"
-          @click="${this._handleInputClick}"
+          @click=${this._handleInputClick}
           .bindValue=${this.text ?? ''}
         >
           <input
             id="input"
             is="iron-input"
-            class="${classMap({hideInput: this.hideInput})}"
+            class=${classMap({hideInput: this.hideInput})}
             type="text"
-            @click="${this._handleInputClick}"
+            @click=${this._handleInputClick}
             readonly=""
             .value=${this.text ?? ''}
             part="text-container-style"
@@ -113,13 +113,13 @@
         </iron-input>
         <gr-tooltip-content
           ?has-tooltip=${this.hasTooltip}
-          title="${ifDefined(this.buttonTitle)}"
+          title=${ifDefined(this.buttonTitle)}
         >
           <gr-button
             id="copy-clipboard-button"
             link=""
             class="copyToClipboard"
-            @click="${this._copyToClipboard}"
+            @click=${this._copyToClipboard}
             aria-label="Click to copy to clipboard"
           >
             <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
index fcc1673..cf385e8 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
@@ -136,7 +136,7 @@
         <footer>
           <gr-button
             id="cancel"
-            class="${this.cancelLabel.length ? '' : 'hidden'}"
+            class=${this.cancelLabel.length ? '' : 'hidden'}
             link
             ?disabled=${this.disableCancel}
             @click=${(e: Event) => this.handleCancelTap(e)}
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index 01a72ae..78de898 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -145,7 +145,7 @@
     return html`
       <paper-tabs
         id="downloadTabs"
-        class="${this.computeShowTabs()}"
+        class=${this.computeShowTabs()}
         .selected=${selectedIndex}
         @selected-changed=${this.handleTabChange}
       >
@@ -160,7 +160,7 @@
 
   private renderCommands() {
     return html`
-      <div class="commands" ?hidden="${!this.schemes.length}"></div>
+      <div class="commands" ?hidden=${!this.schemes.length}></div>
         ${this.commands?.map((command, index) =>
           this.renderShellCommand(command, index)
         )}
@@ -171,7 +171,7 @@
   private renderShellCommand(command: Command, index: number) {
     return html`
       <gr-shell-command
-        class="${this.computeClass(command.title)}"
+        class=${this.computeClass(command.title)}
         .label=${command.title}
         .command=${command.command}
         .tooltip=${this.computeTooltip(index)}
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 6191bd7..dd432b8 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -20,15 +20,21 @@
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property} from '@polymer/decorators';
-import {htmlTemplate} from './gr-editable-content_html';
-import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {fire, fireAlert, fireEvent} from '../../../utils/event-util';
 import {getAppContext} from '../../../services/app-context';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {queryAndAssert} from '../../../utils/common-util';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {Interaction} from '../../../constants/reporting';
+import {LitElement, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {css} from 'lit';
+import {PropertyValues} from 'lit';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {nothing} from 'lit';
+import {classMap} from 'lit/directives/class-map';
+import {when} from 'lit/directives/when';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -37,14 +43,14 @@
   interface HTMLElementTagNameMap {
     'gr-editable-content': GrEditableContent;
   }
+  interface HTMLElementEventMap {
+    'content-changed': ValueChangedEvent<string>;
+    'editing-changed': ValueChangedEvent<boolean>;
+  }
 }
 
 @customElement('gr-editable-content')
-export class GrEditableContent extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrEditableContent extends LitElement {
   /**
    * Fired when the save button is pressed.
    *
@@ -63,58 +69,35 @@
    * @event show-alert
    */
 
-  @property({type: String, notify: true, observer: '_contentChanged'})
+  @property({type: String})
   content?: string;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   disabled = false;
 
   @property({
     type: Boolean,
-    observer: '_editingChanged',
-    notify: true,
-    reflectToAttribute: true,
+    reflect: true,
   })
   editing = false;
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'remove-zero-width-space'})
   removeZeroWidthSpace?: boolean;
 
   // If no storage key is provided, content is not stored.
-  @property({type: String})
+  @property({type: String, attribute: 'storage-key'})
   storageKey?: string;
 
-  /** If false, then the "Show more" button was used to expand. */
-  @property({type: Boolean})
-  _commitCollapsed = true;
-
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'commit-collapsible'})
   commitCollapsible = true;
 
-  @property({
-    type: Boolean,
-    computed:
-      '_computeHideShowAllContainer(hideEditCommitMessage, _hideShowAllButton, editing)',
-  })
-  _hideShowAllContainer = false;
-
-  @property({
-    type: Boolean,
-    computed: '_computeHideShowAllButton(commitCollapsible, editing)',
-  })
-  _hideShowAllButton = false;
-
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'hide-edit-commit-message'})
   hideEditCommitMessage?: boolean;
 
-  @property({
-    type: Boolean,
-    computed: '_computeSaveDisabled(disabled, content, _newContent)',
-  })
-  _saveDisabled!: boolean;
+  /** If false, then the "Show more" button was used to expand. */
+  @state() commitCollapsed = true;
 
-  @property({type: String, observer: '_newContentChanged'})
-  _newContent = '';
+  @state() newContent = '';
 
   private readonly storage = getAppContext().storageService;
 
@@ -128,12 +111,217 @@
     super.disconnectedCallback();
   }
 
-  _contentChanged() {
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('editing')) this.editingChanged();
+    if (changedProperties.has('newContent')) this.newContentChanged();
+    if (changedProperties.has('content')) this.contentChanged();
+  }
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+        }
+        :host([disabled]) iron-autogrow-textarea {
+          opacity: 0.5;
+        }
+        .viewer {
+          background-color: var(--view-background-color);
+          border: 1px solid var(--view-background-color);
+          border-radius: var(--border-radius);
+          box-shadow: var(--elevation-level-1);
+          padding: var(--spacing-m);
+        }
+        :host(.collapsed) .viewer,
+        .viewer.collapsed {
+          max-height: var(--collapsed-max-height, 300px);
+          overflow: hidden;
+        }
+        .editor iron-autogrow-textarea,
+        .viewer {
+          min-height: 100px;
+        }
+        .editor iron-autogrow-textarea {
+          background-color: var(--view-background-color);
+          width: 100%;
+          display: block;
+
+          /* You have to also repeat everything from shared-styles here, because
+              you can only *replace* --iron-autogrow-textarea vars as a whole. */
+          --iron-autogrow-textarea: {
+            box-sizing: border-box;
+            padding: var(--spacing-m);
+            overflow-y: hidden;
+            white-space: pre;
+          }
+        }
+        .editButtons {
+          display: flex;
+          justify-content: space-between;
+        }
+        .show-all-container {
+          background-color: var(--view-background-color);
+          display: flex;
+          justify-content: flex-end;
+          border: 1px solid transparent;
+          border-top-color: var(--border-color);
+          border-radius: 0 0 4px 4px;
+          box-shadow: var(--elevation-level-1);
+          /* slightly up to cover rounded corner of the commit msg */
+          margin-top: calc(-1 * var(--spacing-xs));
+          /* To make this bar pop over editor, since editor has relative position.
+          */
+          position: relative;
+        }
+        :host([editing]) .show-all-container {
+          box-shadow: none;
+          border: 1px solid var(--border-color);
+        }
+        .show-all-container .show-all-button {
+          margin-right: auto;
+        }
+        .show-all-container iron-icon {
+          color: inherit;
+          --iron-icon-height: 18px;
+          --iron-icon-width: 18px;
+        }
+        .cancel-button {
+          margin-right: var(--spacing-l);
+        }
+        .save-button {
+          margin-right: var(--spacing-xs);
+        }
+        gr-button {
+          font-family: var(--font-family);
+          line-height: var(--line-height-normal);
+          padding: var(--spacing-xs);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <gr-endpoint-decorator name="commit-message">
+        <gr-endpoint-param
+          name="editing"
+          .value=${this.editing}
+        ></gr-endpoint-param>
+        ${this.renderViewer()} ${this.renderEditor()} ${this.renderButtons()}
+        <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+      </gr-endpoint-decorator>
+    `;
+  }
+
+  private renderViewer() {
+    if (this.editing) return;
+    return html`
+      <div
+        class=${classMap({
+          viewer: true,
+          collapsed: this.commitCollapsed && this.commitCollapsible,
+        })}
+      >
+        <slot></slot>
+      </div>
+    `;
+  }
+
+  private renderEditor() {
+    if (!this.editing) return;
+    return html`
+      <div class="editor">
+        <div>
+          <iron-autogrow-textarea
+            autocomplete="on"
+            .bindValue=${this.newContent}
+            ?disabled=${this.disabled}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              this.newContent = e.detail.value;
+            }}
+          ></iron-autogrow-textarea>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderButtons() {
+    if (this.editing && !this.commitCollapsible && this.hideEditCommitMessage)
+      return nothing;
+
+    return html`
+      <div class="show-all-container">
+        ${when(
+          this.commitCollapsible && !this.editing,
+          () => html`
+            <gr-button
+              link
+              class="show-all-button"
+              @click=${this.toggleCommitCollapsed}
+            >
+              ${when(
+                !this.commitCollapsed,
+                () => html`
+                  <iron-icon icon="gr-icons:expand-less"></iron-icon>
+                `
+              )}
+              ${when(
+                this.commitCollapsed,
+                () => html`
+                  <iron-icon icon="gr-icons:expand-more"></iron-icon>
+                `
+              )}
+              ${this.commitCollapsed ? 'Show all' : 'Show less'}
+            </gr-button>
+          `
+        )}
+        ${when(
+          !this.hideEditCommitMessage,
+          () => html`
+            <gr-button
+              link
+              class="edit-commit-message"
+              title="Edit commit message"
+              @click=${this.handleEditCommitMessage}
+              ><iron-icon icon="gr-icons:edit"></iron-icon> Edit</gr-button
+            >
+          `
+        )}
+        ${when(
+          this.editing,
+          () => html` <div class="editButtons">
+            <gr-button
+              link
+              class="cancel-button"
+              @click=${this.handleCancel}
+              ?disabled=${this.disabled}
+              >Cancel</gr-button
+            >
+            <gr-button
+              class="save-button"
+              primary=""
+              @click=${this.handleSave}
+              ?disabled=${this.computeSaveDisabled()}
+              >Save</gr-button
+            >
+          </div>`
+        )}
+        </div>
+      </div>
+    `;
+  }
+
+  contentChanged() {
     /* A changed content means that either a different change has been loaded
      * or new content was saved. Either way, let's reset the component.
      */
     this.editing = false;
-    this._newContent = '';
+    this.newContent = '';
+    fire(this, 'content-changed', {
+      value: this.content ?? '',
+    });
   }
 
   focusTextarea() {
@@ -143,15 +331,15 @@
     ).textarea.focus();
   }
 
-  _newContentChanged(newContent: string) {
+  newContentChanged() {
     if (!this.storageKey) return;
     const storageKey = this.storageKey;
 
     this.storeTask = debounce(
       this.storeTask,
       () => {
-        if (newContent.length) {
-          this.storage.setEditableContentItem(storageKey, newContent);
+        if (this.newContent.length) {
+          this.storage.setEditableContentItem(storageKey, this.newContent);
         } else {
           // This does not really happen, because we don't clear newContent
           // after saving (see below). So this only occurs when the user clears
@@ -165,8 +353,8 @@
     );
   }
 
-  _editingChanged(editing: boolean) {
-    // This method is for initializing _newContent when you start editing.
+  editingChanged() {
+    // This method is for initializing newContent when you start editing.
     // Restoring content from local storage is not perfect and has
     // some issues:
     //
@@ -180,7 +368,11 @@
     // content from local storage when you enter editing mode for the first
     // time. Otherwise it is better to just keep the last editing state from
     // the same session.
-    if (!editing || this._newContent) return;
+    fire(this, 'editing-changed', {
+      value: this.editing,
+    });
+
+    if (!this.editing || this.newContent) return;
 
     let content;
     if (this.storageKey) {
@@ -197,72 +389,49 @@
     }
 
     // TODO(wyatta) switch linkify sequence, see issue 5526.
-    this._newContent = this.removeZeroWidthSpace
+    this.newContent = this.removeZeroWidthSpace
       ? content.replace(/^R=\u200B/gm, 'R=')
       : content;
   }
 
-  _computeSaveDisabled(
-    disabled?: boolean,
-    content?: string,
-    newContent?: string
-  ): boolean {
-    return disabled || !newContent || content === newContent;
+  computeSaveDisabled(): boolean {
+    return (
+      this.disabled || !this.newContent || this.content === this.newContent
+    );
   }
 
-  _handleSave(e: Event) {
+  handleSave(e: Event) {
     e.preventDefault();
     this.dispatchEvent(
       new CustomEvent('editable-content-save', {
-        detail: {content: this._newContent},
+        detail: {content: this.newContent},
         composed: true,
         bubbles: true,
       })
     );
-    // It would be nice, if we would set this._newContent = undefined here,
+    // It would be nice, if we would set this.newContent = undefined here,
     // but we can only do that when we are sure that the save operation has
     // succeeded.
   }
 
-  _handleCancel(e: Event) {
+  handleCancel(e: Event) {
     e.preventDefault();
     this.editing = false;
     fireEvent(this, 'editable-content-cancel');
   }
 
-  _computeCollapseText(collapsed: boolean) {
-    return collapsed ? 'Show all' : 'Show less';
-  }
-
-  _toggleCommitCollapsed() {
-    this._commitCollapsed = !this._commitCollapsed;
+  toggleCommitCollapsed() {
+    this.commitCollapsed = !this.commitCollapsed;
     this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: 'Commit message',
-      toState: !this._commitCollapsed ? 'Show all' : 'Show less',
+      toState: !this.commitCollapsed ? 'Show all' : 'Show less',
     });
-    if (this._commitCollapsed) {
+    if (this.commitCollapsed) {
       window.scrollTo(0, 0);
     }
   }
 
-  _computeHideShowAllContainer(
-    hideEditCommitMessage?: boolean,
-    _hideShowAllButton?: boolean,
-    editing?: boolean
-  ) {
-    if (editing) return false;
-    return _hideShowAllButton && hideEditCommitMessage;
-  }
-
-  _computeHideShowAllButton(commitCollapsible?: boolean, editing?: boolean) {
-    return !commitCollapsible || editing;
-  }
-
-  _computeCommitMessageCollapsed(collapsed?: boolean, collapsible?: boolean) {
-    return collapsible && collapsed;
-  }
-
-  _handleEditCommitMessage() {
+  handleEditCommitMessage() {
     this.editing = true;
     this.focusTextarea();
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
deleted file mode 100644
index 8c40177..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) iron-autogrow-textarea {
-      opacity: 0.5;
-    }
-    .viewer {
-      background-color: var(--view-background-color);
-      border: 1px solid var(--view-background-color);
-      border-radius: var(--border-radius);
-      box-shadow: var(--elevation-level-1);
-      padding: var(--spacing-m);
-    }
-    :host([collapsed]) .viewer,
-    .viewer[collapsed] {
-      max-height: var(--collapsed-max-height, 300px);
-      overflow: hidden;
-    }
-    .editor iron-autogrow-textarea,
-    .viewer {
-      min-height: 100px;
-    }
-    .editor iron-autogrow-textarea {
-      background-color: var(--view-background-color);
-      width: 100%;
-      display: block;
-
-      /* You have to also repeat everything from shared-styles here, because
-           you can only *replace* --iron-autogrow-textarea vars as a whole. */
-      --iron-autogrow-textarea: {
-        box-sizing: border-box;
-        padding: var(--spacing-m);
-        overflow-y: hidden;
-        white-space: pre;
-      }
-    }
-    .editButtons {
-      display: flex;
-      justify-content: space-between;
-    }
-    .show-all-container {
-      background-color: var(--view-background-color);
-      display: flex;
-      justify-content: flex-end;
-      border: 1px solid transparent;
-      border-top-color: var(--border-color);
-      border-radius: 0 0 4px 4px;
-      box-shadow: var(--elevation-level-1);
-      /* slightly up to cover rounded corner of the commit msg */
-      margin-top: calc(-1 * var(--spacing-xs));
-      /* To make this bar pop over editor, since editor has relative position.
-      */
-      position: relative;
-    }
-    :host([editing]) .show-all-container {
-      box-shadow: none;
-      border: 1px solid var(--border-color);
-    }
-    .show-all-container .show-all-button {
-      margin-right: auto;
-    }
-    .show-all-container iron-icon {
-      color: inherit;
-      --iron-icon-height: 18px;
-      --iron-icon-width: 18px;
-    }
-    .cancel-button {
-      margin-right: var(--spacing-l);
-    }
-    .save-button {
-      margin-right: var(--spacing-xs);
-    }
-    gr-button {
-      font-family: var(--font-family);
-      line-height: var(--line-height-normal);
-      padding: var(--spacing-xs);
-    }
-  </style>
-  <gr-endpoint-decorator name="commit-message">
-    <gr-endpoint-param name="editing" value="[[editing]]"></gr-endpoint-param>
-    <div
-      class="viewer"
-      hidden$="[[editing]]"
-      collapsed$="[[_computeCommitMessageCollapsed(_commitCollapsed, commitCollapsible)]]"
-    >
-      <slot></slot>
-    </div>
-    <div class="editor" hidden$="[[!editing]]">
-      <div>
-        <iron-autogrow-textarea
-          autocomplete="on"
-          bind-value="{{_newContent}}"
-          disabled="[[disabled]]"
-        ></iron-autogrow-textarea>
-      </div>
-    </div>
-    <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
-    <div class="show-all-container" hidden$="[[_hideShowAllContainer]]">
-      <gr-button
-        link=""
-        class="show-all-button"
-        on-click="_toggleCommitCollapsed"
-        hidden$="[[_hideShowAllButton]]"
-        ><iron-icon
-          icon="gr-icons:expand-more"
-          hidden$="[[!_commitCollapsed]]"
-        ></iron-icon
-        ><iron-icon
-          icon="gr-icons:expand-less"
-          hidden$="[[_commitCollapsed]]"
-        ></iron-icon>
-        [[_computeCollapseText(_commitCollapsed)]]
-      </gr-button>
-      <gr-button
-        link=""
-        class="edit-commit-message"
-        title="Edit commit message"
-        on-click="_handleEditCommitMessage"
-        hidden$="[[hideEditCommitMessage]]"
-        ><iron-icon icon="gr-icons:edit"></iron-icon> Edit</gr-button
-      >
-      <div class="editButtons" hidden$="[[!editing]]">
-        <gr-button
-          link=""
-          class="cancel-button"
-          on-click="_handleCancel"
-          disabled="[[disabled]]"
-          >Cancel</gr-button
-        >
-        <gr-button
-          class="save-button"
-          primary=""
-          on-click="_handleSave"
-          disabled="[[_saveDisabled]]"
-          >Save</gr-button
-        >
-      </div>
-    </div>
-  </gr-endpoint-decorator>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
index 074678e..7fc82a1 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
@@ -21,30 +21,78 @@
 import {queryAndAssert, stubStorage} from '../../../test/test-utils';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {GrButton} from '../gr-button/gr-button';
-
-const basicFixture = fixtureFromElement('gr-editable-content');
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-editable-content tests', () => {
   let element: GrEditableContent;
 
-  setup(() => {
-    element = basicFixture.instantiate();
+  setup(async () => {
+    element = await fixture(html`<gr-editable-content></gr-editable-content>`);
+    await element.updateComplete;
   });
 
-  test('save event', () => {
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `<gr-endpoint-decorator
+      name="commit-message"
+    >
+      <gr-endpoint-param name="editing"> </gr-endpoint-param>
+      <div class="collapsed viewer">
+        <slot> </slot>
+      </div>
+      <div class="show-all-container">
+        <gr-button
+          aria-disabled="false"
+          class="show-all-button"
+          link=""
+          role="button"
+          tabindex="0"
+        >
+          <iron-icon icon="gr-icons:expand-more"> </iron-icon>
+          Show all
+        </gr-button>
+        <gr-button
+          aria-disabled="false"
+          class="edit-commit-message"
+          link=""
+          role="button"
+          tabindex="0"
+          title="Edit commit message"
+        >
+          <iron-icon icon="gr-icons:edit"> </iron-icon>
+          Edit
+        </gr-button>
+      </div>
+      <gr-endpoint-slot name="above-actions"> </gr-endpoint-slot>
+    </gr-endpoint-decorator> `);
+  });
+
+  test('save event', async () => {
     element.content = '';
-    element._newContent = 'foo';
+    // Needed because contentChanged resets newContent
+    // We want contentChanged observer to finish before newContentChanged is
+    // called
+    await element.updateComplete;
+
+    element.newContent = 'foo';
+    element.disabled = false;
+    element.editing = true;
     const handler = sinon.spy();
     element.addEventListener('editable-content-save', handler);
 
-    MockInteractions.tap(queryAndAssert(element, 'gr-button[primary]'));
+    await element.updateComplete;
+
+    queryAndAssert<GrButton>(element, 'gr-button[primary]').click();
+
+    await element.updateComplete;
 
     assert.isTrue(handler.called);
     assert.equal(handler.lastCall.args[0].detail.content, 'foo');
   });
 
-  test('cancel event', () => {
+  test('cancel event', async () => {
     const handler = sinon.spy();
+    element.editing = true;
+    await element.updateComplete;
     element.addEventListener('editable-content-cancel', handler);
 
     MockInteractions.tap(queryAndAssert(element, 'gr-button.cancel-button'));
@@ -52,32 +100,58 @@
     assert.isTrue(handler.called);
   });
 
-  test('enabling editing keeps old content', () => {
+  test('enabling editing keeps old content', async () => {
     element.content = 'current content';
-    element._newContent = 'old content';
+
+    // Needed because contentChanged resets newContent
+    // We want contentChanged observer to finish before newContentChanged is
+    // called
+    await element.updateComplete;
+
+    element.newContent = 'old content';
     element.editing = true;
-    assert.equal(element._newContent, 'old content');
+
+    await element.updateComplete;
+
+    assert.equal(element.newContent, 'old content');
   });
 
   test('disabling editing does not update edit field contents', () => {
     element.content = 'current content';
     element.editing = true;
-    element._newContent = 'stale content';
+    element.newContent = 'stale content';
     element.editing = false;
-    assert.equal(element._newContent, 'stale content');
+    assert.equal(element.newContent, 'stale content');
   });
 
-  test('zero width spaces are removed properly', () => {
+  test('zero width spaces are removed properly', async () => {
     element.removeZeroWidthSpace = true;
     element.content = 'R=\u200Btest@google.com';
+
+    // Needed because contentChanged resets newContent
+    // We want contentChanged observer to finish before editingChanged is
+    // called
+
+    await element.updateComplete;
+
     element.editing = true;
-    assert.equal(element._newContent, 'R=test@google.com');
+
+    // editingChanged updates newContent so wait for it's observer
+    // to finish
+    await element.updateComplete;
+
+    assert.equal(element.newContent, 'R=test@google.com');
   });
 
   suite('editing', () => {
-    setup(() => {
+    setup(async () => {
       element.content = 'current content';
+      // Needed because contentChanged resets newContent
+      // contentChanged updates newContent as well so wait for that observer
+      // to finish before setting editing=true.
+      await element.updateComplete;
       element.editing = true;
+      await element.updateComplete;
     });
 
     test('save button is disabled initially', () => {
@@ -86,8 +160,9 @@
       );
     });
 
-    test('save button is enabled when content changes', () => {
-      element._newContent = 'new content';
+    test('save button is enabled when content changes', async () => {
+      element.newContent = 'new content';
+      await element.updateComplete;
       assert.isFalse(
         queryAndAssert<GrButton>(element, 'gr-button[primary]').disabled
       );
@@ -97,49 +172,60 @@
   suite('storageKey and related behavior', () => {
     let dispatchSpy: sinon.SinonSpy;
 
-    setup(() => {
+    setup(async () => {
       element.content = 'current content';
+      await element.updateComplete;
       element.storageKey = 'test';
       dispatchSpy = sinon.spy(element, 'dispatchEvent');
     });
 
-    test('editing toggled to true, has stored data', () => {
+    test('editing toggled to true, has stored data', async () => {
       stubStorage('getEditableContentItem').returns({
         message: 'stored content',
         updated: 0,
       });
       element.editing = true;
-
-      assert.equal(element._newContent, 'stored content');
+      await element.updateComplete;
+      assert.equal(element.newContent, 'stored content');
       assert.isTrue(dispatchSpy.called);
-      assert.equal(dispatchSpy.firstCall.args[0].type, 'show-alert');
+      assert.equal(dispatchSpy.lastCall.args[0].type, 'show-alert');
     });
 
-    test('editing toggled to true, has no stored data', () => {
+    test('editing toggled to true, has no stored data', async () => {
       stubStorage('getEditableContentItem').returns(null);
       element.editing = true;
 
-      assert.equal(element._newContent, 'current content');
+      await element.updateComplete;
+
+      assert.equal(element.newContent, 'current content');
       assert.equal(dispatchSpy.firstCall.args[0].type, 'editing-changed');
     });
 
-    test('edits are cached', () => {
+    test('edits are cached', async () => {
       const storeStub = stubStorage('setEditableContentItem');
       const eraseStub = stubStorage('eraseEditableContentItem');
       element.editing = true;
 
-      element._newContent = 'new content';
-      flush();
+      // Needed because editingChanged resets newContent
+      // We want ediingChanged() to finish before triggering newContentChanged
+      await element.updateComplete;
+
+      element.newContent = 'new content';
+
+      await element.updateComplete;
+
       element.storeTask?.flush();
 
       assert.isTrue(storeStub.called);
       assert.deepEqual(
-        [element.storageKey, element._newContent],
+        [element.storageKey, element.newContent],
         storeStub.lastCall.args
       );
 
-      element._newContent = '';
-      flush();
+      element.newContent = '';
+
+      await element.updateComplete;
+
       element.storeTask?.flush();
 
       assert.isTrue(eraseStub.called);
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
index 8564751..cd020a9 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
@@ -19,9 +19,6 @@
 import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
 import '../../shared/gr-autocomplete/gr-autocomplete';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property} from '@polymer/decorators';
-import {htmlTemplate} from './gr-editable-label_html';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
 import {PaperInputElementExt} from '../../../types/types';
 import {
@@ -30,6 +27,9 @@
 } from '../gr-autocomplete/gr-autocomplete';
 import {addShortcut, Key} from '../../../utils/dom-util';
 import {queryAndAssert} from '../../../utils/common-util';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -40,53 +40,45 @@
   }
 }
 
-export interface GrEditableLabel {
-  $: {
-    dropdown: IronDropdownElement;
-  };
-}
-
 @customElement('gr-editable-label')
-export class GrEditableLabel extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrEditableLabel extends LitElement {
   /**
    * Fired when the value is changed.
    *
    * @event changed
    */
 
-  @property({type: String})
+  @query('#dropdown')
+  dropdown?: IronDropdownElement;
+
+  @property()
   labelText = '';
 
   @property({type: Boolean})
   editing = false;
 
-  @property({type: String, notify: true, observer: '_updateTitle'})
+  @property()
   value?: string;
 
-  @property({type: String})
+  @property()
   placeholder = '';
 
   @property({type: Boolean})
   readOnly = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   uppercase = false;
 
   @property({type: Number})
   maxLength?: number;
 
-  @property({type: String})
-  _inputText = '';
+  /* private but used in test */
+  @state() inputText = '';
 
   // This is used to push the iron-input element up on the page, so
   // the input is placed in approximately the same position as the
   // trigger.
-  @property({type: Number})
-  readonly _verticalOffset = -30;
+  @state() readonly verticalOffset = -30;
 
   @property({type: Boolean})
   showAsEditPencil = false;
@@ -97,9 +89,139 @@
   @property({type: Object})
   query: AutocompleteQuery = () => Promise.resolve([]);
 
-  override ready() {
-    super.ready();
-    this._ensureAttribute('tabindex', '0');
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          align-items: center;
+          display: inline-flex;
+        }
+        :host([uppercase]) label {
+          text-transform: uppercase;
+        }
+        input,
+        label {
+          width: 100%;
+        }
+        label {
+          color: var(--deemphasized-text-color);
+          display: inline-block;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+        label.editable {
+          color: var(--link-color);
+          cursor: pointer;
+        }
+        #dropdown {
+          box-shadow: var(--elevation-level-2);
+        }
+        .inputContainer {
+          background-color: var(--dialog-background-color);
+          padding: var(--spacing-m);
+        }
+        .buttons {
+          display: flex;
+          justify-content: flex-end;
+          padding-top: var(--spacing-l);
+          width: 100%;
+        }
+        .buttons gr-button {
+          margin-left: var(--spacing-m);
+        }
+        paper-input {
+          --paper-input-container: {
+            padding: 0;
+            min-width: 15em;
+          }
+          --paper-input-container-input: {
+            font-size: inherit;
+          }
+          --paper-input-container-focus-color: var(--link-color);
+        }
+        gr-button iron-icon {
+          color: inherit;
+          --iron-icon-height: 18px;
+          --iron-icon-width: 18px;
+        }
+        gr-button.pencil {
+          --gr-button-padding: 0px 0px;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    this.setAttribute('title', this.computeLabel());
+    return html`${this.renderActivateButton()}
+      <iron-dropdown
+        id="dropdown"
+        .verticalAlign=${'auto'}
+        .horizontalAlign=${'auto'}
+        .verticalOffset=${this.verticalOffset}
+        allowOutsideScroll
+        @iron-overlay-canceled=${this.cancel}
+      >
+        <div class="dropdown-content" slot="dropdown-content">
+          <div class="inputContainer" part="input-container">
+            ${this.renderInputBox()}
+            <div class="buttons">
+              <gr-button link="" id="cancelBtn" @click=${this.cancel}
+                >cancel</gr-button
+              >
+              <gr-button link="" id="saveBtn" @click=${this.save}
+                >save</gr-button
+              >
+            </div>
+          </div>
+        </div>
+      </iron-dropdown>`;
+  }
+
+  private renderActivateButton() {
+    if (this.showAsEditPencil) {
+      return html`<gr-button
+        link=""
+        class="pencil ${this.computeLabelClass()}"
+        @click=${this.showDropdown}
+        title=${this.computeLabel()}
+        ><iron-icon icon="gr-icons:edit"></iron-icon
+      ></gr-button>`;
+    } else {
+      return html`<label
+        class=${this.computeLabelClass()}
+        title=${this.computeLabel()}
+        aria-label=${this.computeLabel()}
+        @click=${this.showDropdown}
+        part="label"
+        >${this.computeLabel()}</label
+      >`;
+    }
+  }
+
+  private renderInputBox() {
+    if (this.autocomplete) {
+      return html`<gr-autocomplete
+        .label=${this.labelText}
+        id="autocomplete"
+        .text=${this.inputText}
+        .query=${this.query}
+        @commit=${this.handleCommit}
+        @text-changed=${(e: CustomEvent) => {
+          this.inputText = e.detail.value;
+        }}
+      >
+      </gr-autocomplete>`;
+    } else {
+      return html`<paper-input
+        id="input"
+        .label=${this.labelText}
+        .maxlength=${this.maxLength}
+        .value=${this.inputText}
+      ></paper-input>`;
+    }
   }
 
   /** Called in disconnectedCallback. */
@@ -113,48 +235,55 @@
 
   override connectedCallback() {
     super.connectedCallback();
+    if (!this.getAttribute('tabindex')) {
+      this.setAttribute('tabindex', '0');
+    }
+    if (!this.getAttribute('id')) {
+      this.setAttribute('id', 'global');
+    }
     this.cleanups.push(
-      addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
+      addShortcut(this, {key: Key.ENTER}, e => this.handleEnter(e))
     );
     this.cleanups.push(
-      addShortcut(this, {key: Key.ESC}, e => this._handleEsc(e))
+      addShortcut(this, {key: Key.ESC}, e => this.handleEsc(e))
     );
   }
 
-  _usePlaceholder(value?: string, placeholder?: string) {
+  private usePlaceholder(value?: string, placeholder?: string) {
     return (!value || !value.length) && placeholder;
   }
 
-  _computeLabel(value?: string, placeholder?: string): string {
-    if (this._usePlaceholder(value, placeholder)) {
-      return placeholder!;
+  private computeLabel(): string {
+    const {value, placeholder} = this;
+    if (this.usePlaceholder(value, placeholder)) {
+      return placeholder;
     }
     return value || '';
   }
 
-  _showDropdown() {
+  private showDropdown() {
     if (this.readOnly || this.editing) return;
-    return this._open().then(() => {
-      this._nativeInput.focus();
+    return this.openDropdown().then(() => {
+      this.nativeInput.focus();
       const input = this.getInput();
       if (!input?.value) return;
-      this._nativeInput.setSelectionRange(0, input.value.length);
+      this.nativeInput.setSelectionRange(0, input.value.length);
     });
   }
 
   open() {
-    return this._open().then(() => {
-      this._nativeInput.focus();
+    return this.openDropdown().then(() => {
+      this.nativeInput.focus();
     });
   }
 
-  _open() {
-    this.$.dropdown.open();
-    this._inputText = this.value || '';
+  private openDropdown() {
+    this.dropdown?.open();
+    this.inputText = this.value || '';
     this.editing = true;
 
     return new Promise<void>(resolve => {
-      this._awaitOpen(resolve);
+      this.awaitOpen(resolve);
     });
   }
 
@@ -162,11 +291,11 @@
    * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
    * opening. Eventually replace with a direct way to listen to the overlay.
    */
-  _awaitOpen(fn: () => void) {
+  private awaitOpen(fn: () => void) {
     let iters = 0;
     const step = () => {
       setTimeout(() => {
-        if (this.$.dropdown.style.display !== 'none') {
+        if (this.dropdown?.style.display !== 'none') {
           fn.call(this);
         } else if (iters++ < AWAIT_MAX_ITERS) {
           step.call(this);
@@ -176,16 +305,17 @@
     step.call(this);
   }
 
-  _id() {
-    return this.getAttribute('id') || 'global';
-  }
-
-  _save() {
+  private save() {
     if (!this.editing) {
       return;
     }
-    this.$.dropdown.close();
-    this.value = this._inputText || '';
+    this.dropdown?.close();
+    const input = this.getInput();
+    if (input) {
+      this.value = input.value ?? undefined;
+    } else {
+      this.value = this.inputText || '';
+    }
     this.editing = false;
     this.dispatchEvent(
       new CustomEvent('changed', {
@@ -196,23 +326,22 @@
     );
   }
 
-  _cancel() {
+  private cancel() {
     if (!this.editing) {
       return;
     }
-    this.$.dropdown.close();
+    this.dropdown?.close();
     this.editing = false;
-    this._inputText = this.value || '';
+    this.inputText = this.value || '';
   }
 
-  get _nativeInput(): HTMLInputElement {
-    // In Polymer 2 inputElement isn't nativeInput anymore
+  private get nativeInput(): HTMLInputElement {
     return (this.getInput()?.$.nativeInput ||
       this.getInput()?.inputElement ||
       this.getGrAutocomplete()) as HTMLInputElement;
   }
 
-  _handleEnter(event: KeyboardEvent) {
+  private handleEnter(event: KeyboardEvent) {
     const grAutocomplete = this.getGrAutocomplete();
     if (event.composedPath().some(el => el === grAutocomplete)) {
       return;
@@ -222,39 +351,36 @@
       .composedPath()
       .some(element => element === inputContainer);
     if (isEventFromInput) {
-      this._save();
+      this.save();
     }
   }
 
-  _handleEsc(event: KeyboardEvent) {
+  private handleEsc(event: KeyboardEvent) {
     const inputContainer = queryAndAssert(this, '.inputContainer');
     const isEventFromInput = event
       .composedPath()
       .some(element => element === inputContainer);
     if (isEventFromInput) {
-      this._cancel();
+      this.cancel();
     }
   }
 
-  _handleCommit() {
+  private handleCommit() {
     this.getInput()?.focus();
   }
 
-  _computeLabelClass(readOnly?: boolean, value?: string, placeholder?: string) {
+  private computeLabelClass() {
+    const {readOnly, value, placeholder} = this;
     const classes = [];
     if (!readOnly) {
       classes.push('editable');
     }
-    if (this._usePlaceholder(value, placeholder)) {
+    if (this.usePlaceholder(value, placeholder)) {
       classes.push('placeholder');
     }
     return classes.join(' ');
   }
 
-  _updateTitle(value?: string) {
-    this.setAttribute('title', this._computeLabel(value, this.placeholder));
-  }
-
   getInput(): PaperInputElementExt | null {
     return this.shadowRoot!.querySelector<PaperInputElementExt>('#input');
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts
deleted file mode 100644
index e711e9d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_html.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      align-items: center;
-      display: inline-flex;
-    }
-    :host([uppercase]) label {
-      text-transform: uppercase;
-    }
-    input,
-    label {
-      width: 100%;
-    }
-    label {
-      color: var(--deemphasized-text-color);
-      display: inline-block;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      white-space: nowrap;
-    }
-    label.editable {
-      color: var(--link-color);
-      cursor: pointer;
-    }
-    #dropdown {
-      box-shadow: var(--elevation-level-2);
-    }
-    .inputContainer {
-      background-color: var(--dialog-background-color);
-      padding: var(--spacing-m);
-    }
-    .buttons {
-      display: flex;
-      justify-content: flex-end;
-      padding-top: var(--spacing-l);
-      width: 100%;
-    }
-    .buttons gr-button {
-      margin-left: var(--spacing-m);
-    }
-    paper-input {
-      --paper-input-container: {
-        padding: 0;
-        min-width: 15em;
-      }
-      --paper-input-container-input: {
-        font-size: inherit;
-      }
-      --paper-input-container-focus-color: var(--link-color);
-    }
-    gr-button iron-icon {
-      color: inherit;
-      --iron-icon-height: 18px;
-      --iron-icon-width: 18px;
-    }
-    gr-button.pencil {
-      --gr-button-padding: 0px 0px;
-    }
-  </style>
-  <template is="dom-if" if="[[!showAsEditPencil]]">
-    <label
-      class$="[[_computeLabelClass(readOnly, value, placeholder)]]"
-      title$="[[_computeLabel(value, placeholder)]]"
-      aria-label$="[[_computeLabel(value, placeholder)]]"
-      on-click="_showDropdown"
-      part="label"
-      >[[_computeLabel(value, placeholder)]]</label
-    >
-  </template>
-  <template is="dom-if" if="[[showAsEditPencil]]">
-    <gr-button
-      link=""
-      class$="pencil [[_computeLabelClass(readOnly, value, placeholder)]]"
-      on-click="_showDropdown"
-      title="[[_computeLabel(value, placeholder)]]"
-      ><iron-icon icon="gr-icons:edit"></iron-icon
-    ></gr-button>
-  </template>
-  <iron-dropdown
-    id="dropdown"
-    vertical-align="auto"
-    horizontal-align="auto"
-    vertical-offset="[[_verticalOffset]]"
-    allow-outside-scroll="true"
-    on-iron-overlay-canceled="_cancel"
-  >
-    <div class="dropdown-content" slot="dropdown-content">
-      <div class="inputContainer" part="input-container">
-        <template is="dom-if" if="[[!autocomplete]]">
-          <paper-input
-            id="input"
-            label="[[labelText]]"
-            maxlength="[[maxLength]]"
-            value="{{_inputText}}"
-          ></paper-input>
-        </template>
-        <template is="dom-if" if="[[autocomplete]]">
-          <gr-autocomplete
-            label="[[labelText]]"
-            id="autocomplete"
-            text="{{_inputText}}"
-            query="[[query]]"
-            on-commit="_handleCommit"
-          >
-          </gr-autocomplete>
-        </template>
-        <div class="buttons">
-          <gr-button link="" id="cancelBtn" on-click="_cancel"
-            >cancel</gr-button
-          >
-          <gr-button link="" id="saveBtn" on-click="_save">save</gr-button>
-        </div>
-      </div>
-    </div>
-  </iron-dropdown>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
index 014116b..a439d05 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
@@ -23,6 +23,7 @@
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
 import {GrButton} from '../gr-button/gr-button';
 import {fixture, html} from '@open-wc/testing-helpers';
+import {IronDropdownElement} from '@polymer/iron-dropdown';
 
 suite('gr-editable-label tests', () => {
   let element: GrEditableLabel;
@@ -57,10 +58,8 @@
     >
       value text
     </label>
-    <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
-    <dom-if style="display: none;"><template is="dom-if"></template></dom-if>
     <iron-dropdown
-      allow-outside-scroll="true"
+      allowoutsidescroll=""
       aria-disabled="false"
       aria-hidden="true"
       horizontal-align="auto"
@@ -75,10 +74,6 @@
             id="input"
             tabindex="0"
           ></paper-input>
-          <dom-if style="display: none;"><template is="dom-if"></template>
-          </dom-if>
-          <dom-if style="display: none;"><template is="dom-if"></template>
-          </dom-if>
           <div class="buttons">
             <gr-button
               aria-disabled="false"
@@ -104,29 +99,25 @@
     </iron-dropdown>`);
   });
 
-  test('element render', () => {
+  test('element render', async () => {
     // The dropdown is closed and the label is visible:
-    assert.isFalse(element.$.dropdown.opened);
+    const dropdown = queryAndAssert<IronDropdownElement>(element, '#dropdown');
+    assert.isFalse(dropdown.opened);
     assert.isTrue(label.classList.contains('editable'));
     assert.equal(label.textContent, 'value text');
-    const focusSpy = sinon.spy(input, 'focus');
-    const showSpy = sinon.spy(element, '_showDropdown');
 
-    MockInteractions.tap(label);
-
-    return showSpy.lastCall.returnValue!.then(() => {
-      // The dropdown is open (which covers up the label):
-      assert.isTrue(element.$.dropdown.opened);
-      assert.isTrue(focusSpy.called);
-      assert.equal(input.value, 'value text');
-    });
+    label.click();
+    await element.updateComplete;
+    // The dropdown is open (which covers up the label):
+    assert.isTrue(dropdown.opened);
+    assert.equal(input.value, 'value text');
   });
 
   test('title with placeholder', async () => {
     assert.equal(element.title, 'value text');
     element.value = '';
 
-    await flush();
+    await element.updateComplete;
     assert.equal(element.title, 'label text');
   });
 
@@ -149,7 +140,7 @@
     assert.isTrue(element.editing);
     assert.isFalse(editedSpy.called);
 
-    element._inputText = 'new text';
+    element.inputText = 'new text';
     // Press enter:
     MockInteractions.keyDownOn(input, 13, null, 'Enter');
     await flush();
@@ -170,7 +161,7 @@
     assert.isTrue(element.editing);
     assert.isFalse(editedSpy.called);
 
-    element._inputText = 'new text';
+    element.inputText = 'new text';
     // Press enter:
     MockInteractions.pressAndReleaseKeyOn(
       queryAndAssert<GrButton>(element, '#saveBtn'),
@@ -196,7 +187,7 @@
     assert.isTrue(element.editing);
     assert.isFalse(editedSpy.called);
 
-    element._inputText = 'new text';
+    element.inputText = 'new text';
     // Press escape:
     MockInteractions.keyDownOn(input, 27, null, 'Escape');
     await flush();
@@ -218,7 +209,7 @@
     assert.isTrue(element.editing);
     assert.isFalse(editedSpy.called);
 
-    element._inputText = 'new text';
+    element.inputText = 'new text';
     // Press escape:
     MockInteractions.tap(queryAndAssert<GrButton>(element, '#cancelBtn'));
     await flush();
@@ -236,7 +227,7 @@
     setup(async () => {
       element = await fixture<GrEditableLabel>(html`
         <gr-editable-label
-          read-only
+          readOnly
           value="value text"
           placeholder="label text"
         ></gr-editable-label>
@@ -246,13 +237,17 @@
 
     test('disallows edit when read-only', async () => {
       // The dropdown is closed.
-      assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.tap(label);
+      const dropdown = queryAndAssert<IronDropdownElement>(
+        element,
+        '#dropdown'
+      );
+      assert.isFalse(dropdown.opened);
+      label.click();
 
-      await flush();
+      await element.updateComplete;
 
       // The dropdown is still closed.
-      assert.isFalse(element.$.dropdown.opened);
+      assert.isFalse(dropdown.opened);
     });
 
     test('label is not marked as editable', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts
index 3f759b5..a2088cc 100644
--- a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip.ts
@@ -67,10 +67,10 @@
 
   override render() {
     return html` <span
-      class="${this._computeStatusClass(this.file)}"
+      class=${this._computeStatusClass(this.file)}
       tabindex="0"
-      title="${this._computeFileStatusLabel(this.file?.status)}"
-      aria-label="${this._computeFileStatusLabel(this.file?.status)}"
+      title=${this._computeFileStatusLabel(this.file?.status)}
+      aria-label=${this._computeFileStatusLabel(this.file?.status)}
     >
       ${this._computeFileStatusLabel(this.file?.status)}
     </span>`;
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index c6f44af..9507620 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -353,7 +353,7 @@
   private renderText(content: string, isPre?: boolean): TemplateResult {
     return html`
       <gr-linked-text
-        class="${isPre ? 'pre' : ''}"
+        class=${isPre ? 'pre' : ''}
         .config=${this.config}
         content=${content}
         pre
@@ -364,7 +364,7 @@
   private renderInlineText(content: string, isPre?: boolean): TemplateResult {
     return html`
       <gr-linked-text
-        class="${isPre ? 'pre' : ''}"
+        class=${isPre ? 'pre' : ''}
         .config=${this.config}
         content=${content}
         pre
@@ -374,7 +374,7 @@
   }
 
   private renderLink(text: string, url: string): TemplateResult {
-    return html`<a href="${url}">${text}</a>`;
+    return html`<a href=${url}>${text}</a>`;
   }
 
   private renderInlineItem(span: InlineItem): TemplateResult {
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index 9d763d0..4493e8d 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -214,7 +214,7 @@
           class="removeReviewerOrCC"
           link=""
           no-uppercase
-          @click="${this.handleRemoveReviewerOrCC}"
+          @click=${this.handleRemoveReviewerOrCC}
         >
           Remove ${this.computeReviewerOrCCText()}
         </gr-button>
@@ -224,7 +224,7 @@
           class="changeReviewerOrCC"
           link=""
           no-uppercase
-          @click="${this.handleChangeReviewerOrCCStatus}"
+          @click=${this.handleChangeReviewerOrCCStatus}
         >
           ${this.computeChangeReviewerOrCCText()}
         </gr-button>
@@ -237,7 +237,7 @@
       <gr-endpoint-decorator name="hovercard-status">
         <gr-endpoint-param
           name="account"
-          .value="${this.account}"
+          .value=${this.account}
         ></gr-endpoint-param>
       </gr-endpoint-decorator>
     `;
@@ -247,7 +247,7 @@
     return html` <div class="links">
       <iron-icon class="linkIcon" icon="gr-icons:link"></iron-icon
       ><a
-        href="${ifDefined(this.computeOwnerChangesLink())}"
+        href=${ifDefined(this.computeOwnerChangesLink())}
         @click=${() => {
           this.forceHide();
           return true;
@@ -258,7 +258,7 @@
         }}
         >Changes</a
       >·<a
-        href="${ifDefined(this.computeOwnerDashboardLink())}"
+        href=${ifDefined(this.computeOwnerDashboardLink())}
         @click=${() => {
           this.forceHide();
           return true;
@@ -311,7 +311,7 @@
           ${lastUpdate
             ? html` (<gr-date-formatter
                   withTooltip
-                  .dateStr="${lastUpdate}"
+                  .dateStr=${lastUpdate}
                 ></gr-date-formatter
                 >)`
             : ''}
@@ -328,7 +328,7 @@
           class="addToAttentionSet"
           link=""
           no-uppercase
-          @click="${this.handleClickAddToAttentionSet}"
+          @click=${this.handleClickAddToAttentionSet}
         >
           Add to attention set
         </gr-button>
@@ -344,7 +344,7 @@
           class="removeFromAttentionSet"
           link=""
           no-uppercase
-          @click="${this.handleClickRemoveFromAttentionSet}"
+          @click=${this.handleClickRemoveFromAttentionSet}
         >
           Remove from attention set
         </gr-button>
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index 36a7354..b1d3914 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -267,15 +267,15 @@
         hasNeutralStatus(labelInfo, approvalInfo));
     return html`<div class="reviewer-row">
       <gr-account-chip
-        .account="${reviewer}"
-        .change="${this.change}"
-        .vote="${approvalInfo}"
-        .label="${labelInfo}"
+        .account=${reviewer}
+        .change=${this.change}
+        .vote=${approvalInfo}
+        .label=${labelInfo}
       >
         <gr-vote-chip
           slot="vote-chip"
-          .vote="${approvalInfo}"
-          .label="${labelInfo}"
+          .vote=${approvalInfo}
+          .label=${labelInfo}
           circle-shape
         ></gr-vote-chip
       ></gr-account-chip>
@@ -291,7 +291,7 @@
       <td>
         <gr-tooltip-content
           has-tooltip
-          title="${this._computeValueTooltip(labelInfo, mappedLabel.value)}"
+          title=${this._computeValueTooltip(labelInfo, mappedLabel.value)}
         >
           <gr-label class="${mappedLabel.className} voteChip font-small">
             ${mappedLabel.value}
@@ -301,8 +301,8 @@
       <td>
         <gr-account-label
           clickable
-          .account="${mappedLabel.account}"
-          .change="${change}"
+          .account=${mappedLabel.account}
+          .change=${change}
         ></gr-account-label>
       </td>
       <td>${this.renderRemoveVote(mappedLabel.account)}</td>
@@ -327,10 +327,8 @@
       <gr-button
         link
         aria-label="Remove vote"
-        @click="${this.onDeleteVote}"
-        data-account-id="${ifDefined(
-          reviewer._account_id as number | undefined
-        )}"
+        @click=${this.onDeleteVote}
+        data-account-id=${ifDefined(reviewer._account_id as number | undefined)}
         class="deleteBtn ${this.computeDeleteClass(
           reviewer,
           this.mutable,
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts
index 753835d..c020a9c 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts
@@ -89,12 +89,12 @@
           <gr-autocomplete
             id="autocomplete"
             threshold="0"
-            .query="${this.query}"
-            ?disabled="${this.disabled}"
-            .placeholder="${this.placeholder}"
+            .query=${this.query}
+            ?disabled=${this.disabled}
+            .placeholder=${this.placeholder}
             borderless=""
           ></gr-autocomplete>
-          <div id="trigger" @click="${this._handleTriggerClick}">â–¼</div>
+          <div id="trigger" @click=${this._handleTriggerClick}>â–¼</div>
         </div>
       </div>
     `;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
index 801b8bf..ad99406 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
@@ -113,10 +113,10 @@
           this.transparentBackground
         )}"
       >
-        <a href="${this.href}">
+        <a href=${this.href}>
           <gr-limited-text
-            .limit="${this.limit}"
-            .text="${this.text}"
+            .limit=${this.limit}
+            .text=${this.text}
           ></gr-limited-text>
         </a>
         <gr-button
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
index 6e1b20c..d352583 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
@@ -81,9 +81,9 @@
     return html` <label>${label}</label>
       <div class="commandContainer">
         <gr-copy-clipboard
-          .text="${this.command}"
+          .text=${this.command}
           hasTooltip
-          buttonTitle="${this.tooltip}"
+          buttonTitle=${this.tooltip}
         ></gr-copy-clipboard>
       </div>`;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
index 0e41891..bd4efcd 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
@@ -92,12 +92,12 @@
     return html` <div class="tooltip">
       <i
         class="arrowPositionBelow arrow"
-        style="${styleMap({marginLeft: this.arrowCenterOffset})}"
+        style=${styleMap({marginLeft: this.arrowCenterOffset})}
       ></i>
       ${this.text}
       <i
         class="arrowPositionAbove arrow"
-        style="${styleMap({marginLeft: this.arrowCenterOffset})}"
+        style=${styleMap({marginLeft: this.arrowCenterOffset})}
       ></i>
     </div>`;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
index d89ed65..146a01e 100644
--- a/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
@@ -139,7 +139,7 @@
 
     return html`<gr-tooltip-content
       class="container ${this.more ? 'more' : ''}"
-      title="${this.computeTooltip()}"
+      title=${this.computeTooltip()}
       has-tooltip
     >
       <div class="vote-chip ${this.computeClass()}">${renderValue}</div>
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
index a16820c..a451700 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
@@ -347,11 +347,11 @@
     };
 
     const button = html` <paper-button
-      class="${classes}"
-      aria-label="${ariaLabel}"
-      @click="${expandHandler}"
-      @mouseenter="${() => mouseHandler('enter')}"
-      @mouseleave="${() => mouseHandler('leave')}"
+      class=${classes}
+      aria-label=${ariaLabel}
+      @click=${expandHandler}
+      @mouseenter=${() => mouseHandler('enter')}
+      @mouseleave=${() => mouseHandler('leave')}
     >
       <span class="showContext">${text}</span>
       ${tooltip}
@@ -464,7 +464,7 @@
 
     const position =
       buttonType === ContextButtonType.BLOCK_ABOVE ? 'top' : 'bottom';
-    return html`<paper-tooltip offset="10" position="${position}"
+    return html`<paper-tooltip offset="10" position=${position}
       ><div class="breadcrumbTooltip">${tooltipText}</div></paper-tooltip
     >`;
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts
index f121113..dae5c03 100644
--- a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts
@@ -1,29 +1,10 @@
 /**
  * @license
- * Copyright (C) 2019 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.
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-coverage-layer_html';
+import {Side} from '../../../api/diff';
 import {CoverageRange, CoverageType, DiffLayer} from '../../../types/types';
-import {customElement, property} from '@polymer/decorators';
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-coverage-layer': GrCoverageLayer;
-  }
-}
 
 const TOOLTIP_MAP = new Map([
   [CoverageType.COVERED, 'Covered by tests.'],
@@ -32,21 +13,12 @@
   [CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'],
 ]);
 
-@customElement('gr-coverage-layer')
-export class GrCoverageLayer extends PolymerElement implements DiffLayer {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrCoverageLayer implements DiffLayer {
   /**
    * Must be sorted by code_range.start_line.
    * Must only contain ranges that match the side.
    */
-  @property({type: Array})
-  coverageRanges: CoverageRange[] = [];
-
-  @property({type: String})
-  side?: string;
+  private coverageRanges: CoverageRange[] = [];
 
   /**
    * We keep track of the line number from the previous annotate() call,
@@ -56,11 +28,22 @@
    * and efficient way for finding the coverage range that matches a given
    * line number.
    */
-  @property({type: Number})
-  _lineNumber = 0;
+  private lastLineNumber = 0;
 
-  @property({type: Number})
-  _index = 0;
+  /**
+   * See `lastLineNumber` comment.
+   */
+  private index = 0;
+
+  constructor(private readonly side: Side) {}
+
+  /**
+   * Must be sorted by code_range.start_line.
+   * Must only contain ranges that match the side.
+   */
+  setRanges(ranges: CoverageRange[]) {
+    this.coverageRanges = ranges;
+  }
 
   /**
    * Layer method to add annotations to a line.
@@ -87,27 +70,27 @@
     // If the line number is smaller than before, then we have to reset our
     // algorithm and start searching the coverage ranges from the beginning.
     // That happens for example when you expand diff sections.
-    if (elementLineNumber < this._lineNumber) {
-      this._index = 0;
+    if (elementLineNumber < this.lastLineNumber) {
+      this.index = 0;
     }
-    this._lineNumber = elementLineNumber;
+    this.lastLineNumber = elementLineNumber;
 
     // We simply loop through all the coverage ranges until we find one that
     // matches the line number.
-    while (this._index < this.coverageRanges.length) {
-      const coverageRange = this.coverageRanges[this._index];
+    while (this.index < this.coverageRanges.length) {
+      const coverageRange = this.coverageRanges[this.index];
 
       // If the line number has moved past the current coverage range, then
       // try the next coverage range.
-      if (this._lineNumber > coverageRange.code_range.end_line) {
-        this._index++;
+      if (this.lastLineNumber > coverageRange.code_range.end_line) {
+        this.index++;
         continue;
       }
 
       // If the line number has not reached the next coverage range (and the
       // range before also did not match), then this line has not been
       // instrumented. Nothing to do for this line.
-      if (this._lineNumber < coverageRange.code_range.start_line) {
+      if (this.lastLineNumber < coverageRange.code_range.start_line) {
         return;
       }
 
diff --git a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_html.ts b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_html.ts
deleted file mode 100644
index 1489006..0000000
--- a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_html.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.js b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.js
deleted file mode 100644
index e886e61..0000000
--- a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.js
+++ /dev/null
@@ -1,124 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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 '../../../test/common-test-setup-karma.js';
-import '../gr-diff/gr-diff-line.js';
-import './gr-coverage-layer.js';
-
-const basicFixture = fixtureFromElement('gr-coverage-layer');
-
-suite('gr-coverage-layer', () => {
-  let element;
-
-  setup(() => {
-    const initialCoverageRanges = [
-      {
-        type: 'COVERED',
-        side: 'right',
-        code_range: {
-          start_line: 1,
-          end_line: 2,
-        },
-      },
-      {
-        type: 'NOT_COVERED',
-        side: 'right',
-        code_range: {
-          start_line: 3,
-          end_line: 4,
-        },
-      },
-      {
-        type: 'PARTIALLY_COVERED',
-        side: 'right',
-        code_range: {
-          start_line: 5,
-          end_line: 6,
-        },
-      },
-      {
-        type: 'NOT_INSTRUMENTED',
-        side: 'right',
-        code_range: {
-          start_line: 8,
-          end_line: 9,
-        },
-      },
-    ];
-
-    element = basicFixture.instantiate();
-    element.coverageRanges = initialCoverageRanges;
-    element.side = 'right';
-  });
-
-  suite('annotate', () => {
-    function createLine(lineNumber) {
-      const lineEl = document.createElement('div');
-      lineEl.setAttribute('data-side', 'right');
-      lineEl.setAttribute('data-value', lineNumber);
-      lineEl.className = 'right';
-      return lineEl;
-    }
-
-    function checkLine(lineNumber, className, opt_negated) {
-      const line = createLine(lineNumber);
-      element.annotate(undefined, line, undefined);
-      let contains = line.classList.contains(className);
-      if (opt_negated) contains = !contains;
-      assert.isTrue(contains);
-    }
-
-    test('line 1-2 are covered', () => {
-      checkLine(1, 'COVERED');
-      checkLine(2, 'COVERED');
-    });
-
-    test('line 3-4 are not covered', () => {
-      checkLine(3, 'NOT_COVERED');
-      checkLine(4, 'NOT_COVERED');
-    });
-
-    test('line 5-6 are partially covered', () => {
-      checkLine(5, 'PARTIALLY_COVERED');
-      checkLine(6, 'PARTIALLY_COVERED');
-    });
-
-    test('line 7 is implicitly not instrumented', () => {
-      checkLine(7, 'COVERED', true);
-      checkLine(7, 'NOT_COVERED', true);
-      checkLine(7, 'PARTIALLY_COVERED', true);
-      checkLine(7, 'NOT_INSTRUMENTED', true);
-    });
-
-    test('line 8-9 are not instrumented', () => {
-      checkLine(8, 'NOT_INSTRUMENTED');
-      checkLine(9, 'NOT_INSTRUMENTED');
-    });
-
-    test('coverage correct, if annotate is called out of order', () => {
-      checkLine(8, 'NOT_INSTRUMENTED');
-      checkLine(1, 'COVERED');
-      checkLine(5, 'PARTIALLY_COVERED');
-      checkLine(3, 'NOT_COVERED');
-      checkLine(6, 'PARTIALLY_COVERED');
-      checkLine(4, 'NOT_COVERED');
-      checkLine(9, 'NOT_INSTRUMENTED');
-      checkLine(2, 'COVERED');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts
new file mode 100644
index 0000000..5687b10
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts
@@ -0,0 +1,113 @@
+/**
+ * @license
+ * Copyright 2019 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import {CoverageRange, CoverageType, Side} from '../../../api/diff';
+import {GrCoverageLayer} from './gr-coverage-layer';
+
+suite('gr-coverage-layer', () => {
+  let layer: GrCoverageLayer;
+
+  setup(() => {
+    const initialCoverageRanges: CoverageRange[] = [
+      {
+        type: CoverageType.COVERED,
+        side: Side.RIGHT,
+        code_range: {
+          start_line: 1,
+          end_line: 2,
+        },
+      },
+      {
+        type: CoverageType.NOT_COVERED,
+        side: Side.RIGHT,
+        code_range: {
+          start_line: 3,
+          end_line: 4,
+        },
+      },
+      {
+        type: CoverageType.PARTIALLY_COVERED,
+        side: Side.RIGHT,
+        code_range: {
+          start_line: 5,
+          end_line: 6,
+        },
+      },
+      {
+        type: CoverageType.NOT_INSTRUMENTED,
+        side: Side.RIGHT,
+        code_range: {
+          start_line: 8,
+          end_line: 9,
+        },
+      },
+    ];
+
+    layer = new GrCoverageLayer(Side.RIGHT);
+    layer.setRanges(initialCoverageRanges);
+  });
+
+  suite('annotate', () => {
+    function createLine(lineNumber: number) {
+      const lineEl = document.createElement('div');
+      lineEl.setAttribute('data-side', Side.RIGHT);
+      lineEl.setAttribute('data-value', lineNumber.toString());
+      lineEl.className = Side.RIGHT;
+      return lineEl;
+    }
+
+    function checkLine(
+      lineNumber: number,
+      className: string,
+      negated?: boolean
+    ) {
+      const content = document.createElement('div');
+      const line = createLine(lineNumber);
+      layer.annotate(content, line);
+      let contains = line.classList.contains(className);
+      if (negated) contains = !contains;
+      assert.isTrue(contains);
+    }
+
+    test('line 1-2 are covered', () => {
+      checkLine(1, 'COVERED');
+      checkLine(2, 'COVERED');
+    });
+
+    test('line 3-4 are not covered', () => {
+      checkLine(3, 'NOT_COVERED');
+      checkLine(4, 'NOT_COVERED');
+    });
+
+    test('line 5-6 are partially covered', () => {
+      checkLine(5, 'PARTIALLY_COVERED');
+      checkLine(6, 'PARTIALLY_COVERED');
+    });
+
+    test('line 7 is implicitly not instrumented', () => {
+      checkLine(7, 'COVERED', true);
+      checkLine(7, 'NOT_COVERED', true);
+      checkLine(7, 'PARTIALLY_COVERED', true);
+      checkLine(7, 'NOT_INSTRUMENTED', true);
+    });
+
+    test('line 8-9 are not instrumented', () => {
+      checkLine(8, 'NOT_INSTRUMENTED');
+      checkLine(9, 'NOT_INSTRUMENTED');
+    });
+
+    test('coverage correct, if annotate is called out of order', () => {
+      checkLine(8, 'NOT_INSTRUMENTED');
+      checkLine(1, 'COVERED');
+      checkLine(5, 'PARTIALLY_COVERED');
+      checkLine(3, 'NOT_COVERED');
+      checkLine(6, 'PARTIALLY_COVERED');
+      checkLine(4, 'NOT_COVERED');
+      checkLine(9, 'NOT_INSTRUMENTED');
+      checkLine(2, 'COVERED');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
index 39db35a..27ebe4e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -14,10 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../gr-coverage-layer/gr-coverage-layer';
 import '../gr-diff-processor/gr-diff-processor';
 import '../../../elements/shared/gr-hovercard/gr-hovercard';
-import '../gr-ranged-comment-layer/gr-ranged-comment-layer';
 import './gr-diff-builder-side-by-side';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-builder-element_html';
@@ -64,9 +62,6 @@
 export interface GrDiffBuilderElement {
   $: {
     processor: GrDiffProcessor;
-    rangeLayer: GrRangedCommentLayer;
-    coverageLayerLeft: GrCoverageLayer;
-    coverageLayerRight: GrCoverageLayer;
   };
 }
 
@@ -112,6 +107,12 @@
    */
 
   /**
+   * Fired whenever a new chunk of lines has been rendered synchronously.
+   *
+   * @event render-progress
+   */
+
+  /**
    * Fired when the diff finishes rendering text content.
    *
    * @event render-content
@@ -180,24 +181,12 @@
   @property({type: Array})
   commentRanges: CommentRangeLayer[] = [];
 
-  @property({type: Array})
+  @property({type: Array, observer: 'coverageObserver'})
   coverageRanges: CoverageRange[] = [];
 
   @property({type: Boolean})
   useNewImageDiffUi = false;
 
-  @property({
-    type: Array,
-    computed: '_computeLeftCoverageRanges(coverageRanges)',
-  })
-  _leftCoverageRanges?: CoverageRange[];
-
-  @property({
-    type: Array,
-    computed: '_computeRightCoverageRanges(coverageRanges)',
-  })
-  _rightCoverageRanges?: CoverageRange[];
-
   /**
    * The promise last returned from `render()` while the asynchronous
    * rendering is running - `null` otherwise. Provides a `cancel()`
@@ -206,6 +195,12 @@
   @property({type: Object})
   _cancelableRenderPromise: CancelablePromise<unknown> | null = null;
 
+  private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
+
+  private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
+
+  private rangeLayer = new GrRangedCommentLayer();
+
   constructor() {
     super();
     afterNextRender(this, () => {
@@ -232,12 +227,21 @@
     return this.querySelector('#diffTable') as HTMLTableElement;
   }
 
-  _computeLeftCoverageRanges(coverageRanges: CoverageRange[]) {
-    return coverageRanges.filter(range => range && range.side === 'left');
+  @observe('commentRanges.*')
+  rangeObserver() {
+    this.rangeLayer.updateRanges(this.commentRanges);
   }
 
-  _computeRightCoverageRanges(coverageRanges: CoverageRange[]) {
-    return coverageRanges.filter(range => range && range.side === 'right');
+  coverageObserver(coverageRanges: CoverageRange[]) {
+    const leftRanges = coverageRanges.filter(
+      range => range && range.side === Side.LEFT
+    );
+    this.coverageLayerLeft.setRanges(leftRanges);
+
+    const rightRanges = coverageRanges.filter(
+      range => range && range.side === Side.RIGHT
+    );
+    this.coverageLayerRight.setRanges(rightRanges);
   }
 
   render(keyLocations: KeyLocations) {
@@ -302,9 +306,9 @@
       this._createIntralineLayer(),
       this._createTabIndicatorLayer(),
       this._createSpecialCharacterIndicatorLayer(),
-      this.$.rangeLayer,
-      this.$.coverageLayerLeft,
-      this.$.coverageLayerRight,
+      this.rangeLayer,
+      this.coverageLayerLeft,
+      this.coverageLayerRight,
     ];
 
     if (this.layers) {
@@ -520,6 +524,7 @@
       );
       this._builder.addGroups(added);
     }
+    fireEvent(this, 'render-progress');
   }
 
   _createIntralineLayer(): DiffLayer {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts
index 573f559..581f0fb 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_html.ts
@@ -20,19 +20,5 @@
   <div class="contentWrapper">
     <slot></slot>
   </div>
-  <gr-ranged-comment-layer
-    id="rangeLayer"
-    comment-ranges="[[commentRanges]]"
-  ></gr-ranged-comment-layer>
-  <gr-coverage-layer
-    id="coverageLayerLeft"
-    coverage-ranges="[[_leftCoverageRanges]]"
-    side="left"
-  ></gr-coverage-layer>
-  <gr-coverage-layer
-    id="coverageLayerRight"
-    coverage-ranges="[[_rightCoverageRanges]]"
-    side="right"
-  ></gr-coverage-layer>
   <gr-diff-processor id="processor" groups="{{_groups}}"></gr-diff-processor>
 `;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
index 7404bbb..ceadc94 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
@@ -237,7 +237,8 @@
     }
 
     const cell = createElementDiff('td', 'dividerCell');
-    cell.setAttribute('colspan', '3');
+    const colspan = this.renderPrefs?.show_sign_col ? '5' : '3';
+    cell.setAttribute('colspan', colspan);
     row.appendChild(cell);
 
     const contextControls = createElementDiff(
@@ -271,9 +272,13 @@
     row.appendChild(this.createBlameCell(0));
     row.appendChild(createElementDiff('td', 'contextLineNum'));
     if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.appendChild(createElementDiff('td', 'sign'));
       row.appendChild(createElementDiff('td'));
     }
     row.appendChild(createElementDiff('td', 'contextLineNum'));
+    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.appendChild(createElementDiff('td', 'sign'));
+    }
     row.appendChild(createElementDiff('td'));
 
     return row;
@@ -288,6 +293,7 @@
     const td = createElementDiff('td');
     td.classList.add(side);
     if (line.type === GrDiffLineType.BLANK) {
+      td.classList.add('blankLineNum');
       return td;
     }
     if (line.type === GrDiffLineType.BOTH || line.type === type) {
@@ -439,8 +445,13 @@
 
   protected buildMoveControls(group: GrDiffGroup) {
     const movedIn = group.adds.length > 0;
-    const {numberOfCells, movedOutIndex, movedInIndex, lineNumberCols} =
-      this.getMoveControlsConfig();
+    const {
+      numberOfCells,
+      movedOutIndex,
+      movedInIndex,
+      lineNumberCols,
+      signCols,
+    } = this.getMoveControlsConfig();
 
     let controlsClass;
     let descriptionIndex;
@@ -461,6 +472,10 @@
       cells[index].classList.add('moveControlsLineNumCol');
     });
 
+    if (signCols) {
+      cells[signCols.left].classList.add('sign', 'left');
+      cells[signCols.right].classList.add('sign', 'right');
+    }
     const moveRangeHeader = createElementDiff('gr-range-header');
     moveRangeHeader.setAttribute('icon', 'gr-icons:move-item');
     moveRangeHeader.appendChild(descriptionTextDiv);
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
index 676c389..a711215 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
@@ -16,7 +16,7 @@
  */
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
+import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
 import {DiffViewMode, Side} from '../../../constants/constants';
 import {DiffLayer} from '../../../types/types';
 import {RenderPreferences} from '../../../api/diff';
@@ -36,10 +36,11 @@
 
   protected override getMoveControlsConfig() {
     return {
-      numberOfCells: 4,
-      movedOutIndex: 1,
-      movedInIndex: 3,
-      lineNumberCols: [0, 2],
+      numberOfCells: 6,
+      movedOutIndex: 2,
+      movedInIndex: 5,
+      lineNumberCols: [0, 3],
+      signCols: {left: 1, right: 4},
     };
   }
 
@@ -83,6 +84,8 @@
     col.setAttribute('width', lineNumberWidth.toString());
     colgroup.appendChild(col);
 
+    colgroup.appendChild(createElementDiff('col', 'sign left'));
+
     // Add left-side content.
     colgroup.appendChild(createElementDiff('col', 'left'));
 
@@ -91,6 +94,8 @@
     col.setAttribute('width', lineNumberWidth.toString());
     colgroup.appendChild(col);
 
+    colgroup.appendChild(createElementDiff('col', 'sign right'));
+
     // Add right-side content.
     colgroup.appendChild(document.createElement('col'));
 
@@ -120,9 +125,28 @@
   ) {
     const lineNumberEl = this.createLineEl(line, lineNumber, line.type, side);
     row.appendChild(lineNumberEl);
+    row.appendChild(this.createSignEl(line, side));
     row.appendChild(this.createTextEl(lineNumberEl, line, side));
   }
 
+  private createSignEl(line: GrDiffLine, side: Side): HTMLElement {
+    const td = createElementDiff('td', 'sign');
+    td.classList.add(side);
+    if (line.type === GrDiffLineType.BLANK) {
+      td.classList.add('blank');
+    } else if (line.type === GrDiffLineType.ADD && side === Side.RIGHT) {
+      td.classList.add('add');
+      td.innerText = '+';
+    } else if (line.type === GrDiffLineType.REMOVE && side === Side.LEFT) {
+      td.classList.add('remove');
+      td.innerText = '-';
+    }
+    if (!line.hasIntralineInfo) {
+      td.classList.add('no-intraline-info');
+    }
+    return td;
+  }
+
   protected override getNextContentOnSide(
     content: HTMLElement,
     side: Side
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
index ee3027c..4efa238 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
@@ -206,8 +206,9 @@
     let endIndex = this.groups.findIndex(group =>
       group.containsLine(side, endLine)
     );
-    // Not all groups may have rendered yet. In that case let's just render
-    // *all* groups after `startIndex`.
+    // Not all groups may have been processed yet (i.e. this.groups is still
+    // incomplete). In that case let's just return *all* groups until the end
+    // of the array.
     if (endIndex === -1) endIndex = this.groups.length - 1;
     // The filter preserves the legacy behavior to only return non-context
     // groups
@@ -328,6 +329,7 @@
     movedOutIndex: number;
     movedInIndex: number;
     lineNumberCols: number[];
+    signCols?: {left: number; right: number};
   };
 
   /**
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
index 8c8a087..4ecdcb0 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -336,6 +336,10 @@
     this.preventAutoScrollOnManualScroll = true;
   };
 
+  private _boundHandleDiffRenderProgress = () => {
+    this._updateStops();
+  };
+
   private _boundHandleDiffRenderContent = () => {
     this._updateStops();
     // When done rendering, turn focus on move and automatic scrolling back on
@@ -547,6 +551,10 @@
     );
     diff.removeEventListener('render-start', this._boundHandleDiffRenderStart);
     diff.removeEventListener(
+      'render-progress',
+      this._boundHandleDiffRenderProgress
+    );
+    diff.removeEventListener(
       'render-content',
       this._boundHandleDiffRenderContent
     );
@@ -562,6 +570,10 @@
       this.boundHandleDiffLoadingChanged
     );
     diff.addEventListener('render-start', this._boundHandleDiffRenderStart);
+    diff.addEventListener(
+      'render-progress',
+      this._boundHandleDiffRenderProgress
+    );
     diff.addEventListener('render-content', this._boundHandleDiffRenderContent);
     diff.addEventListener('line-selected', this._boundHandleDiffLineSelected);
   }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
index c25b284..1068a8d 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -375,15 +375,15 @@
       color === this.backgroundColor && !this.checkerboardSelected;
     return html`
       <div
-        class="${classMap({
+        class=${classMap({
           'color-picker-button': true,
           selected,
-        })}"
+        })}
       >
         <paper-icon-button
           class="color"
-          style="${styleMap({backgroundColor: color})}"
-          @click="${colorPicked}"
+          style=${styleMap({backgroundColor: color})}
+          @click=${colorPicked}
         ></paper-icon-button>
       </div>
     `;
@@ -392,14 +392,14 @@
   private renderCheckerboardButton() {
     return html`
       <div
-        class="${classMap({
+        class=${classMap({
           'color-picker-button': true,
           selected: this.checkerboardSelected,
-        })}"
+        })}
       >
         <paper-icon-button
           class="color checkerboard"
-          @click="${this.pickCheckerboard}"
+          @click=${this.pickCheckerboard}
         >
         </paper-icon-button>
       </div>
@@ -412,14 +412,14 @@
     const sourceImage = html`
       <img
         id="source-image"
-        src="${src}"
-        class="${classMap({checkerboard: this.checkerboardSelected})}"
-        style="${styleMap({
+        src=${src}
+        class=${classMap({checkerboard: this.checkerboardSelected})}
+        style=${styleMap({
           backgroundColor: this.checkerboardSelected
             ? ''
             : this.backgroundColor,
-        })}"
-        @load="${this.updateSizes}"
+        })}
+        @load=${this.updateSizes}
       />
     `;
 
@@ -428,15 +428,15 @@
         ${sourceImage}
         <img
           id="highlight-image"
-          style="${styleMap({
+          style=${styleMap({
             opacity: this.showHighlight ? '1' : '0',
             // When the highlight layer is not being shown, saving the image or
             // opening it in a new tab from the context menu, e.g. for external
             // comparison, should give back the source image, not the highlight
             // layer.
             'pointer-events': this.showHighlight ? 'auto' : 'none',
-          })}"
-          src="${ifDefined(this.diffHighlightSrc)}"
+          })}
+          src=${ifDefined(this.diffHighlightSrc)}
         />
       </div>
     `;
@@ -461,17 +461,14 @@
     };
     const versionToggle = html`
       <div id="version-switcher">
-        <paper-button
-          class="${classMap(leftClasses)}"
-          @click="${this.selectBase}"
-        >
+        <paper-button class=${classMap(leftClasses)} @click=${this.selectBase}>
           Base
         </paper-button>
-        <paper-fab mini icon="gr-icons:swapHoriz" @click="${this.manualBlink}">
+        <paper-fab mini icon="gr-icons:swapHoriz" @click=${this.manualBlink}>
         </paper-fab>
         <paper-button
-          class="${classMap(rightClasses)}"
-          @click="${this.selectRevision}"
+          class=${classMap(rightClasses)}
+          @click=${this.selectRevision}
         >
           Revision
         </paper-button>
@@ -486,8 +483,8 @@
       ? html`
           <paper-checkbox
             id="highlight-changes"
-            ?checked="${this.showHighlight}"
-            @change="${this.showHighlightChanged}"
+            ?checked=${this.showHighlight}
+            @change=${this.showHighlightChanged}
           >
             Highlight differences
           </paper-checkbox>
@@ -496,17 +493,17 @@
 
     const overviewImage = html`
       <gr-overview-image
-        .frameRect="${this.overviewFrame}"
-        @center-updated="${this.onOverviewCenterUpdated}"
+        .frameRect=${this.overviewFrame}
+        @center-updated=${this.onOverviewCenterUpdated}
       >
         <img
-          src="${src}"
-          class="${classMap({checkerboard: this.checkerboardSelected})}"
-          style="${styleMap({
+          src=${src}
+          class=${classMap({checkerboard: this.checkerboardSelected})}
+          style=${styleMap({
             backgroundColor: this.checkerboardSelected
               ? ''
               : this.backgroundColor,
-          })}"
+          })}
         />
       </gr-overview-image>
     `;
@@ -516,12 +513,12 @@
         <paper-listbox
           slot="dropdown-content"
           selected="fit"
-          .attrForSelected="${'value'}"
-          @selected-changed="${this.zoomControlChanged}"
+          .attrForSelected=${'value'}
+          @selected-changed=${this.zoomControlChanged}
         >
           ${this.zoomLevels.map(
             zoomLevel => html`
-              <paper-item value="${zoomLevel}">
+              <paper-item value=${zoomLevel}>
                 ${zoomLevel === 'fit' ? 'Fit' : `${zoomLevel * 100}%`}
               </paper-item>
             `
@@ -533,8 +530,8 @@
     const followMouse = html`
       <paper-checkbox
         id="follow-mouse"
-        ?checked="${this.followMouse}"
-        @change="${this.followMouseChanged}"
+        ?checked=${this.followMouse}
+        @change=${this.followMouseChanged}
       >
         Magnifier follows mouse
       </paper-checkbox>
@@ -566,20 +563,20 @@
     const spacer = html`
       <div
         id="spacer"
-        style="${styleMap({
+        style=${styleMap({
           width: `${spacerWidth}px`,
           height: `${spacerHeight}px`,
-        })}"
+        })}
       ></div>
     `;
 
     const automaticBlink = html`
       <paper-fab
         id="automatic-blink-button"
-        class="${classMap({show: this.automaticBlinkShown})}"
+        class=${classMap({show: this.automaticBlinkShown})}
         title="Automatic blink"
         icon="gr-icons:${this.automaticBlink ? 'pause' : 'playArrow'}"
-        @click="${this.toggleAutomaticBlink}"
+        @click=${this.toggleAutomaticBlink}
       >
       </paper-fab>
     `;
@@ -609,25 +606,25 @@
       ${customStyle}
       <div
         class="imageArea"
-        @mousemove="${this.mousemoveImageArea}"
-        @mouseleave="${this.mouseleaveImageArea}"
+        @mousemove=${this.mousemoveImageArea}
+        @mouseleave=${this.mouseleaveImageArea}
       >
         <gr-zoomed-image
-          class="${classMap({
+          class=${classMap({
             base: this.baseSelected,
             revision: !this.baseSelected,
-          })}"
-          style="${styleMap({
+          })}
+          style=${styleMap({
             ...this.zoomedImageStyle,
             cursor: this.grabbing ? 'grabbing' : 'pointer',
-          })}"
-          .scale="${this.scale}"
-          .frameRect="${this.magnifierFrame}"
-          @mousedown="${this.mousedownMagnifier}"
-          @mouseup="${this.mouseupMagnifier}"
-          @mousemove="${this.mousemoveMagnifier}"
-          @mouseleave="${this.mouseleaveMagnifier}"
-          @dragstart="${this.dragstartMagnifier}"
+          })}
+          .scale=${this.scale}
+          .frameRect=${this.magnifierFrame}
+          @mousedown=${this.mousedownMagnifier}
+          @mouseup=${this.mouseupMagnifier}
+          @mousemove=${this.mousemoveMagnifier}
+          @mouseleave=${this.mouseleaveMagnifier}
+          @dragstart=${this.dragstartMagnifier}
         >
           ${sourceImageWithHighlight}
         </gr-zoomed-image>
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
index 28e6d82..49a0eb5 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
@@ -124,26 +124,26 @@
       <div class="content-box">
         <div
           class="content"
-          style="${styleMap({
+          style=${styleMap({
             ...this.contentStyle,
-          })}"
-          @mousemove="${this.maybeDragFrame}"
+          })}
+          @mousemove=${this.maybeDragFrame}
           @mousedown=${this.clickOverview}
-          @mouseup="${this.releaseFrame}"
+          @mouseup=${this.releaseFrame}
         >
           <div
             class="content-transform"
-            style="${styleMap(this.contentTransformStyle)}"
+            style=${styleMap(this.contentTransformStyle)}
           >
             <slot></slot>
           </div>
           <div
             class="frame"
-            style="${styleMap({
+            style=${styleMap({
               ...this.frameStyle,
               cursor: this.dragging ? 'grabbing' : 'grab',
-            })}"
-            @mousedown="${this.grabFrame}"
+            })}
+            @mousedown=${this.grabFrame}
           ></div>
         </div>
       </div>
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts
index 66d4671..6fdec67 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts
@@ -59,7 +59,7 @@
   override render() {
     return html`
       <div id="clip">
-        <div id="transform" style="${styleMap(this.imageStyles)}">
+        <div id="transform" style=${styleMap(this.imageStyles)}>
           <slot></slot>
         </div>
       </div>
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index 9034726..f7e40f9 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -485,10 +485,16 @@
   getCursorStops(): Array<HTMLElement | AbortStop> {
     if (this.hidden && this.noAutoRender) return [];
 
+    // Get rendered stops.
+    const stops: Array<HTMLElement | AbortStop> =
+      this.$.diffBuilder.getLineNumberRows();
+
+    // If we are still loading this diff, abort after the rendered stops to
+    // avoid skipping over to e.g. the next file.
     if (this.loading) {
-      return [new AbortStop()];
+      stops.push(new AbortStop());
     }
-    return this.$.diffBuilder.getLineNumberRows();
+    return stops;
   }
 
   isRangeSelected() {
@@ -753,6 +759,10 @@
       // border-right in ".section" css definition (in gr-diff_html.ts)
       const sectionRightBorder = '1px';
 
+      // each sign col has 1ch width.
+      const signColsWidth =
+        sideBySide && renderPrefs?.show_sign_col ? '2ch' : '0ch';
+
       // As some of these calculations are done using 'ch' we end up
       // having <1px difference between ideal and calculated size for each side
       // leading to lines using the max columns (e.g. 80) to wrap (decided
@@ -766,7 +776,7 @@
       const dontWrapCorrection = '2px';
       stylesToUpdate[
         '--diff-max-width'
-      ] = `calc(${contentWidth} + ${lineNumberWidth} + ${sectionRightBorder} + ${dontWrapCorrection})`;
+      ] = `calc(${contentWidth} + ${lineNumberWidth} + ${signColsWidth} + ${sectionRightBorder} + ${dontWrapCorrection})`;
     } else {
       stylesToUpdate['--diff-max-width'] = 'none';
     }
@@ -788,6 +798,9 @@
     if (renderPrefs.hide_line_length_indicator) {
       this.classList.add('hide-line-length-indicator');
     }
+    if (renderPrefs.show_sign_col) {
+      this.classList.add('with-sign-col');
+    }
     if (this.prefs) {
       this._updatePreferenceStyles(this.prefs, renderPrefs);
     }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
index f8d9b21..c2e5550 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_html.ts
@@ -20,11 +20,11 @@
   <style include="shared-styles">
     /**
      * This is used to hide all left side of the diff (e.g. diffs besides comments
-     * in the change log). Since we want to remove the first 3 cells consistently
+     * in the change log). Since we want to remove the first 4 cells consistently
      * in all rows except context buttons (.dividerRow).
      */
-    :host(.no-left) .sideBySide colgroup col:nth-child(-n + 3),
-    :host(.no-left) .sideBySide tr:not(.dividerRow) td:nth-child(-n + 3) {
+    :host(.no-left) .sideBySide colgroup col:nth-child(-n + 4),
+    :host(.no-left) .sideBySide tr:not(.dividerRow) td:nth-child(-n + 4) {
       display: none;
     }
     :host(.disable-context-control-buttons) {
@@ -70,8 +70,8 @@
     }
 
     /* Provides the option to add side borders (left and right) to the line number column. */
-    td.left,
-    td.right,
+    td.lineNum,
+    td.blankLineNum,
     td.moveControlsLineNumCol,
     td.contextLineNum {
       box-shadow: var(--line-number-box-shadow, unset);
@@ -200,6 +200,14 @@
     }
 
     /**
+     * the outline for the sign cell should be always be contiguous top/bottom.
+     */
+    .target-row.target-side-left td.left.sign::before,
+    .target-row.target-side-right td.right.sign::before {
+      border-width: 1px 0;
+    }
+
+    /**
      * For side-by-side we need to select the correct line number to "visually close"
      * the outline.
      */
@@ -286,6 +294,14 @@
     .canComment .lineNumButton {
       cursor: pointer;
     }
+    .sign {
+      min-width: 1ch;
+      width: 1ch;
+      background-color: var(--view-background-color);
+    }
+    .sign.blank {
+      background-color: var(--diff-blank-background-color);
+    }
     .content {
       /* Set min width since setting width on table cells still
            allows them to shrink. Do not set max width because
@@ -296,22 +312,30 @@
     .content.add .contentText .intraline,
       /* If there are no intraline info, consider everything changed */
       .content.add.no-intraline-info .contentText,
+      .sign.add.no-intraline-info,
       .delta.total .content.add .contentText {
       background-color: var(--dark-add-highlight-color);
     }
-    .content.add .contentText {
+    .content.add .contentText,
+    .sign.add {
       background-color: var(--light-add-highlight-color);
     }
     .content.remove .contentText .intraline,
       /* If there are no intraline info, consider everything changed */
       .content.remove.no-intraline-info .contentText,
-      .delta.total .content.remove .contentText {
+      .delta.total .content.remove .contentText,
+      .sign.remove.no-intraline-info {
       background-color: var(--dark-remove-highlight-color);
     }
-    .content.remove .contentText {
+    .content.remove .contentText,
+    .sign.remove {
       background-color: var(--light-remove-highlight-color);
     }
 
+    .ignoredWhitespaceOnly .sign.no-intraline-info {
+      background-color: var(--view-background-color);
+    }
+
     /* dueToRebase */
     .dueToRebase .content.add .contentText .intraline,
     .delta.total.dueToRebase .content.add .contentText {
@@ -329,14 +353,18 @@
     }
 
     /* dueToMove */
+    .dueToMove .sign.add,
     .dueToMove .content.add .contentText,
+    .dueToMove .moveControls.movedIn .sign.right,
     .dueToMove .moveControls.movedIn .moveHeader,
     .delta.total.dueToMove .content.add .contentText {
       background-color: var(--diff-moved-in-background);
     }
 
+    .dueToMove .sign.remove,
     .dueToMove .content.remove .contentText,
     .dueToMove .moveControls.movedOut .moveHeader,
+    .dueToMove .moveControls.movedOut .sign.left,
     .delta.total.dueToMove .content.remove .contentText {
       background-color: var(--diff-moved-out-background);
     }
@@ -495,6 +523,21 @@
       padding: 0 var(--spacing-s) 0 var(--spacing-m);
       color: var(--blue-700);
     }
+
+    col.sign,
+    td.sign {
+      display: none;
+    }
+
+    /**
+     * Sign column should only be shown in high-contrast mode.
+     */
+    :host(.with-sign-col) col.sign {
+      display: table-column;
+    }
+    :host(.with-sign-col) td.sign {
+      display: table-cell;
+    }
     col.blame {
       display: none;
     }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
index 2c84ce3..714005e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.js
@@ -25,6 +25,7 @@
 import '@polymer/paper-button/paper-button.js';
 import {Side} from '../../../api/diff.js';
 import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
+import {AbortStop} from '../../../api/core.js';
 
 const basicFixture = fixtureFromElement('gr-diff');
 
@@ -124,14 +125,14 @@
       element.viewMode = 'SIDE_BY_SIDE';
       flush();
       assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 48px + 1px + 2px)');
+          'calc(2 * 80ch + 2 * 48px + 0ch + 1px + 2px)');
     });
 
     test('max-width considers one content column in unified', () => {
       element.viewMode = 'UNIFIED_DIFF';
       flush();
       assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(1 * 80ch + 2 * 48px + 1px + 2px)');
+          'calc(1 * 80ch + 2 * 48px + 0ch + 1px + 2px)');
     });
 
     test('max-width considers font-size', () => {
@@ -139,7 +140,14 @@
       flush();
       // Each line number column: 4 * 13 = 52px
       assert.equal(getComputedStyleValue('--diff-max-width', element),
-          'calc(2 * 80ch + 2 * 52px + 1px + 2px)');
+          'calc(2 * 80ch + 2 * 52px + 0ch + 1px + 2px)');
+    });
+
+    test('sign cols are considered if show_sign_col is true', () => {
+      element.renderPrefs = {...element.renderPrefs, show_sign_col: true};
+      flush();
+      assert.equal(getComputedStyleValue('--diff-max-width', element),
+          'calc(2 * 80ch + 2 * 48px + 2ch + 1px + 2px)');
     });
   });
 
@@ -536,23 +544,38 @@
         };
 
         element._renderDiffTable();
-        element._setLoading(false);
+
         flush();
       }
 
-      test('getCursorStops returns [] when hidden and noAutoRender', () => {
+      test('returns [] when hidden and noAutoRender', () => {
         element.noAutoRender = true;
         setupDiff();
+        element._setLoading(false);
+        flush();
         element.hidden = true;
         assert.equal(element.getCursorStops().length, 0);
       });
 
-      test('getCursorStops', () => {
+      test('returns one stop per line and one for the file row', () => {
         setupDiff();
+        element._setLoading(false);
+        flush();
         const ROWS = 48;
         const FILE_ROW = 1;
         assert.equal(element.getCursorStops().length, ROWS + FILE_ROW);
       });
+
+      test('returns an additional AbortStop when still loading', () => {
+        setupDiff();
+        element._setLoading(true);
+        flush();
+        const ROWS = 48;
+        const FILE_ROW = 1;
+        const actual = element.getCursorStops();
+        assert.equal(actual.length, ROWS + FILE_ROW + 1);
+        assert.isTrue(actual[actual.length -1] instanceof AbortStop);
+      });
     });
 
     test('adds .hiddenscroll', () => {
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
index 9cab977..6c8a5e9 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -1,30 +1,12 @@
 /**
  * @license
- * Copyright (C) 2016 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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-ranged-comment-layer_html';
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {strToClassName} from '../../../utils/dom-util';
-import {customElement, property, observe} from '@polymer/decorators';
 import {Side} from '../../../constants/constants';
-import {
-  PolymerDeepPropertyChange,
-  PolymerSpliceChange,
-} from '@polymer/polymer/interfaces';
 import {CommentRange} from '../../../types/common';
 import {DiffLayer, DiffLayerListener} from '../../../types/types';
 import {isLongCommentRange} from '../gr-diff/gr-diff-utils';
@@ -39,7 +21,18 @@
   side: Side;
   range: CommentRange;
   hovering: boolean;
-  rootId: string;
+  // New drafts don't have a rootId.
+  rootId?: string;
+}
+
+/** Can be used for array functions like `some()`. */
+function equals(a: CommentRangeLayer) {
+  return (b: CommentRangeLayer) => id(a) === id(b);
+}
+
+function id(r: CommentRangeLayer): string {
+  if (r.rootId) return r.rootId;
+  return `${r.side}-${r.range.start_line}-${r.range.start_character}-${r.range.end_line}-${r.range.end_character}`;
 }
 
 /**
@@ -49,8 +42,10 @@
 interface CommentRangeLineLayer {
   hovering: boolean;
   longRange: boolean;
-  rootId: string;
+  id: string;
+  // start char (0-based)
   start: number;
+  // end char (0-based)
   end: number;
 }
 
@@ -62,40 +57,16 @@
   [side in Side]: LinesMap;
 };
 
-// Polymer 1 adds # before array's key, while Polymer 2 doesn't
-const HOVER_PATH_PATTERN = /^(commentRanges\.#?\d+)\.hovering$/;
-
 const RANGE_BASE_ONLY = 'style-scope gr-diff range';
 const RANGE_HIGHLIGHT = 'style-scope gr-diff range rangeHighlight';
 const HOVER_HIGHLIGHT = 'style-scope gr-diff range rangeHoverHighlight';
 
-@customElement('gr-ranged-comment-layer')
-export class GrRangedCommentLayer extends PolymerElement implements DiffLayer {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrRangedCommentLayer implements DiffLayer {
+  private knownRanges: CommentRangeLayer[] = [];
 
-  /**
-   * Fired when the range in a range comment was malformed and had to be
-   * normalized.
-   *
-   * It's `detail` has a `lineNum` and `side` parameter.
-   *
-   * @event normalize-range
-   */
+  private listeners: DiffLayerListener[] = [];
 
-  @property({type: Array})
-  commentRanges: CommentRangeLayer[] = [];
-
-  @property({type: Array})
-  _listeners: DiffLayerListener[] = [];
-
-  @property({type: Object})
-  _rangesMap: RangesMap = {left: {}, right: {}};
-
-  get styleModuleName() {
-    return 'gr-ranged-comment-styles';
-  }
+  private rangesMap: RangesMap = {left: {}, right: {}};
 
   /**
    * Layer method to add annotations to a line.
@@ -107,16 +78,16 @@
     if (
       line.type === GrDiffLineType.REMOVE ||
       (line.type === GrDiffLineType.BOTH &&
-        el.getAttribute('data-side') !== 'right')
+        el.getAttribute('data-side') !== Side.RIGHT)
     ) {
-      ranges = ranges.concat(this._getRangesForLine(line, Side.LEFT));
+      ranges = this.getRangesForLine(line, Side.LEFT);
     }
     if (
       line.type === GrDiffLineType.ADD ||
       (line.type === GrDiffLineType.BOTH &&
-        el.getAttribute('data-side') !== 'left')
+        el.getAttribute('data-side') !== Side.LEFT)
     ) {
-      ranges = ranges.concat(this._getRangesForLine(line, Side.RIGHT));
+      ranges = this.getRangesForLine(line, Side.RIGHT);
     }
 
     for (const range of ranges) {
@@ -128,7 +99,7 @@
           ? HOVER_HIGHLIGHT
           : range.longRange
           ? RANGE_BASE_ONLY
-          : RANGE_HIGHLIGHT) + ` ${strToClassName(range.rootId)}`
+          : RANGE_HIGHLIGHT) + ` ${strToClassName(range.id)}`
       );
     }
   }
@@ -137,115 +108,71 @@
    * Register a listener for layer updates.
    */
   addListener(listener: DiffLayerListener) {
-    this._listeners.push(listener);
+    this.listeners.push(listener);
   }
 
   removeListener(listener: DiffLayerListener) {
-    this._listeners = this._listeners.filter(f => f !== listener);
+    this.listeners = this.listeners.filter(f => f !== listener);
   }
 
   /**
    * Notify Layer listeners of changes to annotations.
    */
-  _notifyUpdateRange(start: number, end: number, side: Side) {
-    for (const listener of this._listeners) {
+  private notifyUpdateRange(start: number, end: number, side: Side) {
+    for (const listener of this.listeners) {
       listener(start, end, side);
     }
   }
 
-  /**
-   * Handle change in the ranges by updating the ranges maps and by
-   * emitting appropriate update notifications.
-   */
-  @observe('commentRanges.*')
-  _handleCommentRangesChange(
-    record: PolymerDeepPropertyChange<
-      CommentRangeLayer[],
-      PolymerSpliceChange<CommentRangeLayer[]>
-    >
-  ) {
-    if (!record) return;
-
-    // If the entire set of comments was changed.
-    if (record.path === 'commentRanges') {
-      const value = record.value as CommentRangeLayer[];
-      this._rangesMap = {left: {}, right: {}};
-      for (const {side, range, rootId, hovering} of value) {
-        const longRange = isLongCommentRange(range);
-        this._updateRangesMap({
-          side,
-          range,
-          hovering,
-          operation: (forLine, start, end, hovering) => {
-            forLine.push({start, end, hovering, rootId, longRange});
-          },
-        });
-      }
+  updateRanges(newRanges: CommentRangeLayer[]) {
+    for (const newRange of newRanges) {
+      if (this.knownRanges.some(equals(newRange))) continue;
+      this.addRange(newRange);
     }
 
-    // If the change only changed the `hovering` property of a comment.
-    const match = record.path.match(HOVER_PATH_PATTERN);
-    if (match) {
-      // The #number indicates the key of that item in the array
-      // not the index, especially in polymer 1.
-      const {side, range, hovering, rootId} = this.get(match[1]);
-
-      this._updateRangesMap({
-        side,
-        range,
-        hovering,
-        skipLayerUpdate: true,
-        operation: (forLine, start, end, hovering) => {
-          const index = forLine.findIndex(
-            lineRange => lineRange.start === start && lineRange.end === end
-          );
-          forLine[index].hovering = hovering;
-          forLine[index].rootId = rootId;
-        },
-      });
+    for (const knownRange of this.knownRanges) {
+      if (newRanges.some(equals(knownRange))) continue;
+      this.removeRange(knownRange);
     }
 
-    // If comments were spliced in or out.
-    if (record.path === 'commentRanges.splices') {
-      const value = record.value as PolymerSpliceChange<CommentRangeLayer[]>;
-      for (const indexSplice of value.indexSplices) {
-        const removed = indexSplice.removed;
-        for (const {side, range, hovering, rootId} of removed) {
-          this._updateRangesMap({
-            side,
-            range,
-            hovering,
-            operation: (forLine, start, end) => {
-              const index = forLine.findIndex(
-                lineRange =>
-                  lineRange.start === start &&
-                  lineRange.end === end &&
-                  rootId === lineRange.rootId
-              );
-              forLine.splice(index, 1);
-            },
-          });
-        }
-        const added = indexSplice.object.slice(
-          indexSplice.index,
-          indexSplice.index + indexSplice.addedCount
-        );
-        for (const {side, range, hovering, rootId} of added) {
-          const longRange = isLongCommentRange(range);
-          this._updateRangesMap({
-            side,
-            range,
-            hovering,
-            operation: (forLine, start, end, hovering) => {
-              forLine.push({start, end, hovering, rootId, longRange});
-            },
-          });
-        }
-      }
-    }
+    this.knownRanges = [...newRanges];
   }
 
-  _updateRangesMap(options: {
+  private addRange(commentRange: CommentRangeLayer) {
+    const {side, range, hovering} = commentRange;
+    const longRange = isLongCommentRange(range);
+    this.updateRangesMap({
+      side,
+      range,
+      hovering,
+      operation: (forLine, startChar, endChar, hovering) => {
+        forLine.push({
+          start: startChar,
+          end: endChar,
+          hovering,
+          id: id(commentRange),
+          longRange,
+        });
+      },
+    });
+  }
+
+  private removeRange(commentRange: CommentRangeLayer) {
+    const {side, range, hovering} = commentRange;
+    this.updateRangesMap({
+      side,
+      range,
+      hovering,
+      operation: forLine => {
+        const index = forLine.findIndex(
+          lineRange => id(commentRange) === lineRange.id
+        );
+        if (index > -1) forLine.splice(index, 1);
+      },
+    });
+  }
+
+  private updateRangesMap(options: {
     side: Side;
     range: CommentRange;
     hovering: boolean;
@@ -255,25 +182,23 @@
       end: number,
       hovering: boolean
     ) => void;
-    skipLayerUpdate?: boolean;
   }) {
-    const {side, range, hovering, operation, skipLayerUpdate} = options;
-    const forSide = this._rangesMap[side] || (this._rangesMap[side] = {});
+    const {side, range, hovering, operation} = options;
+    const forSide = this.rangesMap[side] || (this.rangesMap[side] = {});
     for (let line = range.start_line; line <= range.end_line; line++) {
       const forLine = forSide[line] || (forSide[line] = []);
       const start = line === range.start_line ? range.start_character : 0;
       const end = line === range.end_line ? range.end_character : -1;
       operation(forLine, start, end, hovering);
     }
-    if (!skipLayerUpdate) {
-      this._notifyUpdateRange(range.start_line, range.end_line, side);
-    }
+    this.notifyUpdateRange(range.start_line, range.end_line, side);
   }
 
-  _getRangesForLine(line: GrDiffLine, side: Side) {
+  // visible for testing
+  getRangesForLine(line: GrDiffLine, side: Side): CommentRangeLineLayer[] {
     const lineNum = side === Side.LEFT ? line.beforeNumber : line.afterNumber;
-    const ranges: CommentRangeLineLayer[] =
-      this.get(['_rangesMap', side, lineNum]) || [];
+    if (lineNum === 'FILE' || lineNum === 'LOST') return [];
+    const ranges: CommentRangeLineLayer[] = this.rangesMap[side][lineNum] || [];
     return (
       ranges
         .map(range => {
@@ -287,13 +212,6 @@
           // @see Issue 5744
           if (range.start >= range.end && range.start < line.text.length) {
             range.end = line.text.length;
-            this.dispatchEvent(
-              new CustomEvent('normalize-range', {
-                bubbles: true,
-                composed: true,
-                detail: {lineNum, side},
-              })
-            );
           }
 
           return range;
@@ -303,9 +221,3 @@
     );
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-ranged-comment-layer': GrRangedCommentLayer;
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.ts
deleted file mode 100644
index 1489006..0000000
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_html.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html``;
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
deleted file mode 100644
index 8279ab1..0000000
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
+++ /dev/null
@@ -1,353 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 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 '../../../test/common-test-setup-karma.js';
-import '../gr-diff/gr-diff-line.js';
-import './gr-ranged-comment-layer.js';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
-
-const basicFixture = fixtureFromElement('gr-ranged-comment-layer');
-
-suite('gr-ranged-comment-layer', () => {
-  let element;
-
-  setup(() => {
-    const initialCommentRanges = [
-      {
-        side: 'left',
-        range: {
-          end_character: 9,
-          end_line: 39,
-          start_character: 6,
-          start_line: 36,
-        },
-        rootId: 'a',
-      },
-      {
-        side: 'right',
-        range: {
-          end_character: 22,
-          end_line: 12,
-          start_character: 10,
-          start_line: 10,
-        },
-        rootId: 'b',
-      },
-      {
-        side: 'right',
-        range: {
-          end_character: 15,
-          end_line: 100,
-          start_character: 5,
-          start_line: 100,
-        },
-        rootId: 'c',
-      },
-      {
-        side: 'right',
-        range: {
-          end_character: 2,
-          end_line: 55,
-          start_character: 32,
-          start_line: 55,
-        },
-        rootId: 'd',
-      },
-      {
-        side: 'right',
-        range: {
-          end_character: 1,
-          end_line: 71,
-          start_character: 1,
-          start_line: 60,
-        },
-      },
-    ];
-
-    element = basicFixture.instantiate();
-    element.commentRanges = initialCommentRanges;
-  });
-
-  suite('annotate', () => {
-    let el;
-    let line;
-    let annotateElementStub;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-      el = document.createElement('div');
-      el.setAttribute('data-side', 'left');
-      line = new GrDiffLine(GrDiffLineType.BOTH);
-      line.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,';
-    });
-
-    test('type=Remove no-comment', () => {
-      line.type = GrDiffLineType.REMOVE;
-      line.beforeNumber = 40;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('type=Remove has-comment', () => {
-      line.type = GrDiffLineType.REMOVE;
-      line.beforeNumber = 36;
-      const expectedStart = 6;
-      const expectedLength = line.text.length - expectedStart;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementStub.called);
-      const lastCall = annotateElementStub.lastCall;
-      assert.equal(lastCall.args[0], el);
-      assert.equal(lastCall.args[1], expectedStart);
-      assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(
-          lastCall.args[3],
-          'style-scope gr-diff range rangeHighlight generated_a'
-      );
-    });
-
-    test('type=Remove has-comment hovering', () => {
-      line.type = GrDiffLineType.REMOVE;
-      line.beforeNumber = 36;
-      element.set(['commentRanges', 0, 'hovering'], true);
-
-      const expectedStart = 6;
-      const expectedLength = line.text.length - expectedStart;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementStub.called);
-      const lastCall = annotateElementStub.lastCall;
-      assert.equal(lastCall.args[0], el);
-      assert.equal(lastCall.args[1], expectedStart);
-      assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(
-          lastCall.args[3],
-          'style-scope gr-diff range rangeHoverHighlight generated_a'
-      );
-    });
-
-    test('type=Both has-comment', () => {
-      line.type = GrDiffLineType.BOTH;
-      line.beforeNumber = 36;
-
-      const expectedStart = 6;
-      const expectedLength = line.text.length - expectedStart;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementStub.called);
-      const lastCall = annotateElementStub.lastCall;
-      assert.equal(lastCall.args[0], el);
-      assert.equal(lastCall.args[1], expectedStart);
-      assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(
-          lastCall.args[3],
-          'style-scope gr-diff range rangeHighlight generated_a'
-      );
-    });
-
-    test('type=Both has-comment off side', () => {
-      line.type = GrDiffLineType.BOTH;
-      line.beforeNumber = 36;
-      el.setAttribute('data-side', 'right');
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('type=Add has-comment', () => {
-      line.type = GrDiffLineType.ADD;
-      line.afterNumber = 12;
-      el.setAttribute('data-side', 'right');
-
-      const expectedStart = 0;
-      const expectedLength = 22;
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementStub.called);
-      const lastCall = annotateElementStub.lastCall;
-      assert.equal(lastCall.args[0], el);
-      assert.equal(lastCall.args[1], expectedStart);
-      assert.equal(lastCall.args[2], expectedLength);
-      assert.equal(
-          lastCall.args[3],
-          'style-scope gr-diff range rangeHighlight generated_b'
-      );
-    });
-
-    test('long range comment', () => {
-      line.type = GrDiffLineType.ADD;
-      line.afterNumber = 65;
-      el.setAttribute('data-side', 'right');
-
-      element.annotate(el, lineNumberEl, line);
-
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(
-          annotateElementStub.lastCall.args[3],
-          'style-scope gr-diff range generated_'
-      );
-    });
-  });
-
-  test('_handleCommentRangesChange overwrite', () => {
-    element.set('commentRanges', []);
-
-    assert.equal(Object.keys(element._rangesMap.left).length, 0);
-    assert.equal(Object.keys(element._rangesMap.right).length, 0);
-  });
-
-  test('_handleCommentRangesChange hovering', () => {
-    const notifyStub = sinon.stub();
-    element.addListener(notifyStub);
-    const updateRangesMapSpy = sinon.spy(element, '_updateRangesMap');
-
-    element.set(['commentRanges', 1, 'hovering'], true);
-
-    // notify will be skipped for hovering
-    assert.isFalse(notifyStub.called);
-
-    assert.isTrue(updateRangesMapSpy.called);
-  });
-
-  test('_handleCommentRangesChange splice out', () => {
-    const notifyStub = sinon.stub();
-    element.addListener(notifyStub);
-
-    element.splice('commentRanges', 1, 1);
-
-    assert.isTrue(notifyStub.called);
-    const lastCall = notifyStub.lastCall;
-    assert.equal(lastCall.args[0], 10);
-    assert.equal(lastCall.args[1], 12);
-    assert.equal(lastCall.args[2], 'right');
-  });
-
-  test('_handleCommentRangesChange splice in', () => {
-    const notifyStub = sinon.stub();
-    element.addListener(notifyStub);
-
-    element.splice('commentRanges', 1, 0, {
-      side: 'left',
-      range: {
-        end_character: 15,
-        end_line: 275,
-        start_character: 5,
-        start_line: 250,
-      },
-    });
-
-    assert.isTrue(notifyStub.called);
-    const lastCall = notifyStub.lastCall;
-    assert.equal(lastCall.args[0], 250);
-    assert.equal(lastCall.args[1], 275);
-    assert.equal(lastCall.args[2], 'left');
-  });
-
-  test('_handleCommentRangesChange mixed actions', () => {
-    const notifyStub = sinon.stub();
-    element.addListener(notifyStub);
-    const updateRangesMapSpy = sinon.spy(element, '_updateRangesMap');
-
-    element.set(['commentRanges', 1, 'hovering'], true);
-    assert.isTrue(updateRangesMapSpy.callCount === 1);
-    element.splice('commentRanges', 1, 1);
-    assert.isTrue(updateRangesMapSpy.callCount === 2);
-    element.splice('commentRanges', 1, 1);
-    assert.isTrue(updateRangesMapSpy.callCount === 3);
-    element.splice('commentRanges', 1, 0, {
-      side: 'left',
-      range: {
-        end_character: 15,
-        end_line: 275,
-        start_character: 5,
-        start_line: 250,
-      },
-    });
-    assert.isTrue(updateRangesMapSpy.callCount === 4);
-    element.set(['commentRanges', 2, 'hovering'], true);
-    assert.isTrue(updateRangesMapSpy.callCount === 5);
-  });
-
-  test('_computeCommentMap creates maps correctly', () => {
-    // There is only one ranged comment on the left, but it spans ll.36-39.
-    const leftKeys = [];
-    for (let i = 36; i <= 39; i++) { leftKeys.push('' + i); }
-    assert.deepEqual(Object.keys(element._rangesMap.left).sort(),
-        leftKeys.sort());
-
-    assert.equal(element._rangesMap.left[36].length, 1);
-    assert.equal(element._rangesMap.left[36][0].start, 6);
-    assert.equal(element._rangesMap.left[36][0].end, -1);
-
-    assert.equal(element._rangesMap.left[37].length, 1);
-    assert.equal(element._rangesMap.left[37][0].start, 0);
-    assert.equal(element._rangesMap.left[37][0].end, -1);
-
-    assert.equal(element._rangesMap.left[38].length, 1);
-    assert.equal(element._rangesMap.left[38][0].start, 0);
-    assert.equal(element._rangesMap.left[38][0].end, -1);
-
-    assert.equal(element._rangesMap.left[39].length, 1);
-    assert.equal(element._rangesMap.left[39][0].start, 0);
-    assert.equal(element._rangesMap.left[39][0].end, 9);
-
-    // The right has four ranged comments: 10-12, 55-55, 60-71, 100-100
-    const rightKeys = [];
-    for (let i = 10; i <= 12; i++) { rightKeys.push('' + i); }
-    for (let i = 60; i <= 71; i++) { rightKeys.push('' + i); }
-    rightKeys.push('55', '100');
-    assert.deepEqual(Object.keys(element._rangesMap.right).sort(),
-        rightKeys.sort());
-
-    assert.equal(element._rangesMap.right[10].length, 1);
-    assert.equal(element._rangesMap.right[10][0].start, 10);
-    assert.equal(element._rangesMap.right[10][0].end, -1);
-
-    assert.equal(element._rangesMap.right[11].length, 1);
-    assert.equal(element._rangesMap.right[11][0].start, 0);
-    assert.equal(element._rangesMap.right[11][0].end, -1);
-
-    assert.equal(element._rangesMap.right[12].length, 1);
-    assert.equal(element._rangesMap.right[12][0].start, 0);
-    assert.equal(element._rangesMap.right[12][0].end, 22);
-
-    assert.equal(element._rangesMap.right[100].length, 1);
-    assert.equal(element._rangesMap.right[100][0].start, 5);
-    assert.equal(element._rangesMap.right[100][0].end, 15);
-  });
-
-  test('_getRangesForLine normalizes invalid ranges', () => {
-    const line = {
-      afterNumber: 55,
-      text: '_getRangesForLine normalizes invalid ranges',
-    };
-    const ranges = element._getRangesForLine(line, 'right');
-    assert.equal(ranges.length, 1);
-    const range = ranges[0];
-    assert.isTrue(range.start < range.end, 'start and end are normalized');
-    assert.equal(range.end, line.text.length);
-  });
-});
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
new file mode 100644
index 0000000..4e35645
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
@@ -0,0 +1,301 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import '../gr-diff/gr-diff-line';
+import './gr-ranged-comment-layer';
+import {
+  CommentRangeLayer,
+  GrRangedCommentLayer,
+} from './gr-ranged-comment-layer';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {Side} from '../../../api/diff';
+import {SinonStub} from 'sinon';
+
+const rangeA: CommentRangeLayer = {
+  side: Side.LEFT,
+  range: {
+    end_character: 9,
+    end_line: 39,
+    start_character: 6,
+    start_line: 36,
+  },
+  rootId: 'a',
+  hovering: false,
+};
+
+const rangeB: CommentRangeLayer = {
+  side: Side.RIGHT,
+  range: {
+    end_character: 22,
+    end_line: 12,
+    start_character: 10,
+    start_line: 10,
+  },
+  rootId: 'b',
+  hovering: false,
+};
+
+const rangeC: CommentRangeLayer = {
+  side: Side.RIGHT,
+  range: {
+    end_character: 15,
+    end_line: 100,
+    start_character: 5,
+    start_line: 100,
+  },
+  hovering: false,
+};
+
+const rangeD: CommentRangeLayer = {
+  side: Side.RIGHT,
+  range: {
+    end_character: 2,
+    end_line: 55,
+    start_character: 32,
+    start_line: 55,
+  },
+  rootId: 'd',
+  hovering: false,
+};
+
+const rangeE: CommentRangeLayer = {
+  side: Side.RIGHT,
+  range: {
+    end_character: 1,
+    end_line: 71,
+    start_character: 1,
+    start_line: 60,
+  },
+  hovering: false,
+};
+
+suite('gr-ranged-comment-layer', () => {
+  let element: GrRangedCommentLayer;
+
+  setup(() => {
+    const initialCommentRanges: CommentRangeLayer[] = [
+      rangeA,
+      rangeB,
+      rangeC,
+      rangeD,
+      rangeE,
+    ];
+
+    element = new GrRangedCommentLayer();
+    element.updateRanges(initialCommentRanges);
+  });
+
+  suite('annotate', () => {
+    let el: HTMLDivElement;
+    let line: GrDiffLine;
+    let annotateElementStub: SinonStub;
+    const lineNumberEl = document.createElement('td');
+
+    function assertHasRange(
+      commentRange: CommentRangeLayer,
+      hasRange: boolean
+    ) {
+      assertHasRangeOn(
+        commentRange.side,
+        commentRange.range.start_line,
+        hasRange
+      );
+    }
+
+    function assertHasRangeOn(
+      side: Side,
+      lineNumber: number,
+      hasRange: boolean
+    ) {
+      line = new GrDiffLine(GrDiffLineType.BOTH);
+      if (side === Side.LEFT) line.beforeNumber = lineNumber;
+      if (side === Side.RIGHT) line.afterNumber = lineNumber;
+      el.setAttribute('data-side', side);
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.equal(annotateElementStub.called, hasRange);
+      annotateElementStub.reset();
+    }
+
+    setup(() => {
+      annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      el = document.createElement('div');
+      el.setAttribute('data-side', Side.LEFT);
+      line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,';
+    });
+
+    test('type=Remove no-comment', () => {
+      line = new GrDiffLine(GrDiffLineType.REMOVE);
+      line.beforeNumber = 40;
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('type=Remove has-comment', () => {
+      line = new GrDiffLine(GrDiffLineType.REMOVE);
+      line.beforeNumber = 36;
+      const expectedStart = 6;
+      const expectedLength = line.text.length - expectedStart;
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(
+        lastCall.args[3],
+        'style-scope gr-diff range rangeHighlight generated_a'
+      );
+    });
+
+    test('type=Both has-comment', () => {
+      line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = 36;
+
+      const expectedStart = 6;
+      const expectedLength = line.text.length - expectedStart;
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(
+        lastCall.args[3],
+        'style-scope gr-diff range rangeHighlight generated_a'
+      );
+    });
+
+    test('type=Both has-comment off side', () => {
+      line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = 36;
+      el.setAttribute('data-side', Side.RIGHT);
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('type=Add has-comment', () => {
+      line = new GrDiffLine(GrDiffLineType.ADD);
+      line.afterNumber = 12;
+      el.setAttribute('data-side', Side.RIGHT);
+
+      const expectedStart = 0;
+      const expectedLength = 22;
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      const lastCall = annotateElementStub.lastCall;
+      assert.equal(lastCall.args[0], el);
+      assert.equal(lastCall.args[1], expectedStart);
+      assert.equal(lastCall.args[2], expectedLength);
+      assert.equal(
+        lastCall.args[3],
+        'style-scope gr-diff range rangeHighlight generated_b'
+      );
+    });
+
+    test('long range comment', () => {
+      line = new GrDiffLine(GrDiffLineType.ADD);
+      line.afterNumber = 65;
+      el.setAttribute('data-side', Side.RIGHT);
+
+      element.annotate(el, lineNumberEl, line);
+
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(
+        annotateElementStub.lastCall.args[3],
+        'style-scope gr-diff range generated_right-60-1-71-1'
+      );
+    });
+
+    test('updateRanges remove all', () => {
+      assertHasRange(rangeA, true);
+      assertHasRange(rangeB, true);
+      assertHasRange(rangeC, true);
+      assertHasRange(rangeD, true);
+      assertHasRange(rangeE, true);
+
+      element.updateRanges([]);
+
+      assertHasRange(rangeA, false);
+      assertHasRange(rangeB, false);
+      assertHasRange(rangeC, false);
+      assertHasRange(rangeD, false);
+      assertHasRange(rangeE, false);
+    });
+
+    test('updateRanges remove A and C', () => {
+      assertHasRange(rangeA, true);
+      assertHasRange(rangeB, true);
+      assertHasRange(rangeC, true);
+      assertHasRange(rangeD, true);
+      assertHasRange(rangeE, true);
+
+      element.updateRanges([rangeB, rangeD, rangeE]);
+
+      assertHasRange(rangeA, false);
+      assertHasRange(rangeB, true);
+      assertHasRange(rangeC, false);
+      assertHasRange(rangeD, true);
+      assertHasRange(rangeE, true);
+    });
+
+    test('updateRanges add B and D', () => {
+      element.updateRanges([]);
+
+      assertHasRange(rangeA, false);
+      assertHasRange(rangeB, false);
+      assertHasRange(rangeC, false);
+      assertHasRange(rangeD, false);
+      assertHasRange(rangeE, false);
+
+      element.updateRanges([rangeB, rangeD]);
+
+      assertHasRange(rangeA, false);
+      assertHasRange(rangeB, true);
+      assertHasRange(rangeC, false);
+      assertHasRange(rangeD, true);
+      assertHasRange(rangeE, false);
+    });
+
+    test('updateRanges add A, remove B', () => {
+      element.updateRanges([rangeB, rangeC]);
+
+      assertHasRange(rangeA, false);
+      assertHasRange(rangeB, true);
+      assertHasRange(rangeC, true);
+
+      element.updateRanges([rangeA, rangeC]);
+
+      assertHasRange(rangeA, true);
+      assertHasRange(rangeB, false);
+      assertHasRange(rangeC, true);
+    });
+
+    test('_getRangesForLine normalizes invalid ranges', () => {
+      const line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.afterNumber = 55;
+      line.text = 'getRangesForLine normalizes invalid ranges';
+      const ranges = element.getRangesForLine(line, Side.RIGHT);
+      assert.equal(ranges.length, 1);
+      const range = ranges[0];
+      assert.isTrue(range.start < range.end, 'start and end are normalized');
+      assert.equal(range.end, line.text.length);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
index abbb0a3..bb6d1e9 100644
--- a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
+++ b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -1,26 +1,14 @@
 /**
  * @license
- * Copyright (C) 2016 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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-import '../../../styles/shared-styles';
 import '../../../elements/shared/gr-tooltip/gr-tooltip';
 import {GrTooltip} from '../../../elements/shared/gr-tooltip/gr-tooltip';
-import {customElement, property} from '@polymer/decorators';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-selection-action-box_html';
 import {fireEvent} from '../../../utils/event-util';
+import {css, html, LitElement} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -28,38 +16,64 @@
   }
 }
 
-export interface GrSelectionActionBox {
-  $: {
-    tooltip: GrTooltip;
-  };
-}
-
 @customElement('gr-selection-action-box')
-export class GrSelectionActionBox extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrSelectionActionBox extends LitElement {
   /**
    * Fired when the comment creation action was taken (click).
    *
    * @event create-comment-requested
    */
 
+  @query('#tooltip')
+  tooltip?: GrTooltip;
+
   @property({type: Boolean})
   positionBelow = false;
 
+  /**
+   * We need to absolutely position the element before we can show it. So
+   * initially the tooltip must be invisible.
+   */
+  @state() private invisible = true;
+
   constructor() {
     super();
     // See https://crbug.com/gerrit/4767
-    this.addEventListener('mousedown', e => this._handleMouseDown(e));
+    this.addEventListener('mousedown', e => this.handleMouseDown(e));
+  }
+
+  static override styles = [
+    sharedStyles,
+    css`
+      :host {
+        cursor: pointer;
+        font-family: var(--font-family);
+        position: absolute;
+        white-space: nowrap;
+      }
+      gr-tooltip[invisible] {
+        visibility: hidden;
+      }
+    `,
+  ];
+
+  override render() {
+    return html`
+      <gr-tooltip
+        id="tooltip"
+        ?invisible=${this.invisible}
+        text="Press c to comment"
+        ?position-below=${this.positionBelow}
+      ></gr-tooltip>
+    `;
   }
 
   async placeAbove(el: Text | Element | Range) {
-    await this.$.tooltip.updateComplete;
-    const rect = this._getTargetBoundingRect(el);
-    const boxRect = this.$.tooltip.getBoundingClientRect();
-    const parentRect = this._getParentBoundingClientRect();
+    if (!this.tooltip) return;
+    await this.tooltip.updateComplete;
+    const rect = this.getTargetBoundingRect(el);
+    const boxRect = this.tooltip.getBoundingClientRect();
+    const parentRect = this.getParentBoundingClientRect();
     if (parentRect === null) {
       return;
     }
@@ -67,13 +81,15 @@
     this.style.left = `${
       rect.left - parentRect.left + (rect.width - boxRect.width) / 2
     }px`;
+    this.invisible = false;
   }
 
   async placeBelow(el: Text | Element | Range) {
-    await this.$.tooltip.updateComplete;
-    const rect = this._getTargetBoundingRect(el);
-    const boxRect = this.$.tooltip.getBoundingClientRect();
-    const parentRect = this._getParentBoundingClientRect();
+    if (!this.tooltip) return;
+    await this.tooltip.updateComplete;
+    const rect = this.getTargetBoundingRect(el);
+    const boxRect = this.tooltip.getBoundingClientRect();
+    const parentRect = this.getParentBoundingClientRect();
     if (parentRect === null) {
       return;
     }
@@ -81,9 +97,10 @@
     this.style.left = `${
       rect.left - parentRect.left + (rect.width - boxRect.width) / 2
     }px`;
+    this.invisible = false;
   }
 
-  private _getParentBoundingClientRect() {
+  private getParentBoundingClientRect() {
     // With native shadow DOM, the parent is the shadow root, not the gr-diff
     // element
     if (this.parentElement) {
@@ -95,8 +112,8 @@
     return null;
   }
 
-  // private but used in test
-  _getTargetBoundingRect(el: Text | Element | Range) {
+  // visible for testing
+  getTargetBoundingRect(el: Text | Element | Range) {
     let rect;
     if (el instanceof Text) {
       const range = document.createRange();
@@ -109,8 +126,8 @@
     return rect;
   }
 
-  // private but used in test
-  _handleMouseDown(e: MouseEvent) {
+  // visible for testing
+  handleMouseDown(e: MouseEvent) {
     if (e.button !== 0) {
       return;
     } // 0 = main button
diff --git a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_html.ts b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_html.ts
deleted file mode 100644
index 24d63b3..0000000
--- a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_html.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="shared-styles">
-    :host {
-      cursor: pointer;
-      font-family: var(--font-family);
-      position: absolute;
-      white-space: nowrap;
-    }
-  </style>
-  <gr-tooltip
-    id="tooltip"
-    text="Press c to comment"
-    position-below="[[positionBelow]]"
-  ></gr-tooltip>
-`;
diff --git a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts
index 30b7ded..a92c967 100644
--- a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts
@@ -1,51 +1,44 @@
 /**
  * @license
- * Copyright (C) 2016 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.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
 import '../../../test/common-test-setup-karma';
 import './gr-selection-action-box';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {GrSelectionActionBox} from './gr-selection-action-box';
-import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {queryAndAssert} from '../../../test/test-utils';
-
-const basicFixture = fixtureFromTemplate(html`
-  <div>
-    <gr-selection-action-box></gr-selection-action-box>
-    <div class="target">some text</div>
-  </div>
-`);
+import {fixture, html} from '@open-wc/testing-helpers';
 
 suite('gr-selection-action-box', () => {
-  let container: GrSelectionActionBox;
+  let container: HTMLDivElement;
   let element: GrSelectionActionBox;
   let dispatchEventStub: sinon.SinonStub;
 
-  setup(() => {
-    container = basicFixture.instantiate() as GrSelectionActionBox;
+  setup(async () => {
+    container = await fixture<HTMLDivElement>(html`
+      <div>
+        <gr-selection-action-box></gr-selection-action-box>
+        <div class="target">some text</div>
+      </div>
+    `);
     element = queryAndAssert<GrSelectionActionBox>(
       container,
       'gr-selection-action-box'
     );
+    await element.updateComplete;
 
     dispatchEventStub = sinon.stub(element, 'dispatchEvent');
   });
 
+  test('renders', () => {
+    expect(element).shadowDom.to.equal(/* HTML */ `
+      <gr-tooltip invisible id="tooltip" text="Press c to comment"></gr-tooltip>
+    `);
+  });
+
   test('ignores regular keys', () => {
-    MockInteractions.pressAndReleaseKeyOn(document.body, 27, null, 'esc');
+    const event = new KeyboardEvent('keydown', {key: 'a'});
+    document.body.dispatchEvent(event);
     assert.isFalse(dispatchEventStub.called);
   });
 
@@ -61,7 +54,7 @@
     });
 
     test('event handled if main button', () => {
-      element._handleMouseDown(e);
+      element.handleMouseDown(e);
       assert.isTrue(e.preventDefault.called);
       assert.equal(
         dispatchEventStub.lastCall.args[0].type,
@@ -71,7 +64,7 @@
 
     test('event ignored if not main button', () => {
       e.button = 1;
-      element._handleMouseDown(e);
+      element.handleMouseDown(e);
       assert.isFalse(e.preventDefault.called);
       assert.isFalse(dispatchEventStub.called);
     });
@@ -92,7 +85,7 @@
         height: 6,
       } as DOMRect);
       getTargetBoundingRectStub = sinon
-        .stub(element, '_getTargetBoundingRect')
+        .stub(element, 'getTargetBoundingRect')
         .returns({
           top: 42,
           bottom: 20,
@@ -101,11 +94,20 @@
           width: 100,
           height: 60,
         } as DOMRect);
+      assert.isOk(element.tooltip);
       sinon
-        .stub(element.$.tooltip, 'getBoundingClientRect')
+        .stub(element.tooltip!, 'getBoundingClientRect')
         .returns({width: 10, height: 10} as DOMRect);
     });
 
+    test('renders visible', async () => {
+      await element.placeAbove(target);
+      await element.updateComplete;
+      expect(element).shadowDom.to.equal(/* HTML */ `
+        <gr-tooltip id="tooltip" text="Press c to comment"></gr-tooltip>
+      `);
+    });
+
     test('placeAbove for Element argument', async () => {
       await element.placeAbove(target);
       assert.equal(element.style.top, '25px');
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts b/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts
index a41f359..363de13 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts
@@ -109,8 +109,6 @@
   }
   .gr-syntax-strong {
     color: var(--syntax-strong-color);
-    font-style: var(--syntax-strong-style);
-    font-weight: var(--syntax-strong-weight);
   }
   .gr-syntax-tag {
     color: var(--syntax-tag-color);
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
index ad89054..b0984c1 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -129,6 +129,22 @@
     });
   }
 
+  voteChanges(reviewInput: ReviewInput) {
+    const current = this.subject$.getValue();
+    return current.selectedChangeNums.map(changeNum => {
+      const change = current.allChanges.get(changeNum)!;
+      if (!change) throw new Error('invalid change id');
+      return this.restApiService.saveChangeReview(
+        change._number,
+        'current',
+        reviewInput,
+        () => {
+          throw new Error();
+        }
+      );
+    });
+  }
+
   addReviewers(
     changedReviewers: Map<ReviewerState, AccountInfo[]>
   ): Promise<Response>[] {
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
index d4c4a6e..b08455c 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
@@ -7,6 +7,7 @@
 import {
   createAccountWithIdNameAndEmail,
   createChange,
+  createRevisions,
 } from '../../test/test-data-generators';
 import {
   ChangeInfo,
@@ -25,6 +26,7 @@
 import {mockPromise} from '../../test/test-utils';
 import {SinonStubbedMember} from 'sinon';
 import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {ReviewInput} from '../../types/common';
 
 suite('bulk actions model test', () => {
   let bulkActionsModel: BulkActionsModel;
@@ -221,6 +223,61 @@
     });
   });
 
+  suite('voteChanges', () => {
+    let detailedActionsStub: SinonStubbedMember<
+      RestApiService['getDetailedChangesWithActions']
+    >;
+    setup(async () => {
+      const c1 = {...createChange(), revisions: createRevisions(10)};
+      c1._number = 1 as NumericChangeId;
+      const c2 = {...createChange(), revisions: createRevisions(4)};
+      c2._number = 2 as NumericChangeId;
+
+      detailedActionsStub = stubRestApi('getDetailedChangesWithActions');
+      detailedActionsStub.returns(
+        Promise.resolve([
+          {...c1, actions: {abandon: {method: HttpMethod.POST}}},
+          {...c2, status: ChangeStatus.ABANDONED},
+        ])
+      );
+
+      await bulkActionsModel.sync([c1, c2]);
+
+      bulkActionsModel.addSelectedChangeNum(c1._number);
+      bulkActionsModel.addSelectedChangeNum(c2._number);
+    });
+
+    test('vote changes', () => {
+      const reviewStub = stubRestApi('saveChangeReview');
+      const reviewInput: ReviewInput = {
+        labels: {
+          a: 1,
+        },
+      };
+      bulkActionsModel.voteChanges(reviewInput);
+      assert.equal(reviewStub.callCount, 2);
+      assert.deepEqual(reviewStub.firstCall.args.slice(0, 3), [
+        1 as NumericChangeId,
+        'current',
+        {
+          labels: {
+            a: 1,
+          },
+        },
+      ]);
+
+      assert.deepEqual(reviewStub.secondCall.args.slice(0, 3), [
+        2 as NumericChangeId,
+        'current',
+        {
+          labels: {
+            a: 1,
+          },
+        },
+      ]);
+    });
+  });
+
   test('stale changes are removed from the model', async () => {
     const c1 = createChange();
     c1._number = 1 as NumericChangeId;
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index 054021b..8e6a147 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -16,7 +16,6 @@
  */
 /* NB: Order is important, because of namespaced classes. */
 
-import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {GrEtagDecorator} from '../../elements/shared/gr-rest-api-interface/gr-etag-decorator';
 import {
   FetchJSONRequest,
@@ -40,7 +39,6 @@
   listChangesOptionsToHex,
 } from '../../utils/change-util';
 import {assertNever, hasOwnProperty} from '../../utils/common-util';
-import {customElement} from '@polymer/decorators';
 import {AuthRequestInit, AuthService} from '../gr-auth/gr-auth';
 import {
   AccountCapabilityInfo,
@@ -275,12 +273,6 @@
   getAppContext().authService.clearCache();
 }
 
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-rest-api-service-impl': GrRestApiServiceImpl;
-  }
-}
-
 function createReadScheduler() {
   return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 10);
 }
@@ -288,12 +280,7 @@
 function createWriteScheduler() {
   return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 5);
 }
-
-@customElement('gr-rest-api-service-impl')
-export class GrRestApiServiceImpl
-  extends PolymerElement
-  implements RestApiService, Finalizable
-{
+export class GrRestApiServiceImpl implements RestApiService, Finalizable {
   readonly _cache = siteBasedCache; // Shared across instances.
 
   readonly _sharedFetchPromises = fetchPromisesCache; // Shared across instances.
@@ -311,7 +298,6 @@
   private readonly _restApiHelper: GrRestApiHelper;
 
   constructor(authService?: AuthService) {
-    super();
     // TODO: Make the authService constructor parameter required when we have
     // changed all usages of this class to not instantiate via createElement().
     this.authService = authService ?? getAppContext().authService;
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index ebbdc9c..d91b438 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -69,6 +69,7 @@
   GroupName,
   UrlEncodedRepoName,
   NumericChangeId,
+  PreferencesInput,
 } from '../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../types/diff';
 import {readResponsePayload} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
@@ -84,7 +85,6 @@
 import {
   createDefaultDiffPrefs,
   createDefaultEditPrefs,
-  createDefaultPreferences,
 } from '../../constants/constants';
 import {ParsedChangeInfo} from '../../types/types';
 
@@ -502,8 +502,9 @@
   saveIncludedGroup(): Promise<GroupInfo | undefined> {
     throw new Error('saveIncludedGroup() not implemented by RestApiMock.');
   },
-  savePreferences(): Promise<PreferencesInfo> {
-    return Promise.resolve(createDefaultPreferences());
+  savePreferences(input: PreferencesInput): Promise<PreferencesInfo> {
+    const info = input as PreferencesInfo;
+    return Promise.resolve({...info});
   },
   saveRepoConfig(): Promise<Response> {
     return Promise.resolve(new Response());
diff --git a/polygerrit-ui/app/utils/bulk-flow-util.ts b/polygerrit-ui/app/utils/bulk-flow-util.ts
new file mode 100644
index 0000000..9a6179a
--- /dev/null
+++ b/polygerrit-ui/app/utils/bulk-flow-util.ts
@@ -0,0 +1,24 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {ProgressStatus} from '../constants/constants';
+import {NumericChangeId} from '../api/rest-api';
+
+export function getOverallStatus(
+  progressByChangeNum: Map<NumericChangeId, ProgressStatus>
+) {
+  const statuses = Array.from(progressByChangeNum.values());
+  if (statuses.every(s => s === ProgressStatus.NOT_STARTED)) {
+    return ProgressStatus.NOT_STARTED;
+  }
+  if (statuses.some(s => s === ProgressStatus.RUNNING)) {
+    return ProgressStatus.RUNNING;
+  }
+  if (statuses.some(s => s === ProgressStatus.FAILED)) {
+    return ProgressStatus.FAILED;
+  }
+  return ProgressStatus.SUCCESSFUL;
+}
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index da57e4b..f5703f4 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -320,6 +320,15 @@
   return `${numberValue}`;
 }
 
+export function getDefaultValue(
+  labels?: LabelNameToInfoMap,
+  labelName?: string
+) {
+  if (!labelName || !labels?.[labelName]) return undefined;
+  const labelInfo = labels[labelName] as DetailedLabelInfo;
+  return labelInfo.default_value;
+}
+
 export function getVoteForAccount(
   labelName: string,
   account?: AccountInfo,
diff --git a/polygerrit-ui/app/utils/syntax-util.ts b/polygerrit-ui/app/utils/syntax-util.ts
index b9d0597..03774bd 100644
--- a/polygerrit-ui/app/utils/syntax-util.ts
+++ b/polygerrit-ui/app/utils/syntax-util.ts
@@ -38,6 +38,15 @@
     .replace(/&amp;/g, '&');
 }
 
+function equal(r: SyntaxLayerRange) {
+  return (s: SyntaxLayerRange) =>
+    r.start === s.start && r.length === s.length && r.className === s.className;
+}
+
+function unique(r: SyntaxLayerRange, index: number, array: SyntaxLayerRange[]) {
+  return index === array.findIndex(equal(r));
+}
+
 /**
  * HighlightJS produces one long HTML string with HTML elements spanning
  * multiple lines. <gr-diff> is line based, needs all elements closed at the end
@@ -87,7 +96,9 @@
         range.length = lineLength - range.start;
       }
     }
-    rangesPerLine.push({ranges: ranges.filter(r => r.length > 0)});
+    rangesPerLine.push({
+      ranges: ranges.filter(r => r.length > 0).filter(unique),
+    });
   }
   if (carryOverRanges.length > 0) {
     throw new Error('unclosed <span>s in highlighted code');
diff --git a/polygerrit-ui/app/utils/syntax-util_test.ts b/polygerrit-ui/app/utils/syntax-util_test.ts
index 81cdf57..4d381fb 100644
--- a/polygerrit-ui/app/utils/syntax-util_test.ts
+++ b/polygerrit-ui/app/utils/syntax-util_test.ts
@@ -80,6 +80,15 @@
       );
     });
 
+    test('removal of duplicate spans', async () => {
+      assert.deepEqual(
+        highlightedStringToRanges(
+          '<span class="d"><span class="d">asdfqwer</span></span>'
+        ),
+        [{ranges: [{start: 0, length: 8, className: 'd'}]}]
+      );
+    });
+
     test('one line, two spans one after another', async () => {
       assert.deepEqual(
         highlightedStringToRanges(