Merge changes If7bebc41,I5d826655

* changes:
  Add ability to mock/fake time intervals in tests
  Reduce timeout granularity in MultiProgressMonitor
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index 3977278..cc8d813 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -136,6 +136,19 @@
 +
 Changes originally submitted by a user in 'GROUP'.
 
+[[uploader]]
+uploader:'USER'::
++
+Changes where the latest patch set was uploaded by 'USER'.
+The special case of `uploader:self` will find changes uploaded
+by the caller.
+
+[[uploaderin]]
+uploaderin:'GROUP'::
++
+Changes where the latest patch set was uploaded by a user in
+'GROUP'.
+
 [[query]]
 query:'[name=]NAME[,user=USER]'::
 +
@@ -466,6 +479,12 @@
 True on any change where the current user is the change owner.
 Same as `owner:self`.
 
+is:uploader::
++
+True on any change where the current user is the uploader of
+the latest patch set.
+Same as `uploader:self`.
+
 is:reviewer::
 +
 True on any change where the current user is a reviewer.
diff --git a/java/com/google/gerrit/entities/RefNames.java b/java/com/google/gerrit/entities/RefNames.java
index 2263aba..349b67e 100644
--- a/java/com/google/gerrit/entities/RefNames.java
+++ b/java/com/google/gerrit/entities/RefNames.java
@@ -489,7 +489,7 @@
     return Integer.parseInt(rest.substring(0, ie));
   }
 
-  static Integer parseRefSuffix(String name) {
+  public static Integer parseRefSuffix(String name) {
     if (name == null) {
       return null;
     }
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
index 900b2e2..9e4416b 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -39,17 +39,17 @@
   /**
    * List leaf predicates that are fulfilled, for example the expression
    *
-   * <p><i>label:code-review=+2 and branch:refs/heads/master</i>
+   * <p><i>label:Code-Review=+2 and branch:refs/heads/master</i>
    *
    * <p>has two leaf predicates:
    *
    * <ul>
-   *   <li>label:code-review=+2
+   *   <li>label:Code-Review=+2
    *   <li>branch:refs/heads/master
    * </ul>
    *
    * This method will return the leaf predicates that were fulfilled, for example if only the first
-   * predicate was fulfilled, the returned list will be equal to ["label:code-review=+2"].
+   * predicate was fulfilled, the returned list will be equal to ["label:Code-Review=+2"].
    */
   public abstract ImmutableList<String> passingAtoms();
 
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index ba9f6d6..1d38877 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -52,9 +52,11 @@
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -465,10 +467,35 @@
     }
   }
 
+  /** returns all changes that contain draft comments of {@code accountId}. */
+  public Collection<Change.Id> getChangesWithDrafts(Account.Id accountId) {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return getChangesWithDrafts(repo, accountId);
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+  }
+
   private Collection<Ref> getDraftRefs(Repository repo, Change.Id changeId) throws IOException {
     return repo.getRefDatabase().getRefsByPrefix(RefNames.refsDraftCommentsPrefix(changeId));
   }
 
+  private Collection<Change.Id> getChangesWithDrafts(Repository repo, Account.Id accountId)
+      throws IOException {
+    Set<Change.Id> changes = new HashSet<>();
+    for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_DRAFT_COMMENTS)) {
+      Integer accountIdFromRef = RefNames.parseRefSuffix(ref.getName());
+      if (accountIdFromRef != null && accountIdFromRef == accountId.get()) {
+        Change.Id changeId = Change.Id.fromAllUsersRef(ref.getName());
+        if (changeId == null) {
+          continue;
+        }
+        changes.add(changeId);
+      }
+    }
+    return changes;
+  }
+
   private static <T extends Comment> List<T> sort(List<T> comments) {
     comments.sort(COMMENT_ORDER);
     return comments;
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index 5dcbd01..7c61c92 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -289,6 +289,35 @@
     }
   }
 
+  public ImmutableSet<Change.Id> byAccountId(Account.Id accountId, String label) {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      ImmutableSet.Builder<Change.Id> builder = ImmutableSet.builder();
+      for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_STARRED_CHANGES)) {
+        Account.Id currentAccountId = Account.Id.fromRef(ref.getName());
+        // Skip all refs that don't correspond with accountId.
+        if (currentAccountId == null || !currentAccountId.equals(accountId)) {
+          continue;
+        }
+        // Skip all refs that don't contain the required label.
+        StarRef starRef = readLabels(repo, ref.getName());
+        if (!starRef.labels().contains(label)) {
+          continue;
+        }
+
+        // Skip invalid change ids.
+        Change.Id changeId = Change.Id.fromAllUsersRef(ref.getName());
+        if (changeId == null) {
+          continue;
+        }
+        builder.add(changeId);
+      }
+      return builder.build();
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format("Get starred changes for account %d failed", accountId.get()), e);
+    }
+  }
+
   public ImmutableListMultimap<Account.Id, String> byChangeFromIndex(Change.Id changeId) {
     List<ChangeData> changeData =
         queryProvider
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index 85482e4..c8001bb 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -364,7 +364,7 @@
    * <p>Should not be used in new code, as it doesn't result in a single atomic batch ref update for
    * code and NoteDb meta refs.
    *
-   * @param updateRef whether to update the ref during {@code updateRepo}.
+   * @param updateRef whether to update the ref during {@link #updateRepo(RepoContext)}.
    */
   @Deprecated
   public ChangeInserter setUpdateRef(boolean updateRef) {
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 9a94d93..328c5de 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -99,6 +99,8 @@
 import com.google.gerrit.server.cancellation.RequestCancelledException;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.TrackingFooters;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -253,6 +255,7 @@
       TrackingFooters trackingFooters,
       Metrics metrics,
       RevisionJson.Factory revisionJsonFactory,
+      ExperimentFeatures experimentFeatures,
       @GerritServerConfig Config cfg,
       @Assisted Iterable<ListChangesOption> options,
       @Assisted Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory) {
@@ -271,7 +274,9 @@
     this.revisionJson = revisionJsonFactory.create(options);
     this.options = Sets.immutableEnumSet(options);
     this.includeMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
-    this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
+    this.lazyLoad =
+        containsAnyOf(this.options, REQUIRE_LAZY_LOAD)
+            || lazyloadSubmitRequirements(this.options, experimentFeatures);
     this.pluginDefinedInfosFactory = pluginDefinedInfosFactory;
 
     logger.atFine().log("options = %s", options);
@@ -938,4 +943,20 @@
     }
     return ImmutableListMultimap.of();
   }
+
+  private static boolean lazyloadSubmitRequirements(
+      Set<ListChangesOption> changeOptions, ExperimentFeatures experimentFeatures) {
+    // TODO(ghareeb,hiesel): Remove this method.
+    // We are testing the new submit requirements with users in lieu of upgrading the change index
+    // to a version that supports the new requirements.
+    // Upgrading now, before the feature is finalized would be counter productive, because the index
+    // format might change while we iterate over the feature.
+    // Allowing changes to lazyload parameters will slow down dashboards for users who have this
+    // feature enabled, but will backfill submit requirements that weren't loaded from the index by
+    // simply computing them.
+    return changeOptions.contains(SUBMIT_REQUIREMENTS)
+        && experimentFeatures.isFeatureEnabled(
+            ExperimentFeaturesConstants
+                .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD);
+  }
 }
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index b309dee..6c25bae 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -188,6 +188,7 @@
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.project.ProjectNameLockManager;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.approval.ApprovalModule;
@@ -393,6 +394,8 @@
     DynamicSet.bind(binder(), EventListener.class).to(EventsMetrics.class);
     DynamicSet.setOf(binder(), UserScopedEventListener.class);
     DynamicSet.setOf(binder(), CommitValidationListener.class);
+    DynamicSet.bind(binder(), CommitValidationListener.class)
+        .to(SubmitRequirementExpressionsValidator.class);
     DynamicSet.setOf(binder(), CommentValidator.class);
     DynamicSet.setOf(binder(), ChangeMessageModifier.class);
     DynamicSet.setOf(binder(), RefOperationValidationListener.class);
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index b060d3e..65f8f2d 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -25,13 +25,32 @@
   public static String GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG =
       "GerritBackendRequestFeature__remove_revision_etag";
 
+  /** Enable storing submit requirements in NoteDb when the change is merged. */
+  public static final String GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE =
+      "GerritBackendRequestFeature__store_submit_requirements_on_merge";
+
   /**
    * Allow legacy {@link com.google.gerrit.entities.SubmitRecord}s to be converted and returned as
    * submit requirements by the {@link
    * com.google.gerrit.server.project.SubmitRequirementsEvaluator}.
    */
-  public static final String GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_LEGACY_SUBMIT_REQUIREMENTS =
-      "GerritBackendRequestFeature__enable_legacy_submit_requirements";
+  public static final String GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS =
+      "GerritBackendRequestFeature__enable_submit_requirements";
+
+  /**
+   * Allow SubmitRequirements to be computed freshly on dashboards irrespective of the value we
+   * retrieved from the change index.
+   */
+  public static final String
+      GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD =
+          "GerritBackendRequestFeature__enable_submit_requirements_backfilling_on_dashboard";
+
+  /**
+   * When set, we compute information from All-Users repository if able, instead of computing it
+   * from the change index.
+   */
+  public static final String GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY =
+      "GerritBackendRequestFeature__compute_from_all_users_repository";
 
   /** Features, enabled by default in the current release. */
   public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES =
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index bfe1ee1..b9569e4 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -294,6 +294,10 @@
   public static final FieldDef<ChangeData, Integer> OWNER =
       integer(ChangeQueryBuilder.FIELD_OWNER).build(changeGetter(c -> c.getOwner().get()));
 
+  /** Uploader of the latest patch set. */
+  public static final FieldDef<ChangeData, Integer> UPLOADER =
+      integer(ChangeQueryBuilder.FIELD_UPLOADER).build(cd -> cd.currentPatchSet().uploader().get());
+
   /** References the source change number that this change was cherry-picked from. */
   public static final FieldDef<ChangeData, Integer> CHERRY_PICK_OF_CHANGE =
       integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_CHANGE)
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 30ab6e6a..9339d62 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -178,9 +178,14 @@
       new Schema.Builder<ChangeData>().add(V68).add(ChangeField.CHERRY_PICK).build();
 
   /** Added new field {@link ChangeField#ATTENTION_SET_USERS_COUNT}. */
+  @Deprecated
   static final Schema<ChangeData> V70 =
       new Schema.Builder<ChangeData>().add(V69).add(ChangeField.ATTENTION_SET_USERS_COUNT).build();
 
+  /** Added new field {@link ChangeField#UPLOADER}. */
+  static final Schema<ChangeData> V71 =
+      new Schema.Builder<ChangeData>().add(V70).add(ChangeField.UPLOADER).build();
+
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
    */
diff --git a/java/com/google/gerrit/server/notedb/CommitRewriter.java b/java/com/google/gerrit/server/notedb/CommitRewriter.java
index eabee65..338b984 100644
--- a/java/com/google/gerrit/server/notedb/CommitRewriter.java
+++ b/java/com/google/gerrit/server/notedb/CommitRewriter.java
@@ -53,6 +53,7 @@
 import com.google.inject.Singleton;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.io.Serializable;
 import java.nio.charset.Charset;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -108,7 +109,7 @@
 @Singleton
 public class CommitRewriter {
   /** Options to run {@link #backfillProject}. */
-  public static class RunOptions {
+  public static class RunOptions implements Serializable {
     /** Whether to rewrite the commit history or only find refs that need to be fixed. */
     public boolean dryRun = true;
     /**
diff --git a/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
index 1a7d5af..d128633 100644
--- a/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
+++ b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.notedb;
 
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -24,6 +26,7 @@
 public class StoreSubmitRequirementsOp implements BatchUpdateOp {
   private final ChangeData.Factory changeDataFactory;
   private final SubmitRequirementsEvaluator evaluator;
+  private final boolean storeRequirementsInNoteDb;
 
   public interface Factory {
     StoreSubmitRequirementsOp create();
@@ -31,13 +34,23 @@
 
   @Inject
   public StoreSubmitRequirementsOp(
-      ChangeData.Factory changeDataFactory, SubmitRequirementsEvaluator evaluator) {
+      ChangeData.Factory changeDataFactory,
+      ExperimentFeatures experimentFeatures,
+      SubmitRequirementsEvaluator evaluator) {
     this.changeDataFactory = changeDataFactory;
     this.evaluator = evaluator;
+    this.storeRequirementsInNoteDb =
+        experimentFeatures.isFeatureEnabled(
+            ExperimentFeaturesConstants
+                .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE);
   }
 
   @Override
   public boolean updateChange(ChangeContext ctx) throws Exception {
+    if (!storeRequirementsInNoteDb) {
+      // Temporarily stop storing submit requirements in NoteDb when the change is merged.
+      return false;
+    }
     // Create ChangeData using the project/change IDs instead of ctx.getChange(). We do that because
     // for changes requiring a rebase before submission (e.g. if submit type = RebaseAlways), the
     // RebaseOp inserts a new patchset that is visible here (via Change#getCurrentPatchset). If we
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 513aeed..11ffcad 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -28,6 +28,7 @@
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
@@ -125,12 +126,18 @@
   public static final String KEY_BRANCH = "branch";
 
   public static final String SUBMIT_REQUIREMENT = "submit-requirement";
-  public static final String KEY_SR_NAME = "name";
   public static final String KEY_SR_DESCRIPTION = "description";
   public static final String KEY_SR_APPLICABILITY_EXPRESSION = "applicableIf";
   public static final String KEY_SR_SUBMITTABILITY_EXPRESSION = "submittableIf";
   public static final String KEY_SR_OVERRIDE_EXPRESSION = "overrideIf";
   public static final String KEY_SR_OVERRIDE_IN_CHILD_PROJECTS = "canOverrideInChildProjects";
+  public static final ImmutableSet<String> SR_KEYS =
+      ImmutableSet.of(
+          KEY_SR_DESCRIPTION,
+          KEY_SR_APPLICABILITY_EXPRESSION,
+          KEY_SR_SUBMITTABILITY_EXPRESSION,
+          KEY_SR_OVERRIDE_EXPRESSION,
+          KEY_SR_OVERRIDE_IN_CHILD_PROJECTS);
 
   public static final String KEY_MATCH = "match";
   private static final String KEY_HTML = "html";
@@ -643,7 +650,7 @@
     if (rc.getStringList(ACCESS, null, KEY_INHERIT_FROM).length > 1) {
       // The config must not contain more than one parent to inherit from
       // as there is no guarantee which of the parents would be used then.
-      error(ValidationError.create(PROJECT_CONFIG, "Cannot inherit from multiple projects"));
+      error("Cannot inherit from multiple projects");
     }
     p.setParent(rc.getString(ACCESS, null, KEY_INHERIT_FROM));
 
@@ -696,10 +703,8 @@
       String lower = name.toLowerCase();
       if (lowerNames.containsKey(lower)) {
         error(
-            ValidationError.create(
-                PROJECT_CONFIG,
-                String.format(
-                    "Extension Panels \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower))));
+            String.format(
+                "Extension Panels \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower)));
       }
       lowerNames.put(lower, name);
       extensionPanelSections.put(
@@ -725,26 +730,14 @@
         ca.setAutoVerify(null);
       } else if (rules.size() > 1) {
         error(
-            ValidationError.create(
-                PROJECT_CONFIG,
-                "Invalid rule in "
-                    + CONTRIBUTOR_AGREEMENT
-                    + "."
-                    + name
-                    + "."
-                    + KEY_AUTO_VERIFY
-                    + ": at most one group may be set"));
+            String.format(
+                "Invalid rule in %s.%s.%s: at most one group may be set",
+                CONTRIBUTOR_AGREEMENT, name, KEY_AUTO_VERIFY));
       } else if (rules.get(0).getAction() != Action.ALLOW) {
         error(
-            ValidationError.create(
-                PROJECT_CONFIG,
-                "Invalid rule in "
-                    + CONTRIBUTOR_AGREEMENT
-                    + "."
-                    + name
-                    + "."
-                    + KEY_AUTO_VERIFY
-                    + ": the group must be allowed"));
+            String.format(
+                "Invalid rule in %s.%s.%s: the group must be allowed",
+                CONTRIBUTOR_AGREEMENT, name, KEY_AUTO_VERIFY));
       } else {
         ca.setAutoVerify(rules.get(0).getGroup());
       }
@@ -792,21 +785,16 @@
           if (ref.getUUID() != null) {
             n.addGroup(ref);
           } else {
-            error(
-                ValidationError.create(
-                    PROJECT_CONFIG,
-                    "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
+            error(String.format("group \"%s\" not in %s", ref.getName(), GroupList.FILE_NAME));
           }
         } else if (dst.startsWith("user ")) {
-          error(ValidationError.create(PROJECT_CONFIG, dst + " not supported"));
+          error(String.format("%s not supported", dst));
         } else {
           try {
             n.addAddress(Address.parse(dst));
           } catch (IllegalArgumentException err) {
             error(
-                ValidationError.create(
-                    PROJECT_CONFIG,
-                    "notify section \"" + sectionName + "\" has invalid email \"" + dst + "\""));
+                String.format("notify section \"%s\" has invalid email \"%s\"", sectionName, dst));
           }
         }
       }
@@ -867,7 +855,7 @@
     try {
       RefPattern.validateRegExp(refPattern);
     } catch (InvalidNameException e) {
-      error(ValidationError.create(PROJECT_CONFIG, "Invalid ref name: " + e.getMessage()));
+      error(String.format("Invalid ref name: %s", e.getMessage()));
       return false;
     }
     return true;
@@ -895,9 +883,7 @@
         // to fail fast if any of the patterns are invalid.
         patterns.add(Pattern.compile(patternString).pattern());
       } catch (PatternSyntaxException e) {
-        error(
-            ValidationError.create(
-                PROJECT_CONFIG, "Invalid regular expression: " + e.getMessage()));
+        error(String.format("Invalid regular expression: %s", e.getMessage()));
         continue;
       }
     }
@@ -924,15 +910,11 @@
         rule = PermissionRule.fromString(ruleString, useRange);
       } catch (IllegalArgumentException notRule) {
         error(
-            ValidationError.create(
-                PROJECT_CONFIG,
-                "Invalid rule in "
-                    + section
-                    + (subsection != null ? "." + subsection : "")
-                    + "."
-                    + varName
-                    + ": "
-                    + notRule.getMessage()));
+            String.format(
+                "Invalid rule in %s.%s: %s",
+                section + (subsection != null ? "." + subsection : ""),
+                varName,
+                notRule.getMessage()));
         continue;
       }
 
@@ -943,9 +925,7 @@
         // all rules in the same file share the same GroupReference.
         //
         ref = groupList.resolve(rule.getGroup());
-        error(
-            ValidationError.create(
-                PROJECT_CONFIG, "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
+        error(String.format("group \"%s\" not in %s", ref.getName(), GroupList.FILE_NAME));
       }
 
       perm.add(rule.toBuilder().setGroup(ref));
@@ -964,17 +944,16 @@
   }
 
   private void loadSubmitRequirementSections(Config rc) {
+    checkForUnsupportedSubmitRequirementParams(rc);
+
     Map<String, String> lowerNames = new HashMap<>();
     submitRequirementSections = new LinkedHashMap<>();
     for (String name : rc.getSubsections(SUBMIT_REQUIREMENT)) {
       String lower = name.toLowerCase();
       if (lowerNames.containsKey(lower)) {
         error(
-            ValidationError.create(
-                PROJECT_CONFIG,
-                String.format(
-                    "Submit requirement \"%s\" conflicts with \"%s\". Skipping the former.",
-                    name, lowerNames.get(lower))));
+            String.format(
+                "Submit requirement '%s' conflicts with '%s'.", name, lowerNames.get(lower)));
         continue;
       }
       lowerNames.put(lower, name);
@@ -982,22 +961,34 @@
       String appExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_APPLICABILITY_EXPRESSION);
       String blockExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_SUBMITTABILITY_EXPRESSION);
       String overrideExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_EXPRESSION);
-      boolean canInherit =
-          rc.getBoolean(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS, false);
-
-      if (blockExpr == null) {
+      boolean canInherit;
+      try {
+        canInherit =
+            rc.getBoolean(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS, false);
+      } catch (IllegalArgumentException e) {
+        String canInheritValue =
+            rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS);
         error(
-            ValidationError.create(
-                PROJECT_CONFIG,
-                (String.format(
-                    "Submit requirement \"%s\" does not define a submittability expression."
-                        + " Skipping this requirement.",
-                    name))));
+            String.format(
+                "Invalid value %s.%s.%s for submit requirement '%s': %s",
+                SUBMIT_REQUIREMENT,
+                name,
+                KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+                name,
+                canInheritValue));
         continue;
       }
 
-      // TODO(SR): add expressions validation. Expressions are stored as strings so we need to
-      // validate their syntax.
+      if (blockExpr == null) {
+        error(
+            String.format(
+                "Setting a submittability expression for submit requirement '%s' is required:"
+                    + " Missing %s.%s.%s",
+                name, SUBMIT_REQUIREMENT, name, KEY_SR_SUBMITTABILITY_EXPRESSION));
+        continue;
+      }
+
+      // The expressions are validated in SubmitRequirementExpressionsValidator.
 
       SubmitRequirement submitRequirement =
           SubmitRequirement.builder()
@@ -1013,16 +1004,50 @@
     }
   }
 
+  /**
+   * Report unsupported submit requirement parameters as errors.
+   *
+   * <p>Unsupported are submit requirements parameters that
+   *
+   * <ul>
+   *   <li>are directly set in the {@code submit-requirement} section (as submit requirements are
+   *       solely defined in subsections)
+   *   <li>are unknown (maybe they were accidentally misspelled?)
+   * </ul>
+   */
+  private void checkForUnsupportedSubmitRequirementParams(Config rc) {
+    Set<String> directSubmitRequirementParams = rc.getNames(SUBMIT_REQUIREMENT);
+    if (!directSubmitRequirementParams.isEmpty()) {
+      error(
+          String.format(
+              "Submit requirements must be defined in %s.<name> subsections."
+                  + " Setting parameters directly in the %s section is not allowed: %s",
+              SUBMIT_REQUIREMENT,
+              SUBMIT_REQUIREMENT,
+              directSubmitRequirementParams.stream().sorted().collect(toImmutableList())));
+    }
+
+    for (String subsection : rc.getSubsections(SUBMIT_REQUIREMENT)) {
+      ImmutableList<String> unknownSubmitRequirementParams =
+          rc.getNames(SUBMIT_REQUIREMENT, subsection).stream()
+              .filter(p -> !SR_KEYS.contains(p))
+              .collect(toImmutableList());
+      if (!unknownSubmitRequirementParams.isEmpty()) {
+        error(
+            String.format(
+                "Unsupported parameters for submit requirement '%s': %s",
+                subsection, unknownSubmitRequirementParams));
+      }
+    }
+  }
+
   private void loadLabelSections(Config rc) {
     Map<String, String> lowerNames = Maps.newHashMapWithExpectedSize(2);
     labelSections = new LinkedHashMap<>();
     for (String name : rc.getSubsections(LABEL)) {
       String lower = name.toLowerCase();
       if (lowerNames.containsKey(lower)) {
-        error(
-            ValidationError.create(
-                PROJECT_CONFIG,
-                String.format("Label \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower))));
+        error(String.format("Label \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower)));
       }
       lowerNames.put(lower, name);
 
@@ -1034,18 +1059,13 @@
           if (allValues.add(labelValue.getValue())) {
             values.add(labelValue);
           } else {
-            error(
-                ValidationError.create(
-                    PROJECT_CONFIG,
-                    String.format("Duplicate %s \"%s\" for label \"%s\"", KEY_VALUE, value, name)));
+            error(String.format("Duplicate %s \"%s\" for label \"%s\"", KEY_VALUE, value, name));
           }
         } catch (IllegalArgumentException notValue) {
           error(
-              ValidationError.create(
-                  PROJECT_CONFIG,
-                  String.format(
-                      "Invalid %s \"%s\" for label \"%s\": %s",
-                      KEY_VALUE, value, name, notValue.getMessage())));
+              String.format(
+                  "Invalid %s \"%s\" for label \"%s\": %s",
+                  KEY_VALUE, value, name, notValue.getMessage()));
         }
       }
 
@@ -1053,7 +1073,7 @@
       try {
         label = LabelType.builder(name, values);
       } catch (IllegalArgumentException badName) {
-        error(ValidationError.create(PROJECT_CONFIG, String.format("Invalid label \"%s\"", name)));
+        error(String.format("Invalid label \"%s\"", name));
         continue;
       }
 
@@ -1064,11 +1084,9 @@
               : Optional.of(LabelFunction.MAX_WITH_BLOCK);
       if (!function.isPresent()) {
         error(
-            ValidationError.create(
-                PROJECT_CONFIG,
-                String.format(
-                    "Invalid %s for label \"%s\". Valid names are: %s",
-                    KEY_FUNCTION, name, Joiner.on(", ").join(LabelFunction.ALL.keySet()))));
+            String.format(
+                "Invalid %s for label \"%s\". Valid names are: %s",
+                KEY_FUNCTION, name, Joiner.on(", ").join(LabelFunction.ALL.keySet())));
       }
       label.setFunction(function.orElse(null));
       label.setCopyCondition(rc.getString(LABEL, name, KEY_COPY_CONDITION));
@@ -1078,11 +1096,7 @@
         if (isInRange(dv, values)) {
           label.setDefaultValue(dv);
         } else {
-          error(
-              ValidationError.create(
-                  PROJECT_CONFIG,
-                  String.format(
-                      "Invalid %s \"%s\" for label \"%s\"", KEY_DEFAULT_VALUE, dv, name)));
+          error(String.format("Invalid %s \"%s\" for label \"%s\"", KEY_DEFAULT_VALUE, dv, name));
         }
       }
       label.setAllowPostSubmit(
@@ -1131,18 +1145,13 @@
           short copyValue = Shorts.checkedCast(PermissionRule.parseInt(value));
           if (!copyValues.add(copyValue)) {
             error(
-                ValidationError.create(
-                    PROJECT_CONFIG,
-                    String.format(
-                        "Duplicate %s \"%s\" for label \"%s\"", KEY_COPY_VALUE, value, name)));
+                String.format("Duplicate %s \"%s\" for label \"%s\"", KEY_COPY_VALUE, value, name));
           }
         } catch (IllegalArgumentException notValue) {
           error(
-              ValidationError.create(
-                  PROJECT_CONFIG,
-                  String.format(
-                      "Invalid %s \"%s\" for label \"%s\": %s",
-                      KEY_COPY_VALUE, value, name, notValue.getMessage())));
+              String.format(
+                  "Invalid %s \"%s\" for label \"%s\": %s",
+                  KEY_COPY_VALUE, value, name, notValue.getMessage()));
         }
       }
       label.setCopyValues(copyValues);
@@ -1177,18 +1186,14 @@
         commentLinkSections.put(name, buildCommentLink(rc, name, false));
       } catch (PatternSyntaxException e) {
         error(
-            ValidationError.create(
-                PROJECT_CONFIG,
-                String.format(
-                    "Invalid pattern \"%s\" in commentlink.%s.match: %s",
-                    rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage())));
+            String.format(
+                "Invalid pattern \"%s\" in commentlink.%s.match: %s",
+                rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage()));
       } catch (IllegalArgumentException e) {
         error(
-            ValidationError.create(
-                PROJECT_CONFIG,
-                String.format(
-                    "Error in pattern \"%s\" in commentlink.%s.match: %s",
-                    rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage())));
+            String.format(
+                "Error in pattern \"%s\" in commentlink.%s.match: %s",
+                rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage()));
       }
     }
   }
@@ -1230,9 +1235,7 @@
         if (groupName != null) {
           GroupReference ref = groupList.byName(groupName);
           if (ref == null) {
-            error(
-                ValidationError.create(
-                    PROJECT_CONFIG, "group \"" + groupName + "\" not in " + GroupList.FILE_NAME));
+            error(String.format("group \"%s\" not in %s", groupName, GroupList.FILE_NAME));
           }
           rc.setString(PLUGIN, plugin, name, value);
         }
@@ -1782,11 +1785,15 @@
     try {
       return rc.getEnum(section, subsection, name, defaultValue);
     } catch (IllegalArgumentException err) {
-      error(ValidationError.create(PROJECT_CONFIG, err.getMessage()));
+      error(err.getMessage());
       return defaultValue;
     }
   }
 
+  private void error(String errorMessage) {
+    error(ValidationError.create(PROJECT_CONFIG, errorMessage));
+  }
+
   @Override
   public void error(ValidationError error) {
     if (validationErrors == null) {
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java b/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
new file mode 100644
index 0000000..738e71b
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementExpressionsValidator.java
@@ -0,0 +1,184 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * Validates the expressions of submit requirements in {@code project.config}.
+ *
+ * <p>Other validation of submit requirements is done in {@link ProjectConfig}, see {@code
+ * ProjectConfig#loadSubmitRequirementSections(Config)}.
+ *
+ * <p>The validation of the expressions cannot be in {@link ProjectConfig} as it requires injecting
+ * {@link SubmitRequirementsEvaluator} and we cannot do injections into {@link ProjectConfig} (since
+ * {@link ProjectConfig} is cached in the project cache).
+ */
+@Singleton
+public class SubmitRequirementExpressionsValidator implements CommitValidationListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final DiffOperations diffOperations;
+  private final ProjectConfig.Factory projectConfigFactory;
+  private final SubmitRequirementsEvaluator submitRequirementsEvaluator;
+
+  @Inject
+  SubmitRequirementExpressionsValidator(
+      DiffOperations diffOperations,
+      ProjectConfig.Factory projectConfigFactory,
+      SubmitRequirementsEvaluator submitRequirementsEvaluator) {
+    this.diffOperations = diffOperations;
+    this.projectConfigFactory = projectConfigFactory;
+    this.submitRequirementsEvaluator = submitRequirementsEvaluator;
+  }
+
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event)
+      throws CommitValidationException {
+    try {
+      if (!event.refName.equals(RefNames.REFS_CONFIG)
+          || !isFileChanged(event, ProjectConfig.PROJECT_CONFIG)) {
+        // the project.config file in refs/meta/config was not modified, hence we do not need to
+        // validate the submit requirements in it
+        return ImmutableList.of();
+      }
+
+      ProjectConfig projectConfig = getProjectConfig(event);
+      ImmutableList<CommitValidationMessage> validationMessages =
+          validateSubmitRequirementExpressions(
+              projectConfig.getSubmitRequirementSections().values());
+      if (!validationMessages.isEmpty()) {
+        throw new CommitValidationException(
+            String.format(
+                "invalid submit requirement expressions in %s (revision = %s)",
+                ProjectConfig.PROJECT_CONFIG, projectConfig.getRevision()),
+            validationMessages);
+      }
+      return ImmutableList.of();
+    } catch (IOException | DiffNotAvailableException | ConfigInvalidException e) {
+      String errorMessage =
+          String.format(
+              "failed to validate submit requirement expressions in %s for revision %s in ref %s"
+                  + " of project %s",
+              ProjectConfig.PROJECT_CONFIG,
+              event.commit.getName(),
+              RefNames.REFS_CONFIG,
+              event.project.getNameKey());
+      logger.atSevere().withCause(e).log(errorMessage);
+      throw new CommitValidationException(errorMessage, e);
+    }
+  }
+
+  /**
+   * Whether the given file was changed in the given revision.
+   *
+   * @param receiveEvent the receive event
+   * @param fileName the name of the file
+   */
+  private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
+      throws DiffNotAvailableException {
+    return diffOperations
+        .listModifiedFilesAgainstParent(
+            receiveEvent.project.getNameKey(), receiveEvent.commit, /* parentNum=*/ 0)
+        .keySet().stream()
+        .anyMatch(fileName::equals);
+  }
+
+  private ProjectConfig getProjectConfig(CommitReceivedEvent receiveEvent)
+      throws IOException, ConfigInvalidException {
+    ProjectConfig projectConfig = projectConfigFactory.create(receiveEvent.project.getNameKey());
+    projectConfig.load(receiveEvent.revWalk, receiveEvent.commit);
+    return projectConfig;
+  }
+
+  private ImmutableList<CommitValidationMessage> validateSubmitRequirementExpressions(
+      Collection<SubmitRequirement> submitRequirements) {
+    List<CommitValidationMessage> validationMessages = new ArrayList<>();
+    for (SubmitRequirement submitRequirement : submitRequirements) {
+      validateSubmitRequirementExpression(
+          validationMessages,
+          submitRequirement,
+          submitRequirement.submittabilityExpression(),
+          ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION);
+      submitRequirement
+          .applicabilityExpression()
+          .ifPresent(
+              expression ->
+                  validateSubmitRequirementExpression(
+                      validationMessages,
+                      submitRequirement,
+                      expression,
+                      ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION));
+      submitRequirement
+          .overrideExpression()
+          .ifPresent(
+              expression ->
+                  validateSubmitRequirementExpression(
+                      validationMessages,
+                      submitRequirement,
+                      expression,
+                      ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION));
+    }
+    return ImmutableList.copyOf(validationMessages);
+  }
+
+  private void validateSubmitRequirementExpression(
+      List<CommitValidationMessage> validationMessages,
+      SubmitRequirement submitRequirement,
+      SubmitRequirementExpression expression,
+      String configKey) {
+    try {
+      submitRequirementsEvaluator.validateExpression(expression);
+    } catch (QueryParseException e) {
+      if (validationMessages.isEmpty()) {
+        validationMessages.add(
+            new CommitValidationMessage(
+                "Invalid project configuration", ValidationMessage.Type.ERROR));
+      }
+      validationMessages.add(
+          new CommitValidationMessage(
+              String.format(
+                  "  %s: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+                      + " invalid: %s",
+                  ProjectConfig.PROJECT_CONFIG,
+                  expression.expressionString(),
+                  submitRequirement.name(),
+                  ProjectConfig.SUBMIT_REQUIREMENT,
+                  submitRequirement.name(),
+                  configKey,
+                  e.getMessage()),
+              ValidationMessage.Type.ERROR));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
index 9555bf3..cc2c805 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -24,8 +24,6 @@
 import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.experiments.ExperimentFeatures;
-import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder;
 import com.google.inject.AbstractModule;
@@ -43,7 +41,6 @@
   private final Provider<SubmitRequirementChangeQueryBuilder> queryBuilder;
   private final ProjectCache projectCache;
   private final SubmitRuleEvaluator.Factory legacyEvaluator;
-  private final ExperimentFeatures experimentFeatures;
 
   public static Module module() {
     return new AbstractModule() {
@@ -60,12 +57,10 @@
   private SubmitRequirementsEvaluatorImpl(
       Provider<SubmitRequirementChangeQueryBuilder> queryBuilder,
       ProjectCache projectCache,
-      SubmitRuleEvaluator.Factory legacyEvaluator,
-      ExperimentFeatures experimentFeatures) {
+      SubmitRuleEvaluator.Factory legacyEvaluator) {
     this.queryBuilder = queryBuilder;
     this.projectCache = projectCache;
     this.legacyEvaluator = legacyEvaluator;
-    this.experimentFeatures = experimentFeatures;
   }
 
   @Override
@@ -79,10 +74,7 @@
       ChangeData cd, boolean includeLegacy) {
     Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements = getRequirements(cd);
     Map<SubmitRequirement, SubmitRequirementResult> result = projectConfigRequirements;
-    if (includeLegacy
-        && experimentFeatures.isFeatureEnabled(
-            ExperimentFeaturesConstants
-                .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_LEGACY_SUBMIT_REQUIREMENTS)) {
+    if (includeLegacy) {
       Map<SubmitRequirement, SubmitRequirementResult> legacyReqs =
           SubmitRequirementsAdapter.getLegacyRequirements(legacyEvaluator, cd);
       result =
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
index 2e43eac..102d3f2 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
@@ -50,10 +50,10 @@
     result.putAll(projectConfigRequirements);
     Map<String, SubmitRequirementResult> requirementsByName =
         projectConfigRequirements.entrySet().stream()
-            .collect(Collectors.toMap(sr -> sr.getKey().name(), sr -> sr.getValue()));
+            .collect(Collectors.toMap(sr -> sr.getKey().name().toLowerCase(), sr -> sr.getValue()));
     for (Map.Entry<SubmitRequirement, SubmitRequirementResult> legacy :
         legacyRequirements.entrySet()) {
-      String name = legacy.getKey().name();
+      String name = legacy.getKey().name().toLowerCase();
       SubmitRequirementResult projectConfigResult = requirementsByName.get(name);
       SubmitRequirementResult legacyResult = legacy.getValue();
       if (projectConfigResult != null && matchByStatus(projectConfigResult, legacyResult)) {
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 9ea6475..9961519 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -948,6 +948,10 @@
    * com.google.gerrit.server.index.change.ChangeField#STORED_SUBMIT_REQUIREMENTS}.
    */
   public Map<SubmitRequirement, SubmitRequirementResult> submitRequirements() {
+    if (!experimentFeatures.isFeatureEnabled(
+        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)) {
+      return Collections.emptyMap();
+    }
     if (submitRequirements == null) {
       if (!lazyload()) {
         return Collections.emptyMap();
@@ -964,12 +968,6 @@
           notes().getSubmitRequirementsResult().stream()
               .filter(r -> !r.isLegacy())
               .collect(Collectors.toMap(r -> r.submitRequirement(), Function.identity()));
-      if (!experimentFeatures.isFeatureEnabled(
-          ExperimentFeaturesConstants
-              .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_LEGACY_SUBMIT_REQUIREMENTS)) {
-        submitRequirements = projectConfigRequirements;
-        return submitRequirements;
-      }
       Map<SubmitRequirement, SubmitRequirementResult> legacyRequirements =
           SubmitRequirementsAdapter.getLegacyRequirements(submitRuleEvaluatorFactory, this);
       submitRequirements =
@@ -981,7 +979,15 @@
 
   public void setSubmitRequirements(
       Map<SubmitRequirement, SubmitRequirementResult> submitRequirements) {
-    this.submitRequirements = submitRequirements;
+    if (!experimentFeatures.isFeatureEnabled(
+        ExperimentFeaturesConstants
+            .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD)) {
+      // Only set back values from the index if the experiment is not active. While the experiment
+      // is active, we want
+      // to compute SRs from scratch to ensure fresh results.
+      // TODO(ghareeb, hiesel): Remove this.
+      this.submitRequirements = submitRequirements;
+    }
   }
 
   public List<SubmitRecord> submitRecords(SubmitRuleOptions options) {
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 4e638df..3afdcdd 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
 import com.google.common.base.CharMatcher;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
@@ -21,12 +23,15 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.HashtagsUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Locale;
+import java.util.Set;
 
 /** Predicates that match against {@link ChangeData}. */
 public class ChangePredicates {
@@ -76,8 +81,34 @@
    * Returns a predicate that matches changes where the provided {@link
    * com.google.gerrit.entities.Account.Id} has a pending draft comment.
    */
-  public static Predicate<ChangeData> draftBy(Account.Id id) {
-    return new ChangeIndexPredicate(ChangeField.DRAFTBY, id.toString());
+  public static Predicate<ChangeData> draftBy(
+      boolean computeFromAllUsersRepository, CommentsUtil commentsUtil, Account.Id id) {
+    if (!computeFromAllUsersRepository) {
+      return new ChangeIndexPredicate(ChangeField.DRAFTBY, id.toString());
+    }
+    Set<Predicate<ChangeData>> changeIdPredicates =
+        commentsUtil.getChangesWithDrafts(id).stream()
+            .map(ChangePredicates::idStr)
+            .collect(toImmutableSet());
+    return Predicate.or(changeIdPredicates);
+  }
+
+  /**
+   * Returns a predicate that matches changes where the provided {@link
+   * com.google.gerrit.entities.Account.Id} has starred changes with {@code label}.
+   */
+  public static Predicate<ChangeData> starBy(
+      boolean computeFromAllUsersRepository,
+      StarredChangesUtil starredChangesUtil,
+      Account.Id id,
+      String label) {
+    if (!computeFromAllUsersRepository) {
+      return new StarPredicate(id, label);
+    }
+    return Predicate.or(
+        starredChangesUtil.byAccountId(id, label).stream()
+            .map(ChangePredicates::idStr)
+            .collect(toImmutableSet()));
   }
 
   /**
@@ -125,6 +156,14 @@
   }
 
   /**
+   * Returns a predicate that matches changes where the latest patch set was uploaded by the
+   * provided {@link com.google.gerrit.entities.Account.Id}.
+   */
+  public static Predicate<ChangeData> uploader(Account.Id id) {
+    return new ChangeIndexPredicate(ChangeField.UPLOADER, id.toString());
+  }
+
+  /**
    * Returns a predicate that matches changes that are a cherry pick of the provided {@link
    * com.google.gerrit.entities.Change.Id}.
    */
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index f1fe520..57191c5 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Change.CHANGE_ID_PATTERN;
 import static com.google.gerrit.server.account.AccountResolver.isSelf;
+import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
@@ -70,6 +71,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.HasOperandAliasConfig;
 import com.google.gerrit.server.config.OperatorAliasConfig;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
@@ -195,6 +197,8 @@
   public static final String FIELD_SUBMISSIONID = "submissionid";
   public static final String FIELD_TR = "tr";
   public static final String FIELD_UNRESOLVED_COMMENT_COUNT = "unresolved";
+  public static final String FIELD_UPLOADER = "uploader";
+  public static final String FIELD_UPLOADERIN = "uploaderin";
   public static final String FIELD_VISIBLETO = "visibleto";
   public static final String FIELD_WATCHEDBY = "watchedby";
   public static final String FIELD_WIP = "wip";
@@ -251,6 +255,7 @@
     final OperatorAliasConfig operatorAliasConfig;
     final boolean indexMergeable;
     final boolean conflictsPredicateEnabled;
+    final ExperimentFeatures experimentFeatures;
     final HasOperandAliasConfig hasOperandAliasConfig;
     final PluginSetContext<SubmitRule> submitRules;
 
@@ -286,6 +291,7 @@
         GroupMembers groupMembers,
         OperatorAliasConfig operatorAliasConfig,
         @GerritServerConfig Config gerritConfig,
+        ExperimentFeatures experimentFeatures,
         HasOperandAliasConfig hasOperandAliasConfig,
         ChangeIsVisibleToPredicate.Factory changeIsVisbleToPredicateFactory,
         PluginSetContext<SubmitRule> submitRules) {
@@ -318,6 +324,7 @@
           operatorAliasConfig,
           MergeabilityComputationBehavior.fromConfig(gerritConfig).includeInIndex(),
           gerritConfig.getBoolean("change", null, "conflictsPredicateEnabled", true),
+          experimentFeatures,
           hasOperandAliasConfig,
           changeIsVisbleToPredicateFactory,
           submitRules);
@@ -352,6 +359,7 @@
         OperatorAliasConfig operatorAliasConfig,
         boolean indexMergeable,
         boolean conflictsPredicateEnabled,
+        ExperimentFeatures experimentFeatures,
         HasOperandAliasConfig hasOperandAliasConfig,
         ChangeIsVisibleToPredicate.Factory changeIsVisbleToPredicateFactory,
         PluginSetContext<SubmitRule> submitRules) {
@@ -384,6 +392,7 @@
       this.operatorAliasConfig = operatorAliasConfig;
       this.indexMergeable = indexMergeable;
       this.conflictsPredicateEnabled = conflictsPredicateEnabled;
+      this.experimentFeatures = experimentFeatures;
       this.hasOperandAliasConfig = hasOperandAliasConfig;
       this.submitRules = submitRules;
     }
@@ -418,6 +427,7 @@
           operatorAliasConfig,
           indexMergeable,
           conflictsPredicateEnabled,
+          experimentFeatures,
           hasOperandAliasConfig,
           changeIsVisbleToPredicateFactory,
           submitRules);
@@ -661,6 +671,14 @@
       return ChangePredicates.owner(self());
     }
 
+    if ("uploader".equalsIgnoreCase(value)) {
+      if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
+        throw new QueryParseException(
+            "'is:uploader' operator is not supported by change index version");
+      }
+      return ChangePredicates.uploader(self());
+    }
+
     if ("reviewer".equalsIgnoreCase(value)) {
       if (args.getSchema().hasField(ChangeField.WIP)) {
         return Predicate.and(
@@ -1079,15 +1097,29 @@
   }
 
   private Predicate<ChangeData> ignoredBySelf() throws QueryParseException {
-    return new StarPredicate(self(), StarredChangesUtil.IGNORE_LABEL);
+    return ChangePredicates.starBy(
+        args.experimentFeatures.isFeatureEnabled(
+            GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
+        args.starredChangesUtil,
+        self(),
+        StarredChangesUtil.IGNORE_LABEL);
   }
 
   private Predicate<ChangeData> starredBySelf() throws QueryParseException {
-    return new StarPredicate(self(), StarredChangesUtil.DEFAULT_LABEL);
+    return ChangePredicates.starBy(
+        args.experimentFeatures.isFeatureEnabled(
+            GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
+        args.starredChangesUtil,
+        self(),
+        StarredChangesUtil.DEFAULT_LABEL);
   }
 
   private Predicate<ChangeData> draftBySelf() throws QueryParseException {
-    return ChangePredicates.draftBy(self());
+    return ChangePredicates.draftBy(
+        args.experimentFeatures.isFeatureEnabled(
+            GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
+        args.commentsUtil,
+        self());
   }
 
   @Operator
@@ -1163,6 +1195,23 @@
   }
 
   @Operator
+  public Predicate<ChangeData> uploader(String who)
+      throws QueryParseException, IOException, ConfigInvalidException {
+    if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
+      throw new QueryParseException("'uploader' operator is not supported by change index version");
+    }
+    return uploader(parseAccount(who, (AccountState s) -> true));
+  }
+
+  private Predicate<ChangeData> uploader(Set<Account.Id> who) {
+    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(who.size());
+    for (Account.Id id : who) {
+      p.add(ChangePredicates.uploader(id));
+    }
+    return Predicate.or(p);
+  }
+
+  @Operator
   public Predicate<ChangeData> attention(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
     if (!args.index.getSchema().hasField(ChangeField.ATTENTION_SET_USERS)) {
@@ -1212,6 +1261,31 @@
   }
 
   @Operator
+  public Predicate<ChangeData> uploaderin(String group) throws QueryParseException, IOException {
+    if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
+      throw new QueryParseException("'uploader' operator is not supported by change index version");
+    }
+
+    GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
+    if (g == null) {
+      throw error("Group " + group + " not found");
+    }
+
+    AccountGroup.UUID groupId = g.getUUID();
+    GroupDescription.Basic groupDescription = args.groupBackend.get(groupId);
+    if (!(groupDescription instanceof GroupDescription.Internal)) {
+      return new UploaderinPredicate(args.userFactory, groupId);
+    }
+
+    Set<Account.Id> accounts = getMembers(groupId);
+    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(accounts.size());
+    for (Account.Id id : accounts) {
+      p.add(ChangePredicates.uploader(id));
+    }
+    return Predicate.or(p);
+  }
+
+  @Operator
   public Predicate<ChangeData> r(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
     return reviewer(who);
diff --git a/java/com/google/gerrit/server/query/change/UploaderinPredicate.java b/java/com/google/gerrit/server/query/change/UploaderinPredicate.java
new file mode 100644
index 0000000..f44c0b5
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/UploaderinPredicate.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.index.query.PostFilterPredicate;
+import com.google.gerrit.server.IdentifiedUser;
+
+/**
+ * Predicate that matches changes where the latest patch set was uploaded by a user in the provided
+ * group.
+ */
+public class UploaderinPredicate extends PostFilterPredicate<ChangeData> {
+  protected final IdentifiedUser.GenericFactory userFactory;
+  protected final AccountGroup.UUID uuid;
+
+  public UploaderinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
+    super(ChangeQueryBuilder.FIELD_UPLOADERIN, uuid.get());
+    this.userFactory = userFactory;
+    this.uuid = uuid;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    PatchSet latestPatchSet = cd.currentPatchSet();
+    if (latestPatchSet == null) {
+      return false;
+    }
+    IdentifiedUser uploader = userFactory.create(latestPatchSet.uploader());
+    return uploader.getEffectiveGroups().contains(uuid);
+  }
+
+  @Override
+  public int getCost() {
+    return 2;
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index 1485a6e56..ad3c56b 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.restapi.account;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Strings;
@@ -39,6 +40,7 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangePredicates;
@@ -75,6 +77,7 @@
   private final Provider<CommentJson> commentJsonProvider;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
+  private final ExperimentFeatures experimentFeatures;
 
   @Inject
   DeleteDraftComments(
@@ -86,7 +89,8 @@
       ChangeJson.Factory changeJsonFactory,
       Provider<CommentJson> commentJsonProvider,
       CommentsUtil commentsUtil,
-      PatchSetUtil psUtil) {
+      PatchSetUtil psUtil,
+      ExperimentFeatures experimentFeatures) {
     this.userProvider = userProvider;
     this.batchUpdateFactory = batchUpdateFactory;
     this.queryBuilderProvider = queryBuilderProvider;
@@ -96,6 +100,7 @@
     this.commentJsonProvider = commentJsonProvider;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
+    this.experimentFeatures = experimentFeatures;
   }
 
   @Override
@@ -146,7 +151,12 @@
 
   private Predicate<ChangeData> predicate(Account.Id accountId, DeleteDraftCommentsInput input)
       throws BadRequestException {
-    Predicate<ChangeData> hasDraft = ChangePredicates.draftBy(accountId);
+    Predicate<ChangeData> hasDraft =
+        ChangePredicates.draftBy(
+            experimentFeatures.isFeatureEnabled(
+                GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY),
+            commentsUtil,
+            accountId);
     if (CharMatcher.whitespace().trimFrom(Strings.nullToEmpty(input.query)).isEmpty()) {
       return hasDraft;
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index e657c89..74407c0 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -225,7 +225,6 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushResult;
-import org.junit.Ignore;
 import org.junit.Test;
 
 @NoHttpd
@@ -4059,7 +4058,7 @@
       assertThat(testLabel.status).isEqualTo(SubmitRecordInfo.Label.Status.OK);
       assertThat(testLabel.appliedBy).isNull();
 
-      voteLabel(changeId, "code-review", 2);
+      voteLabel(changeId, "Code-Review", 2);
       // Code review record is satisfied after voting +2
       change = gApi.changes().id(changeId).get();
       assertThat(change.submitRecords).hasSize(2);
@@ -4159,13 +4158,16 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_withLabelEqualsMax() throws Exception {
     configSubmitRequirement(
         project,
         SubmitRequirement.builder()
-            .setName("code-review")
+            .setName("Code-Review")
             .setSubmittabilityExpression(
-                SubmitRequirementExpression.create("label:code-review=MAX"))
+                SubmitRequirementExpression.create("label:Code-Review=MAX"))
             .setAllowOverrideInChildProjects(false)
             .build());
 
@@ -4175,16 +4177,19 @@
     ChangeInfo change = gApi.changes().id(changeId).get();
     assertThat(change.submitRequirements).hasSize(1);
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
 
-    voteLabel(changeId, "code-review", 2);
+    voteLabel(changeId, "Code-Review", 2);
     change = gApi.changes().id(changeId).get();
     assertThat(change.submitRequirements).hasSize(1);
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_withLabelEqualsMax_fromNonUploader() throws Exception {
     configLabel("my-label", LabelFunction.NO_OP); // label function has no effect
     projectOperations
@@ -4204,14 +4209,15 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
+    // The second requirement is coming from the legacy code-review label function
+    assertThat(change.submitRequirements).hasSize(2);
     assertSubmitRequirementStatus(
         change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
 
     // Voting with a max vote as the uploader will not satisfy the submit requirement.
     voteLabel(changeId, "my-label", 1);
     change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
+    assertThat(change.submitRequirements).hasSize(2);
     assertSubmitRequirementStatus(
         change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
 
@@ -4219,19 +4225,22 @@
     requestScopeOperations.setApiUser(user.id());
     voteLabel(changeId, "my-label", 1);
     change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
+    assertThat(change.submitRequirements).hasSize(2);
     assertSubmitRequirementStatus(
         change.submitRequirements, "my-label", Status.SATISFIED, /* isLegacy= */ false);
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_withLabelEqualsMinBlockingSubmission() throws Exception {
     configSubmitRequirement(
         project,
         SubmitRequirement.builder()
-            .setName("code-review")
+            .setName("Code-Review")
             .setSubmittabilityExpression(
-                SubmitRequirementExpression.create("-label:code-review=MIN"))
+                SubmitRequirementExpression.create("-label:Code-Review=MIN"))
             .setAllowOverrideInChildProjects(false)
             .build());
 
@@ -4239,27 +4248,36 @@
     String changeId = r.getChangeId();
 
     ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
+    assertThat(change.submitRequirements).hasSize(2);
     // Requirement is satisfied because there are no votes
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement (coming from the label function definition) is not satisfied. We return
+    // both legacy and non-legacy requirements in this case since their statuses are not identical.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
 
-    voteLabel(changeId, "code-review", -1);
+    voteLabel(changeId, "Code-Review", -1);
     change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
+    assertThat(change.submitRequirements).hasSize(2);
     // Requirement is still satisfied because -1 is not the max negative value
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
 
-    voteLabel(changeId, "code-review", -2);
+    voteLabel(changeId, "Code-Review", -2);
     change = gApi.changes().id(changeId).get();
     assertThat(change.submitRequirements).hasSize(1);
     // Requirement is now unsatisfied because -2 is the max negative value
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_withMaxWithBlock_ignoringSelfApproval() throws Exception {
     configLabel("my-label", LabelFunction.MAX_WITH_BLOCK);
     projectOperations
@@ -4286,7 +4304,8 @@
     // Admin (a.k.a uploader) adds a -1 min vote. This is going to block submission.
     voteLabel(changeId, "my-label", -1);
     ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
+    // The other requirement is coming from the code-review label function
+    assertThat(change.submitRequirements).hasSize(2);
     assertSubmitRequirementStatus(
         change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
 
@@ -4294,7 +4313,7 @@
     requestScopeOperations.setApiUser(user.id());
     voteLabel(changeId, "my-label", 1);
     change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
+    assertThat(change.submitRequirements).hasSize(2);
     assertSubmitRequirementStatus(
         change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
 
@@ -4302,19 +4321,22 @@
     requestScopeOperations.setApiUser(admin.id());
     voteLabel(changeId, "my-label", 0);
     change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
+    assertThat(change.submitRequirements).hasSize(2);
     assertSubmitRequirementStatus(
         change.submitRequirements, "my-label", Status.SATISFIED, /* isLegacy= */ false);
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_withLabelEqualsAny() throws Exception {
     configSubmitRequirement(
         project,
         SubmitRequirement.builder()
-            .setName("code-review")
+            .setName("Code-Review")
             .setSubmittabilityExpression(
-                SubmitRequirementExpression.create("label:code-review=ANY"))
+                SubmitRequirementExpression.create("label:Code-Review=ANY"))
             .setAllowOverrideInChildProjects(false)
             .build());
 
@@ -4324,30 +4346,36 @@
     ChangeInfo change = gApi.changes().id(changeId).get();
     assertThat(change.submitRequirements).hasSize(1);
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
 
-    voteLabel(changeId, "code-review", 1);
+    voteLabel(changeId, "Code-Review", 1);
     change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
+    assertThat(change.submitRequirements).hasSize(2);
+    // Legacy and non-legacy requirements have mismatching status. Both are returned from the API.
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsFulfilled()
       throws Exception {
     configSubmitRequirement(
         project,
         SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
             .setAllowOverrideInChildProjects(false)
             .build());
     configSubmitRequirement(
         project,
         SubmitRequirement.builder()
-            .setName("verified")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:verified=+1"))
+            .setName("Verified")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Verified=+1"))
             .setAllowOverrideInChildProjects(false)
             .build());
 
@@ -4357,29 +4385,32 @@
     ChangeInfo change = gApi.changes().id(changeId).get();
     assertThat(change.submitRequirements).hasSize(2);
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
     assertSubmitRequirementStatus(
-        change.submitRequirements, "verified", Status.UNSATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Verified", Status.UNSATISFIED, /* isLegacy= */ false);
 
-    voteLabel(changeId, "code-review", 2);
+    voteLabel(changeId, "Code-Review", 2);
 
     change = gApi.changes().id(changeId).get();
     assertThat(change.submitRequirements).hasSize(2);
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
     assertSubmitRequirementStatus(
-        change.submitRequirements, "verified", Status.UNSATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Verified", Status.UNSATISFIED, /* isLegacy= */ false);
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsNotFulfilled()
       throws Exception {
     configSubmitRequirement(
         project,
         SubmitRequirement.builder()
-            .setName("code-review")
+            .setName("Code-Review")
             .setApplicabilityExpression(SubmitRequirementExpression.of("project:foo"))
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
             .setAllowOverrideInChildProjects(false)
             .build());
 
@@ -4387,14 +4418,19 @@
     String changeId = r.getChangeId();
 
     ChangeInfo change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
+    assertThat(change.submitRequirements).hasSize(2);
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.NOT_APPLICABLE, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.NOT_APPLICABLE, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirementIsOverridden_whenOverrideExpressionIsFulfilled() throws Exception {
-    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
+    configLabel("build-cop-override", LabelFunction.NO_BLOCK);
     projectOperations
         .project(project)
         .forUpdate()
@@ -4408,8 +4444,8 @@
     configSubmitRequirement(
         project,
         SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
             .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
             .setAllowOverrideInChildProjects(false)
             .build());
@@ -4419,34 +4455,38 @@
     ChangeInfo change = gApi.changes().id(changeId).get();
     assertThat(change.submitRequirements).hasSize(1);
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
 
     voteLabel(changeId, "build-cop-override", 1);
 
     change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
+    assertThat(change.submitRequirements).hasSize(2);
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.OVERRIDDEN, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.OVERRIDDEN, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
   }
 
   @Test
-  @Ignore("Test is flaky")
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_overriddenInChildProject() throws Exception {
     configSubmitRequirement(
         allProjects,
         SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+1"))
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
             .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
             .setAllowOverrideInChildProjects(true)
             .build());
 
-    // Override submit requirement in child project (requires code-review=+2 instead of +1)
+    // Override submit requirement in child project (requires Code-Review=+2 instead of +1)
     configSubmitRequirement(
         project,
         SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
             .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
             .setAllowOverrideInChildProjects(false)
             .build());
@@ -4456,28 +4496,31 @@
     ChangeInfo change = gApi.changes().id(changeId).get();
     assertThat(change.submitRequirements).hasSize(1);
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
 
-    voteLabel(changeId, "code-review", 1);
+    voteLabel(changeId, "Code-Review", 1);
     change = gApi.changes().id(changeId).get();
     assertThat(change.submitRequirements).hasSize(1);
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
 
-    voteLabel(changeId, "code-review", 2);
+    voteLabel(changeId, "Code-Review", 2);
     change = gApi.changes().id(changeId).get();
     assertThat(change.submitRequirements).hasSize(1);
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_inheritedFromParentProject() throws Exception {
     configSubmitRequirement(
         allProjects,
         SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+1"))
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
             .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
             .setAllowOverrideInChildProjects(false)
             .build());
@@ -4487,34 +4530,40 @@
     ChangeInfo change = gApi.changes().id(changeId).get();
     assertThat(change.submitRequirements).hasSize(1);
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
 
-    voteLabel(changeId, "code-review", 1);
+    voteLabel(changeId, "Code-Review", 1);
     change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
+    assertThat(change.submitRequirements).hasSize(2);
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_ignoredInChildProject_ifParentDoesNotAllowOverride()
       throws Exception {
     configSubmitRequirement(
         allProjects,
         SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+1"))
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+1"))
             .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
             .setAllowOverrideInChildProjects(false)
             .build());
 
-    // Override submit requirement in child project (requires code-review=+2 instead of +1).
+    // Override submit requirement in child project (requires Code-Review=+2 instead of +1).
     // Will have no effect since parent does not allow override.
     configSubmitRequirement(
         project,
         SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
             .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
             .setAllowOverrideInChildProjects(false)
             .build());
@@ -4524,17 +4573,27 @@
     ChangeInfo change = gApi.changes().id(changeId).get();
     assertThat(change.submitRequirements).hasSize(1);
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
 
-    voteLabel(changeId, "code-review", 1);
+    voteLabel(changeId, "Code-Review", 1);
     change = gApi.changes().id(changeId).get();
-    assertThat(change.submitRequirements).hasSize(1);
+    assertThat(change.submitRequirements).hasSize(2);
     // +1 was enough to fulfill the requirement: override in child project was ignored
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
+        ExperimentFeaturesConstants
+            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
+      })
   public void submitRequirement_storedForClosedChanges() throws Exception {
     for (SubmitType submitType : SubmitType.values()) {
       Project.NameKey project = createProjectForPush(submitType);
@@ -4542,9 +4601,9 @@
       configSubmitRequirement(
           project,
           SubmitRequirement.builder()
-              .setName("code-review")
+              .setName("Code-Review")
               .setSubmittabilityExpression(
-                  SubmitRequirementExpression.create("label:code-review=+2"))
+                  SubmitRequirementExpression.create("label:Code-Review=+2"))
               .setAllowOverrideInChildProjects(false)
               .build());
 
@@ -4552,12 +4611,12 @@
           createChange(repo, "master", "Add a file", "foo", "content", "topic");
       String changeId = r.getChangeId();
 
-      voteLabel(changeId, "code-review", 2);
+      voteLabel(changeId, "Code-Review", 2);
 
       ChangeInfo change = gApi.changes().id(changeId).get();
       assertThat(change.submitRequirements).hasSize(1);
       assertSubmitRequirementStatus(
-          change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+          change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
 
       RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
       revision.review(ReviewInput.approve());
@@ -4571,17 +4630,24 @@
       assertThat(result.submittabilityExpressionResult().status())
           .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
       assertThat(result.submittabilityExpressionResult().expression().expressionString())
-          .isEqualTo("label:code-review=+2");
+          .isEqualTo("label:Code-Review=+2");
     }
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
+        ExperimentFeaturesConstants
+            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
+      })
   public void submitRequirement_retrievedFromNoteDbForClosedChanges() throws Exception {
     configSubmitRequirement(
         project,
         SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
             .setAllowOverrideInChildProjects(false)
             .build());
 
@@ -4591,14 +4657,14 @@
     ChangeInfo change = gApi.changes().id(changeId).get();
     assertThat(change.submitRequirements).hasSize(1);
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ false);
 
-    voteLabel(changeId, "code-review", 2);
+    voteLabel(changeId, "Code-Review", 2);
 
     change = gApi.changes().id(changeId).get();
     assertThat(change.submitRequirements).hasSize(1);
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
 
     gApi.changes().id(changeId).current().submit();
 
@@ -4606,24 +4672,26 @@
     configSubmitRequirement(
         project,
         SubmitRequirement.builder()
-            .setName("verified")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:verified=+1"))
+            .setName("Verified")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Verified=+1"))
             .setAllowOverrideInChildProjects(false)
             .build());
 
-    // The new "verified" submit requirement is not returned, since this change is closed
+    // The new "Verified" submit requirement is not returned, since this change is closed
     change = gApi.changes().id(changeId).get();
     assertThat(change.submitRequirements).hasSize(1);
     assertSubmitRequirementStatus(
-        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ false);
   }
 
   @Test
   @GerritConfig(
       name = "experiments.enabled",
-      value =
-          ExperimentFeaturesConstants
-              .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_LEGACY_SUBMIT_REQUIREMENTS)
+      values = {
+        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
+        ExperimentFeaturesConstants
+            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
+      })
   public void
       submitRequirements_returnOneEntryForMatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
           throws Exception {
@@ -4686,9 +4754,7 @@
   @Test
   @GerritConfig(
       name = "experiments.enabled",
-      value =
-          ExperimentFeaturesConstants
-              .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_LEGACY_SUBMIT_REQUIREMENTS)
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void
       submitRequirements_returnTwoEntriesForMismatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
           throws Exception {
@@ -4744,9 +4810,7 @@
   @Test
   @GerritConfig(
       name = "experiments.enabled",
-      value =
-          ExperimentFeaturesConstants
-              .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_LEGACY_SUBMIT_REQUIREMENTS)
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirements_returnForLegacySubmitRecords_ifEnabled() throws Exception {
     configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
     projectOperations
@@ -4799,19 +4863,22 @@
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
   public void submitRequirement_backFilledFromIndexForActiveChanges() throws Exception {
     configSubmitRequirement(
         project,
         SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
             .setAllowOverrideInChildProjects(false)
             .build());
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
 
-    voteLabel(changeId, "code-review", 2);
+    voteLabel(changeId, "Code-Review", 2);
 
     // Query the change. ChangeInfo is back-filled from the change index.
     List<ChangeInfo> changeInfos =
@@ -4823,25 +4890,32 @@
     assertThat(changeInfos).hasSize(1);
     assertSubmitRequirementStatus(
         changeInfos.get(0).submitRequirements,
-        "code-review",
+        "Code-Review",
         Status.SATISFIED,
         /* isLegacy= */ false);
   }
 
   @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
+        ExperimentFeaturesConstants
+            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
+      })
   public void submitRequirement_backFilledFromIndexForClosedChanges() throws Exception {
     configSubmitRequirement(
         project,
         SubmitRequirement.builder()
-            .setName("code-review")
-            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
             .setAllowOverrideInChildProjects(false)
             .build());
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
 
-    voteLabel(changeId, "code-review", 2);
+    voteLabel(changeId, "Code-Review", 2);
     gApi.changes().id(changeId).current().submit();
 
     // Query the change. ChangeInfo is back-filled from the change index.
@@ -4854,12 +4928,41 @@
     assertThat(changeInfos).hasSize(1);
     assertSubmitRequirementStatus(
         changeInfos.get(0).submitRequirements,
-        "code-review",
+        "Code-Review",
         Status.SATISFIED,
         /* isLegacy= */ false);
   }
 
   @Test
+  public void submitRequirements_notServedIfExperimentNotEnabled() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("Code-Review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:Code-Review=+2"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).isEmpty();
+
+    voteLabel(changeId, "Code-Review", -1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).isEmpty();
+
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).isEmpty();
+
+    gApi.changes().id(changeId).current().submit();
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).isEmpty();
+  }
+
+  @Test
   public void fourByteEmoji() throws Exception {
     // U+1F601 GRINNING FACE WITH SMILING EYES
     String smile = new String(Character.toChars(0x1f601));
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
index c6b57da..96db71a 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.annotations.Exports;
@@ -28,6 +29,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.inject.Inject;
@@ -201,6 +203,37 @@
     assertThat(rule.numberOfEvaluations.get()).isEqualTo(0);
   }
 
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        ExperimentFeaturesConstants
+            .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD,
+        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS
+      })
+  public void submitRuleIsInvokedWhenQueryingChangeWithExperiment() throws Exception {
+    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
+    String changeId = r.getChangeId();
+
+    rule.numberOfEvaluations.set(0);
+    gApi.changes().query(changeId).withOptions(ListChangesOption.SUBMIT_REQUIREMENTS).get();
+
+    // Submit rules are invoked
+    assertThat(rule.numberOfEvaluations.get()).isEqualTo(1);
+  }
+
+  @Test
+  public void submitRuleIsNotInvokedWhenQueryingChangeWithoutExperiment() throws Exception {
+    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
+    String changeId = r.getChangeId();
+
+    rule.numberOfEvaluations.set(0);
+    gApi.changes().query(changeId).withOptions(ListChangesOption.SUBMIT_REQUIREMENTS).get();
+
+    // Submit rules are not invoked
+    assertThat(rule.numberOfEvaluations.get()).isEqualTo(0);
+  }
+
   private List<ChangeInfo> queryIsSubmittable() throws Exception {
     return gApi.changes().query("is:submittable project:" + project.get()).get();
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 6e19c39..f511683 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -138,13 +138,13 @@
     SubmitRequirement sr =
         createSubmitRequirement(
             /* applicabilityExpr= */ "project:" + project.get(),
-            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* submittabilityExpr= */ "label:\"Code-Review=+2\"",
             /* overrideExpr= */ "");
 
     SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
     assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.UNSATISFIED);
     assertThat(result.submittabilityExpressionResult().failingAtoms())
-        .containsExactly("label:\"code-review=+2\"");
+        .containsExactly("label:\"Code-Review=+2\"");
   }
 
   @Test
@@ -160,7 +160,7 @@
     SubmitRequirement sr =
         createSubmitRequirement(
             /* applicabilityExpr= */ "project:" + project.get(),
-            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* submittabilityExpr= */ "label:\"Code-Review=+2\"",
             /* overrideExpr= */ "label:\"build-cop-override=+1\"");
 
     SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
@@ -175,7 +175,7 @@
     SubmitRequirement sr =
         createSubmitRequirement(
             /* applicabilityExpr= */ "invalid_field:invalid_value",
-            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* submittabilityExpr= */ "label:\"Code-Review=+2\"",
             /* overrideExpr= */ "label:\"build-cop-override=+1\"");
 
     SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
@@ -206,7 +206,7 @@
     SubmitRequirement sr =
         createSubmitRequirement(
             /* applicabilityExpr= */ "project:" + project.get(),
-            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* submittabilityExpr= */ "label:\"Code-Review=+2\"",
             /* overrideExpr= */ "invalid_field:invalid_value");
 
     SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
new file mode 100644
index 0000000..d8aa789
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
@@ -0,0 +1,455 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.project.ProjectConfig;
+import java.util.Locale;
+import java.util.function.Consumer;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.junit.Test;
+
+/**
+ * Tests validating submit requirements on upload of {@code project.config} to {@code
+ * refs/meta/config}.
+ */
+public class SubmitRequirementsValidationIT extends AbstractDaemonTest {
+  @Test
+  public void validSubmitRequirementIsAccepted_optionalParametersNotSet() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ submitRequirementName,
+                /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+                /* value= */ "label:\"Code-Review=+2\""));
+
+    PushResult r = pushRefsMetaConfig();
+    assertOkStatus(r);
+  }
+
+  @Test
+  public void validSubmitRequirementIsAccepted_allParametersSet() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    updateProjectConfig(
+        projectConfig -> {
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_DESCRIPTION,
+              /* value= */ "foo bar description");
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
+              /* value= */ "branch:refs/heads/master");
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+              /* value= */ "label:\"Code-Review=+2\"");
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+              /* value= */ "label:\"override=+1\"");
+          projectConfig.setBoolean(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+              /* value= */ false);
+        });
+
+    PushResult r = pushRefsMetaConfig();
+    assertOkStatus(r);
+  }
+
+  @Test
+  public void parametersDirectlyInSubmitRequirementsSectionAreRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    updateProjectConfig(
+        projectConfig -> {
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ null,
+              /* name= */ ProjectConfig.KEY_SR_DESCRIPTION,
+              /* value= */ "foo bar description");
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ null,
+              /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+              /* value= */ "label:\"Code-Review=+2\"");
+        });
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Submit requirements must be defined in submit-requirement.<name>"
+                + " subsections. Setting parameters directly in the submit-requirement section is"
+                + " not allowed: [%s, %s]",
+            ProjectConfig.KEY_SR_DESCRIPTION, ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION));
+  }
+
+  @Test
+  public void unsupportedParameterDirectlyInSubmitRequirementsSectionIsRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ null,
+                /* name= */ "unknown",
+                /* value= */ "value"));
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        "project.config: Submit requirements must be defined in submit-requirement.<name>"
+            + " subsections. Setting parameters directly in the submit-requirement section is"
+            + " not allowed: [unknown]");
+  }
+
+  @Test
+  public void unsupportedParameterForSubmitRequirementIsRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ submitRequirementName,
+                /* name= */ "unknown",
+                /* value= */ "value"));
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Unsupported parameters for submit requirement '%s': [unknown]",
+            submitRequirementName));
+  }
+
+  @Test
+  public void conflictingSubmitRequirementsAreRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    updateProjectConfig(
+        projectConfig -> {
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+              /* value= */ "label:\"Code-Review=+2\"");
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName.toLowerCase(Locale.US),
+              /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+              /* value= */ "label:\"Code-Review=+2\"");
+        });
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Submit requirement '%s' conflicts with '%s'.",
+            submitRequirementName.toLowerCase(Locale.US), submitRequirementName));
+  }
+
+  @Test
+  public void conflictingSubmitRequirementIsRejected() throws Exception {
+    fetchRefsMetaConfig();
+    String submitRequirementName = "Code-Review";
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ submitRequirementName,
+                /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+                /* value= */ "label:\"Code-Review=+2\""));
+    PushResult r = pushRefsMetaConfig();
+    assertOkStatus(r);
+
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ submitRequirementName.toLowerCase(Locale.US),
+                /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+                /* value= */ "label:\"Code-Review=+2\""));
+    r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Submit requirement '%s' conflicts with '%s'.",
+            submitRequirementName.toLowerCase(Locale.US), submitRequirementName));
+  }
+
+  @Test
+  public void submitRequirementWithoutSubmittabilityExpressionIsRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ submitRequirementName,
+                /* name= */ ProjectConfig.KEY_SR_DESCRIPTION,
+                /* value= */ "foo bar description"));
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Setting a submittability expression for submit requirement '%s' is"
+                + " required: Missing %s.%s.%s",
+            submitRequirementName,
+            ProjectConfig.SUBMIT_REQUIREMENT,
+            submitRequirementName,
+            ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION));
+  }
+
+  @Test
+  public void submitRequirementWithInvalidSubmittabilityExpressionIsRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    String invalidExpression = "invalid_field:invalid_value";
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ submitRequirementName,
+                /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+                /* value= */ invalidExpression));
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+                + " invalid: Unsupported operator %s",
+            invalidExpression,
+            submitRequirementName,
+            ProjectConfig.SUBMIT_REQUIREMENT,
+            submitRequirementName,
+            ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+            invalidExpression));
+  }
+
+  @Test
+  public void submitRequirementWithInvalidApplicabilityExpressionIsRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    String invalidExpression = "invalid_field:invalid_value";
+    updateProjectConfig(
+        projectConfig -> {
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+              /* value= */ "label:\"Code-Review=+2\"");
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
+              /* value= */ invalidExpression);
+        });
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+                + " invalid: Unsupported operator %s",
+            invalidExpression,
+            submitRequirementName,
+            ProjectConfig.SUBMIT_REQUIREMENT,
+            submitRequirementName,
+            ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
+            invalidExpression));
+  }
+
+  @Test
+  public void submitRequirementWithInvalidOverrideExpressionIsRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    String invalidExpression = "invalid_field:invalid_value";
+    updateProjectConfig(
+        projectConfig -> {
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+              /* value= */ "label:\"Code-Review=+2\"");
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+              /* value= */ invalidExpression);
+        });
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Expression '%s' of submit requirement '%s' (parameter %s.%s.%s) is"
+                + " invalid: Unsupported operator %s",
+            invalidExpression,
+            submitRequirementName,
+            ProjectConfig.SUBMIT_REQUIREMENT,
+            submitRequirementName,
+            ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+            invalidExpression));
+  }
+
+  @Test
+  public void submitRequirementWithInvalidAllowOverrideInChildProjectsIsRejected()
+      throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    String invalidValue = "invalid";
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ submitRequirementName,
+                /* name= */ ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+                /* value= */ invalidValue));
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Invalid value %s.%s.%s for submit requirement '%s': %s",
+            ProjectConfig.SUBMIT_REQUIREMENT,
+            submitRequirementName,
+            ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+            submitRequirementName,
+            invalidValue));
+  }
+
+  private void fetchRefsMetaConfig() throws Exception {
+    fetch(testRepo, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
+    testRepo.reset(RefNames.REFS_CONFIG);
+  }
+
+  private PushResult pushRefsMetaConfig() throws Exception {
+    return pushHead(testRepo, RefNames.REFS_CONFIG);
+  }
+
+  private void updateProjectConfig(Consumer<Config> configUpdater) throws Exception {
+    RevCommit head = getHead(testRepo.getRepository(), RefNames.REFS_CONFIG);
+    Config projectConfig = readProjectConfig(head);
+    configUpdater.accept(projectConfig);
+    RevCommit commit =
+        testRepo.update(
+            RefNames.REFS_CONFIG,
+            testRepo
+                .commit()
+                .parent(head)
+                .message("Update project config")
+                .author(admin.newIdent())
+                .committer(admin.newIdent())
+                .add(ProjectConfig.PROJECT_CONFIG, projectConfig.toText()));
+
+    testRepo.reset(commit);
+  }
+
+  private Config readProjectConfig(RevCommit commit) throws Exception {
+    try (TreeWalk tw =
+        TreeWalk.forPath(
+            testRepo.getRevWalk().getObjectReader(),
+            ProjectConfig.PROJECT_CONFIG,
+            commit.getTree())) {
+      if (tw == null) {
+        throw new IllegalStateException(
+            String.format("%s does not exist", ProjectConfig.PROJECT_CONFIG));
+      }
+    }
+    RevObject blob = testRepo.get(commit.getTree(), ProjectConfig.PROJECT_CONFIG);
+    byte[] data = testRepo.getRepository().open(blob).getCachedBytes(Integer.MAX_VALUE);
+    String content = RawParseUtils.decode(data);
+
+    Config projectConfig = new Config();
+    projectConfig.fromText(content);
+    return projectConfig;
+  }
+
+  public void assertOkStatus(PushResult result) {
+    RemoteRefUpdate refUpdate = result.getRemoteUpdate(RefNames.REFS_CONFIG);
+    assertThat(refUpdate).isNotNull();
+    assertWithMessage(getMessage(result, refUpdate))
+        .that(refUpdate.getStatus())
+        .isEqualTo(Status.OK);
+  }
+
+  public void assertErrorStatus(PushResult result, String... expectedMessages) {
+    RemoteRefUpdate refUpdate = result.getRemoteUpdate(RefNames.REFS_CONFIG);
+    assertThat(refUpdate).isNotNull();
+    assertWithMessage(getMessage(result, refUpdate))
+        .that(refUpdate.getStatus())
+        .isEqualTo(Status.REJECTED_OTHER_REASON);
+    for (String expectedMessage : expectedMessages) {
+      assertThat(result.getMessages()).contains(expectedMessage);
+    }
+  }
+
+  private String getMessage(PushResult result, RemoteRefUpdate refUpdate) {
+    StringBuilder b = new StringBuilder();
+    if (refUpdate.getMessage() != null) {
+      b.append(refUpdate.getMessage());
+      b.append("\n");
+    }
+    b.append(result.getMessages());
+    return b.toString();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
index a1dee1a..6993dfe 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
@@ -26,7 +26,7 @@
 public class SubmitRequirementSerializerTest {
   private static final SubmitRequirement submitReq =
       SubmitRequirement.builder()
-          .setName("code-review")
+          .setName("Code-Review")
           .setDescription(Optional.of("require code review +2"))
           .setApplicabilityExpression(SubmitRequirementExpression.of("branch(refs/heads/master)"))
           .setSubmittabilityExpression(SubmitRequirementExpression.create("label(code-review, 2+)"))
diff --git a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index 66a98e8..67b0342 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -56,6 +56,7 @@
             new Config(),
             null,
             null,
+            null,
             null));
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 61002f9..0c26f1a 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -696,7 +696,7 @@
                                 .setApplicabilityExpression(
                                     SubmitRequirementExpression.of("project:foo"))
                                 .setSubmittabilityExpression(
-                                    SubmitRequirementExpression.create("label:code-review=+2"))
+                                    SubmitRequirementExpression.create("label:Code-Review=+2"))
                                 .setAllowOverrideInChildProjects(false)
                                 .build())
                         .applicabilityExpressionResult(
@@ -708,10 +708,10 @@
                                     ImmutableList.of())))
                         .submittabilityExpressionResult(
                             SubmitRequirementExpressionResult.create(
-                                SubmitRequirementExpression.create("label:code-review=+2"),
+                                SubmitRequirementExpression.create("label:Code-Review=+2"),
                                 SubmitRequirementExpressionResult.Status.FAIL,
                                 ImmutableList.of(),
-                                ImmutableList.of("label:code-review=+2")))
+                                ImmutableList.of("label:Code-Review=+2")))
                         .build()))
             .build(),
         newProtoBuilder()
@@ -726,7 +726,7 @@
                         SubmitRequirementProto.newBuilder()
                             .setName("Code-Review")
                             .setApplicabilityExpression("project:foo")
-                            .setSubmittabilityExpression("label:code-review=+2")
+                            .setSubmittabilityExpression("label:Code-Review=+2")
                             .setAllowOverrideInChildProjects(false)
                             .build())
                     .setApplicabilityExpressionResult(
@@ -737,9 +737,9 @@
                             .build())
                     .setSubmittabilityExpressionResult(
                         SubmitRequirementExpressionResultProto.newBuilder()
-                            .setExpression("label:code-review=+2")
+                            .setExpression("label:Code-Review=+2")
                             .setStatus("FAIL")
-                            .addFailingAtoms("label:code-review=+2")
+                            .addFailingAtoms("label:Code-Review=+2")
                             .build())
                     .build())
             .build());
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 7f0b685..aed1648 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -217,7 +217,7 @@
                 "[submit-requirement \"Code-review\"]\n"
                     + "  description =  At least one Code Review +2\n"
                     + "  applicableIf =branch(refs/heads/master)\n"
-                    + "  submittableIf =  label(code-review, +2)\n"
+                    + "  submittableIf =  label(Code-Review, +2)\n"
                     + "[submit-requirement \"api-review\"]\n"
                     + "  description =  Additional review required for API modifications\n"
                     + "  applicableIf =commit_filepath_contains(\\\"/api/.*\\\")\n"
@@ -237,7 +237,7 @@
                 .setApplicabilityExpression(
                     SubmitRequirementExpression.of("branch(refs/heads/master)"))
                 .setSubmittabilityExpression(
-                    SubmitRequirementExpression.create("label(code-review, +2)"))
+                    SubmitRequirementExpression.create("label(Code-Review, +2)"))
                 .setOverrideExpression(Optional.empty())
                 .setAllowOverrideInChildProjects(false)
                 .build(),
@@ -262,19 +262,19 @@
             .add("groups", group(developers))
             .add(
                 "project.config",
-                "[submit-requirement \"code-review\"]\n"
-                    + "  submittableIf =  label(code-review, +2)\n")
+                "[submit-requirement \"Code-Review\"]\n"
+                    + "  submittableIf =  label(Code-Review, +2)\n")
             .create();
 
     ProjectConfig cfg = read(rev);
     Map<String, SubmitRequirement> submitRequirements = cfg.getSubmitRequirementSections();
     assertThat(submitRequirements)
         .containsExactly(
-            "code-review",
+            "Code-Review",
             SubmitRequirement.builder()
-                .setName("code-review")
+                .setName("Code-Review")
                 .setSubmittabilityExpression(
-                    SubmitRequirementExpression.create("label(code-review, +2)"))
+                    SubmitRequirementExpression.create("label(Code-Review, +2)"))
                 .setAllowOverrideInChildProjects(false)
                 .build());
   }
@@ -310,9 +310,7 @@
     assertThat(cfg.getValidationErrors()).hasSize(1);
     assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
         .isEqualTo(
-            "project.config: "
-                + "Submit requirement \"Code-Review\" conflicts with \"code-review\". "
-                + "Skipping the former.");
+            "project.config: Submit requirement 'Code-Review' conflicts with 'code-review'.");
   }
 
   @Test
@@ -322,8 +320,8 @@
             .add("groups", group(developers))
             .add(
                 "project.config",
-                "[submit-requirement \"code-review\"]\n"
-                    + "  applicableIf =label(code-review, +2)\n")
+                "[submit-requirement \"Code-Review\"]\n"
+                    + "  applicableIf =label(Code-Review, +2)\n")
             .create();
 
     ProjectConfig cfg = read(rev);
@@ -332,8 +330,9 @@
     assertThat(cfg.getValidationErrors()).hasSize(1);
     assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
         .isEqualTo(
-            "project.config: Submit requirement \"code-review\" does not define a submittability"
-                + " expression. Skipping this requirement.");
+            "project.config: Setting a submittability expression for submit requirement"
+                + " 'Code-Review' is required: Missing"
+                + " submit-requirement.Code-Review.submittableIf");
   }
 
   @Test
@@ -954,10 +953,10 @@
         tr.commit()
             .add(
                 "project.config",
-                "[submit-requirement \"code-review\"]\n"
+                "[submit-requirement \"Code-Review\"]\n"
                     + "  description =  At least one Code Review +2\n"
                     + "  applicableIf =branch(refs/heads/master)\n"
-                    + "  submittableIf =  label(code-review, +2)\n"
+                    + "  submittableIf =  label(Code-Review, +2)\n"
                     + "[notify \"name\"]\n"
                     + "  email = example@example.com\n")
             .create();
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 2c6dd66..3b671aa 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -111,6 +111,7 @@
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
@@ -601,6 +602,29 @@
   }
 
   @Test
+  public void byUploader() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.UPLOADER)).isTrue();
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+    CurrentUser user2CurrentUser = userFactory.create(user2);
+
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+    assertQuery("is:uploader", change1);
+    assertQuery("uploader:" + userId.get(), change1);
+    change1 = newPatchSet(repo, change1, user2CurrentUser);
+    // Uploader has changed
+    assertQuery("uploader:" + userId.get());
+    assertQuery("uploader:" + user2.get(), change1);
+
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("is:uploader", change1); // self (user2)
+
+    String nameEmail = user2CurrentUser.asIdentifiedUser().getNameEmail();
+    assertQuery("uploader: \"" + nameEmail + "\"", change1);
+  }
+
+  @Test
   public void byAuthorExact() throws Exception {
     assume().that(getSchema().hasField(ChangeField.EXACT_AUTHOR)).isTrue();
     byAuthorOrCommitterExact("author:");
@@ -716,6 +740,20 @@
   }
 
   @Test
+  public void byUploaderIn() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.UPLOADER)).isTrue();
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+    assertQuery("uploaderin:Administrators", change1);
+
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+    CurrentUser user2CurrentUser = userFactory.create(user2);
+    newPatchSet(repo, change1, user2CurrentUser);
+    assertQuery("uploaderin:Administrators");
+  }
+
+  @Test
   public void byProject() throws Exception {
     TestRepository<Repo> repo1 = createProject("repo1");
     TestRepository<Repo> repo2 = createProject("repo2");
@@ -2321,7 +2359,21 @@
   }
 
   @Test
-  public void byHasDraft() throws Exception {
+  public void byHasDraft_draftsComputedFromIndex() throws Exception {
+    byHasDraft();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
+  public void byHasDraft_draftsComputedFromAllUsersRepository() throws Exception {
+    byHasDraft();
+  }
+
+  private void byHasDraft() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
@@ -2349,7 +2401,11 @@
     assertQuery("has:draft");
   }
 
-  @Test
+  /**
+   * This test does not have a test about drafts computed from All-Users Repository because zombie
+   * drafts can't be filtered when computing from All-Users repository. TODO(paiking): During
+   * rollout, we should find a way to fix zombie drafts.
+   */
   public void byHasDraftExcludesZombieDrafts() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
     TestRepository<Repo> repo = createProject(project.get());
@@ -2387,8 +2443,62 @@
     assertQuery("has:draft");
   }
 
+  public void byHasDraftWithManyDrafts_draftsComputedFromIndex() throws Exception {
+    byHasDraftWithManyDrafts();
+  }
+
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
+  public void byHasDraftWithManyDrafts_draftsComputedFromAllUsersRepository() throws Exception {
+    byHasDraftWithManyDrafts();
+  }
+
+  private void byHasDraftWithManyDrafts() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change[] changesWithDrafts = new Change[30];
+
+    // unrelated change not shown in the result.
+    insert(repo, newChange(repo));
+
+    for (int i = 0; i < changesWithDrafts.length; i++) {
+      // put the changes in reverse order since this is the order we receive them from the index.
+      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert(repo, newChange(repo));
+      DraftInput in = new DraftInput();
+      in.line = 1;
+      in.message = "nit: trailing whitespace";
+      in.path = Patch.COMMIT_MSG;
+      gApi.changes()
+          .id(changesWithDrafts[changesWithDrafts.length - 1 - i].getId().get())
+          .current()
+          .createDraft(in);
+    }
+    assertQuery("has:draft", changesWithDrafts);
+
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("has:draft");
+  }
+
   @Test
-  public void byStarredBy() throws Exception {
+  public void byStarredBy_starsComputedFromIndex() throws Exception {
+    byStarredBy();
+  }
+
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
+  @Test
+  public void byStarredBy_starsComputedFromAllUsersRepository() throws Exception {
+    byStarredBy();
+  }
+
+  private void byStarredBy() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
@@ -2409,7 +2519,21 @@
   }
 
   @Test
-  public void byStar() throws Exception {
+  public void byStar_starsComputedFromIndex() throws Exception {
+    byStar();
+  }
+
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
+  @Test
+  public void byStar_starsComputedFromAllUsersRepository() throws Exception {
+    byStar();
+  }
+
+  private void byStar() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
     Change change2 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
@@ -2435,7 +2559,21 @@
   }
 
   @Test
-  public void byIgnore() throws Exception {
+  public void byIgnore_starsComputedFromIndex() throws Exception {
+    byIgnore();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
+  public void byIgnore_starsComputedFromAllUsersRepository() throws Exception {
+    byIgnore();
+  }
+
+  private void byIgnore() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Account.Id user2 =
         accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
@@ -2455,6 +2593,42 @@
     assertQuery("-star:ignore", change2, change1);
   }
 
+  public void byStarWithManyStars_starsComputedFromIndex() throws Exception {
+    byStarWithManyStars();
+  }
+
+  @GerritConfig(
+      name = "experiments.enabled",
+      value =
+          ExperimentFeaturesConstants
+              .GERRIT_BACKEND_REQUEST_FEATURE_COMPUTE_FROM_ALL_USERS_REPOSITORY)
+  public void byStarWithManyStars_starsComputedFromAllUsersRepository() throws Exception {
+    byStarWithManyStars();
+  }
+
+  private void byStarWithManyStars() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change[] changesWithDrafts = new Change[30];
+    for (int i = 0; i < changesWithDrafts.length; i++) {
+      // put the changes in reverse order since this is the order we receive them from the index.
+      changesWithDrafts[changesWithDrafts.length - 1 - i] = insert(repo, newChange(repo));
+
+      // star the change
+      gApi.accounts()
+          .self()
+          .starChange(changesWithDrafts[changesWithDrafts.length - 1 - i].getId().toString());
+
+      // ignore the change
+      gApi.changes()
+          .id(changesWithDrafts[changesWithDrafts.length - 1 - i].getId().toString())
+          .ignore(true);
+    }
+
+    // all changes are both starred and ignored.
+    assertQuery("is:ignored", changesWithDrafts);
+    assertQuery("is:starred", changesWithDrafts);
+  }
+
   @Test
   public void byFrom() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
@@ -2585,7 +2759,7 @@
     gApi.changes().id(change2.getId().get()).current().review(new ReviewInput().message("comment"));
 
     PatchSet.Id ps3_1 = change3.currentPatchSetId();
-    change3 = newPatchSet(repo, change3);
+    change3 = newPatchSet(repo, change3, user);
     assertThat(change3.currentPatchSetId()).isNotEqualTo(ps3_1);
     // Response to previous patch set still counts as reviewing.
     gApi.changes()
@@ -3891,7 +4065,8 @@
     }
   }
 
-  protected Change newPatchSet(TestRepository<Repo> repo, Change c) throws Exception {
+  protected Change newPatchSet(TestRepository<Repo> repo, Change c, CurrentUser user)
+      throws Exception {
     // Add a new file so the patch set is not a trivial rebase, to avoid default
     // Code-Review label copying.
     int n = c.currentPatchSetId().get() + 1;
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index f86e825..39b40b6 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -1081,6 +1081,7 @@
   applicability_expression_result?: SubmitRequirementExpressionInfo;
   submittability_expression_result: SubmitRequirementExpressionInfo;
   override_expression_result?: SubmitRequirementExpressionInfo;
+  is_legacy?: boolean;
 }
 
 /**
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 645e770..6ff2894 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -256,7 +256,6 @@
 export function createDefaultPreferences() {
   return {
     changes_per_page: 25,
-    default_diff_view: DiffViewMode.SIDE_BY_SIDE,
     diff_view: DiffViewMode.SIDE_BY_SIDE,
     size_bar_in_change_table: true,
   } as PreferencesInfo;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-column/gr-change-list-column.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-column/gr-change-list-column.ts
new file mode 100644
index 0000000..2b9abfd
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-column/gr-change-list-column.ts
@@ -0,0 +1,87 @@
+/**
+ * @license
+ * Copyright (C) 2015 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 '../../change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {ChangeInfo, SubmitRequirementStatus} from '../../../api/rest-api';
+import {changeIsMerged} from '../../../utils/change-util';
+
+@customElement('gr-change-list-column-requirements')
+export class GrChangeListColumRequirements extends LitElement {
+  @property({type: Object})
+  change?: ChangeInfo;
+
+  static override get styles() {
+    return [
+      css`
+        iron-icon {
+          width: var(--line-height-normal, 20px);
+          height: var(--line-height-normal, 20px);
+          vertical-align: top;
+        }
+        span {
+          line-height: var(--line-height-normal);
+        }
+        .check {
+          color: var(--success-foreground);
+        }
+        iron-icon.close {
+          color: var(--error-foreground);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (changeIsMerged(this.change)) {
+      return this.renderState('check', 'Merged');
+    }
+
+    const submitRequirements = (this.change?.submit_requirements ?? []).filter(
+      req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE
+    );
+    if (!submitRequirements.length) return html`n/a`;
+    const numOfRequirements = submitRequirements.length;
+    const numOfSatisfiedRequirements = submitRequirements.filter(
+      req => req.status === SubmitRequirementStatus.SATISFIED
+    ).length;
+
+    if (numOfSatisfiedRequirements === numOfRequirements) {
+      return this.renderState('check', 'Ready');
+    }
+    return this.renderState(
+      'close',
+      `${numOfSatisfiedRequirements} of ${numOfRequirements} granted`
+    );
+  }
+
+  renderState(icon: string, message: string) {
+    return html`<span class="${icon}"
+      ><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
+      >${message}</span
+    >`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list-column-requirements': GrChangeListColumRequirements;
+  }
+}
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 c476d2d..43f6730 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
@@ -26,6 +26,8 @@
 import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../gr-change-list-column/gr-change-list-column';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-list-item_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
index a0aa962..c59b2e3 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
@@ -290,6 +290,13 @@
       </template>
     </gr-tooltip-content>
   </td>
+  <td
+    class="cell requirements"
+    hidden$="[[_computeIsColumnHidden('Requirements', visibleChangeTableColumns)]]"
+  >
+    <gr-change-list-column-requirements change="[[change]]">
+    </gr-change-list-column-requirements>
+  </td>
   <template is="dom-repeat" items="[[labelNames]]" as="labelName">
     <td
       title$="[[_computeLabelTitle(change, labelName)]]"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
index 34cb6eb..35be3de 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -369,6 +369,7 @@
       'Branch',
       'Updated',
       'Size',
+      'Requirements',
     ];
 
     await flush();
@@ -392,6 +393,7 @@
       'Branch',
       'Updated',
       'Size',
+      'Requirements',
     ];
 
     await flush();
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 a3db699..66cef20 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
@@ -27,6 +27,7 @@
 import {
   KeyboardShortcutMixin,
   Shortcut,
+  ShortcutListener,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {
   GerritNav,
@@ -46,10 +47,10 @@
   PreferencesInput,
 } from '../../../types/common';
 import {hasAttention} from '../../../utils/attention-set-util';
-import {IronKeyboardEvent} from '../../../types/events';
 import {fireEvent, fireReload} from '../../../utils/event-util';
-import {isShiftPressed, modifierPressed} from '../../../utils/dom-util';
 import {ScrollMode} from '../../../constants/constants';
+import {listen} from '../../../services/shortcuts/shortcuts-service';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 const NUMBER_FIXED_COLUMNS = 3;
 const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
@@ -67,6 +68,7 @@
   'Branch',
   'Updated',
   'Size',
+  'Requirements',
 ];
 
 export interface ChangeListSection {
@@ -135,9 +137,6 @@
   @property({type: Boolean})
   showReviewedState = false;
 
-  @property({type: Object})
-  keyEventTarget: HTMLElement = document.body;
-
   @property({type: Array})
   changeTableColumns?: string[];
 
@@ -157,19 +156,19 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  private readonly shortcuts = appContext.shortcutsService;
-
-  override keyboardShortcuts() {
-    return {
-      [Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
-      [Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
-      [Shortcut.NEXT_PAGE]: '_nextPage',
-      [Shortcut.PREV_PAGE]: '_prevPage',
-      [Shortcut.OPEN_CHANGE]: '_openChange',
-      [Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
-      [Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
-      [Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
-    };
+  override keyboardShortcuts(): ShortcutListener[] {
+    return [
+      listen(Shortcut.CURSOR_NEXT_CHANGE, _ => this._nextChange()),
+      listen(Shortcut.CURSOR_PREV_CHANGE, _ => this._prevChange()),
+      listen(Shortcut.NEXT_PAGE, _ => this._nextPage()),
+      listen(Shortcut.PREV_PAGE, _ => this._prevPage()),
+      listen(Shortcut.OPEN_CHANGE, _ => this.openChange()),
+      listen(Shortcut.TOGGLE_CHANGE_REVIEWED, _ =>
+        this._toggleChangeReviewed()
+      ),
+      listen(Shortcut.TOGGLE_CHANGE_STAR, _ => this._toggleChangeStar()),
+      listen(Shortcut.REFRESH_CHANGE_LIST, _ => this._refreshChangeList()),
+    ];
   }
 
   private cursor = new GrCursorManager();
@@ -204,7 +203,7 @@
   }
 
   /**
-   * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
+   * shortcut-service catches keyboard events globally. Some keyboard
    * events must be scoped to a component level (e.g. `enter`) in order to not
    * override native browser functionality.
    *
@@ -213,7 +212,7 @@
   _scopedKeydownHandler(e: KeyboardEvent) {
     if (e.keyCode === 13) {
       // Enter.
-      this.openChange(e);
+      this.openChange();
     }
   }
 
@@ -265,6 +264,8 @@
     if (!config || !config.change) return true;
     if (column === 'Assignee') return !!config.change.enable_assignee;
     if (column === 'Comments') return experiments.includes('comments-column');
+    if (column === 'Requirements')
+      return experiments.includes(KnownExperimentId.SUBMIT_REQUIREMENTS_UI);
     return true;
   }
 
@@ -406,63 +407,30 @@
     );
   }
 
-  _nextChange(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _nextChange() {
     this.isCursorMoving = true;
     this.cursor.next();
     this.isCursorMoving = false;
     this.selectedIndex = this.cursor.index;
   }
 
-  _prevChange(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _prevChange() {
     this.isCursorMoving = true;
     this.cursor.previous();
     this.isCursorMoving = false;
     this.selectedIndex = this.cursor.index;
   }
 
-  _openChange(e: IronKeyboardEvent) {
-    if (this.shortcuts.modifierPressed(e)) return;
-    this.openChange(e.detail.keyboardEvent);
-  }
-
-  openChange(e: KeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || modifierPressed(e)) return;
-    e.preventDefault();
+  openChange() {
     const change = this._changeForIndex(this.selectedIndex);
     if (change) GerritNav.navigateToChange(change);
   }
 
-  _nextPage(e: IronKeyboardEvent) {
-    if (
-      this.shortcuts.shouldSuppress(e) ||
-      (this.shortcuts.modifierPressed(e) && !isShiftPressed(e))
-    ) {
-      return;
-    }
-
-    e.preventDefault();
+  _nextPage() {
     fireEvent(this, 'next-page');
   }
 
-  _prevPage(e: IronKeyboardEvent) {
-    if (
-      this.shortcuts.shouldSuppress(e) ||
-      (this.shortcuts.modifierPressed(e) && !isShiftPressed(e))
-    ) {
-      return;
-    }
-
-    e.preventDefault();
+  _prevPage() {
     this.dispatchEvent(
       new CustomEvent('previous-page', {
         composed: true,
@@ -471,12 +439,7 @@
     );
   }
 
-  _toggleChangeReviewed(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _toggleChangeReviewed() {
     this._toggleReviewedForIndex(this.selectedIndex);
   }
 
@@ -490,21 +453,11 @@
     changeEl.toggleReviewed();
   }
 
-  _refreshChangeList(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _refreshChangeList() {
     fireReload(this);
   }
 
-  _toggleChangeStar(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _toggleChangeStar() {
     this._toggleStarForIndex(this.selectedIndex);
   }
 
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
index 4956380..472435c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
@@ -147,38 +147,37 @@
     await flush();
     const promise = mockPromise();
     afterNextRender(element, () => {
-      const elementItems = element.root.querySelectorAll(
-          'gr-change-list-item');
-      assert.equal(elementItems.length, 3);
-
-      assert.isTrue(elementItems[0].hasAttribute('selected'));
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      assert.equal(element.selectedIndex, 1);
-      assert.isTrue(elementItems[1].hasAttribute('selected'));
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      assert.equal(element.selectedIndex, 2);
-      assert.isTrue(elementItems[2].hasAttribute('selected'));
-
-      const navStub = sinon.stub(GerritNav, 'navigateToChange');
-      assert.equal(element.selectedIndex, 2);
-      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-      assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
-          'Should navigate to /c/2/');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-      assert.equal(element.selectedIndex, 1);
-      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-      assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
-          'Should navigate to /c/1/');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-      assert.equal(element.selectedIndex, 0);
-
       promise.resolve();
     });
     await promise;
+    const elementItems = element.root.querySelectorAll(
+        'gr-change-list-item');
+    assert.equal(elementItems.length, 3);
+
+    assert.isTrue(elementItems[0].hasAttribute('selected'));
+    MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+    assert.equal(element.selectedIndex, 1);
+    assert.isTrue(elementItems[1].hasAttribute('selected'));
+    MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+    assert.equal(element.selectedIndex, 2);
+    assert.isTrue(elementItems[2].hasAttribute('selected'));
+
+    const navStub = sinon.stub(GerritNav, 'navigateToChange');
+    assert.equal(element.selectedIndex, 2);
+    MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
+    assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
+        'Should navigate to /c/2/');
+
+    MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+    assert.equal(element.selectedIndex, 1);
+    MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
+    assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
+        'Should navigate to /c/1/');
+
+    MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+    MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+    MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+    assert.equal(element.selectedIndex, 0);
   });
 
   test('no changes', () => {
@@ -283,6 +282,7 @@
           'Branch',
           'Updated',
           'Size',
+          'Requirements',
         ],
       };
       element._config = {};
@@ -320,6 +320,7 @@
           'Branch',
           'Updated',
           'Size',
+          'Requirements',
         ],
       };
       element._config = {};
@@ -449,45 +450,45 @@
       await flush();
       const promise = mockPromise();
       afterNextRender(element, () => {
-        const elementItems = element.root.querySelectorAll(
-            'gr-change-list-item');
-        assert.equal(elementItems.length, 9);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-        assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-
-        const navStub = sinon.stub(GerritNav, 'navigateToChange');
-        assert.equal(element.selectedIndex, 2);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
-        assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
-            'Should navigate to /c/2/');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k'
-        assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
-        assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
-            'Should navigate to /c/1/');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-        assert.equal(element.selectedIndex, 4);
-        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
-        assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
-            'Should navigate to /c/4/');
-
-        MockInteractions.keyUpOn(element, 82); // 'r'
-        const change = element._changeForIndex(element.selectedIndex);
-        assert.equal(change.reviewed, true,
-            'Should mark change as reviewed');
-        MockInteractions.keyUpOn(element, 82); // 'r'
-        assert.equal(change.reviewed, false,
-            'Should mark change as unreviewed');
         promise.resolve();
       });
       await promise;
+      const elementItems = element.root.querySelectorAll(
+          'gr-change-list-item');
+      assert.equal(elementItems.length, 9);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      assert.equal(element.selectedIndex, 1);
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+
+      const navStub = sinon.stub(GerritNav, 'navigateToChange');
+      assert.equal(element.selectedIndex, 2);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
+      assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
+          'Should navigate to /c/2/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+      assert.equal(element.selectedIndex, 1);
+      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
+      assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
+          'Should navigate to /c/1/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      assert.equal(element.selectedIndex, 4);
+      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
+      assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
+          'Should navigate to /c/4/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+      const change = element._changeForIndex(element.selectedIndex);
+      assert.equal(change.reviewed, true,
+          'Should mark change as reviewed');
+      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+      assert.equal(change.reviewed, false,
+          'Should mark change as unreviewed');
     });
 
     test('_computeItemHighlight gives false for null account', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 891482d..b10c17e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -95,9 +95,9 @@
 } from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
 import {fireAlert, fireEvent, fireReload} from '../../../utils/event-util';
 import {
-  CODE_REVIEW,
   getApprovalInfo,
   getVotingRange,
+  StandardLabels,
 } from '../../../utils/label-util';
 import {CommentThread} from '../../../utils/comment-util';
 import {ShowAlertEventDetail} from '../../../types/events';
@@ -975,8 +975,9 @@
     // Allow the user to use quick approve to vote the max score on code review
     // even if it is already granted by someone else. Does not apply if the
     // user owns the change or has already granted the max score themselves.
-    const codeReviewLabel = this.change.labels[CODE_REVIEW];
-    const codeReviewPermittedValues = this.change.permitted_labels[CODE_REVIEW];
+    const codeReviewLabel = this.change.labels[StandardLabels.CODE_REVIEW];
+    const codeReviewPermittedValues =
+      this.change.permitted_labels[StandardLabels.CODE_REVIEW];
     if (
       !result &&
       codeReviewLabel &&
@@ -988,7 +989,7 @@
       getApprovalInfo(codeReviewLabel, this.account)?.value !==
         getVotingRange(codeReviewLabel)?.max
     ) {
-      result = CODE_REVIEW;
+      result = StandardLabels.CODE_REVIEW;
     }
 
     if (result) {
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 97101f6..25dab25 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
@@ -256,7 +256,6 @@
             id="assigneeValue"
             placeholder="Set assignee..."
             max-count="1"
-            skip-suggest-on-empty=""
             accounts="{{_assignee}}"
             readonly="[[_computeAssigneeReadOnly(_mutable, change)]]"
             suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]"
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 c19be58e..1a4b7a0 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
@@ -48,11 +48,12 @@
 import {
   KeyboardShortcutMixin,
   Shortcut,
+  ShortcutListener,
   ShortcutSection,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {pluralize} from '../../../utils/string-util';
-import {windowLocationReload, querySelectorAll} from '../../../utils/dom-util';
+import {querySelectorAll, windowLocationReload} from '../../../utils/dom-util';
 import {
   GeneratedWebLink,
   GerritNav,
@@ -62,8 +63,8 @@
 import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
 import {DiffViewMode} from '../../../api/diff';
 import {
-  DefaultBase,
   ChangeStatus,
+  DefaultBase,
   PrimaryTab,
   SecondaryTab,
 } from '../../../constants/constants';
@@ -83,9 +84,9 @@
   changeIsOpen,
   changeStatuses,
   isCc,
+  isInvolved,
   isOwner,
   isReviewer,
-  isInvolved,
 } from '../../../utils/change-util';
 import {EventType as PluginEventType} from '../../../api/plugin';
 import {customElement, observe, property} from '@polymer/decorators';
@@ -158,9 +159,7 @@
   ParsedChangeInfo,
 } from '../../../types/types';
 import {
-  IronKeyboardEventListener,
   CloseFixPreviewEvent,
-  IronKeyboardEvent,
   EditableContentSaveEvent,
   EventType,
   OpenFixPreviewEvent,
@@ -192,10 +191,12 @@
   drafts$,
 } from '../../../services/comments/comments-model';
 import {
-  hasAttention,
   getAddedByReason,
   getRemovedByReason,
+  hasAttention,
 } from '../../../utils/attention-set-util';
+import {listen} from '../../../services/shortcuts/shortcuts-service';
+import {preferenceDiffViewMode$} from '../../../services/user/user-model';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
@@ -295,9 +296,6 @@
   @property({type: Boolean})
   hasParent?: boolean;
 
-  @property({type: Object})
-  keyEventTarget = document.body;
-
   @property({type: Boolean})
   disableEdit = false;
 
@@ -529,7 +527,7 @@
   @property({type: Boolean})
   _showRobotCommentsButton = false;
 
-  _throttledToggleChangeStar?: IronKeyboardEventListener;
+  _throttledToggleChangeStar?: (e: KeyboardEvent) => void;
 
   @property({type: Boolean})
   _showChecksTab = false;
@@ -560,28 +558,50 @@
 
   private replyDialogResizeObserver?: ResizeObserver;
 
-  override keyboardShortcuts() {
-    return {
-      [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
-      [Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
-      [Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
-      [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
-      [Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialogShortcut',
-      [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
-      [Shortcut.TOGGLE_CHANGE_STAR]: '_throttledToggleChangeStar',
-      [Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
-      [Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
-      [Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
-      [Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
-      [Shortcut.EDIT_TOPIC]: '_handleEditTopic',
-      [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
-      [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
-      [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
-      [Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
-      [Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
-      [Shortcut.OPEN_SUBMIT_DIALOG]: '_handleOpenSubmitDialog',
-      [Shortcut.TOGGLE_ATTENTION_SET]: '_handleToggleAttentionSet',
-    };
+  override keyboardShortcuts(): ShortcutListener[] {
+    return [
+      listen(Shortcut.SEND_REPLY, _ => {}), // docOnly
+      listen(Shortcut.EMOJI_DROPDOWN, _ => {}), // docOnly
+      listen(Shortcut.REFRESH_CHANGE, _ => fireReload(this, true)),
+      listen(Shortcut.OPEN_REPLY_DIALOG, _ => this._handleOpenReplyDialog()),
+      listen(Shortcut.OPEN_DOWNLOAD_DIALOG, _ =>
+        this._handleOpenDownloadDialog()
+      ),
+      listen(Shortcut.TOGGLE_DIFF_MODE, _ => this._handleToggleDiffMode()),
+      listen(Shortcut.TOGGLE_CHANGE_STAR, e => {
+        if (this._throttledToggleChangeStar) {
+          this._throttledToggleChangeStar(e);
+        }
+      }),
+      listen(Shortcut.UP_TO_DASHBOARD, _ => this._determinePageBack()),
+      listen(Shortcut.EXPAND_ALL_MESSAGES, _ =>
+        this._handleExpandAllMessages()
+      ),
+      listen(Shortcut.COLLAPSE_ALL_MESSAGES, _ =>
+        this._handleCollapseAllMessages()
+      ),
+      listen(Shortcut.OPEN_DIFF_PREFS, _ =>
+        this._handleOpenDiffPrefsShortcut()
+      ),
+      listen(Shortcut.EDIT_TOPIC, _ => this.$.metadata.editTopic()),
+      listen(Shortcut.DIFF_AGAINST_BASE, _ => this._handleDiffAgainstBase()),
+      listen(Shortcut.DIFF_AGAINST_LATEST, _ =>
+        this._handleDiffAgainstLatest()
+      ),
+      listen(Shortcut.DIFF_BASE_AGAINST_LEFT, _ =>
+        this._handleDiffBaseAgainstLeft()
+      ),
+      listen(Shortcut.DIFF_RIGHT_AGAINST_LATEST, _ =>
+        this._handleDiffRightAgainstLatest()
+      ),
+      listen(Shortcut.DIFF_BASE_AGAINST_LATEST, _ =>
+        this._handleDiffBaseAgainstLatest()
+      ),
+      listen(Shortcut.OPEN_SUBMIT_DIALOG, _ => this._handleOpenSubmitDialog()),
+      listen(Shortcut.TOGGLE_ATTENTION_SET, _ =>
+        this._handleToggleAttentionSet()
+      ),
+    ];
   }
 
   disconnected$ = new Subject();
@@ -592,6 +612,10 @@
 
   private lastStarredTimestamp?: number;
 
+  private readonly userService = appContext.userService;
+
+  private diffViewMode?: DiffViewMode;
+
   override ready() {
     super.ready();
     aPluginHasRegistered$.pipe(takeUntil(this.disconnected$)).subscribe(b => {
@@ -603,6 +627,11 @@
     drafts$.pipe(takeUntil(this.disconnected$)).subscribe(drafts => {
       this._diffDrafts = {...drafts};
     });
+    preferenceDiffViewMode$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(diffViewMode => {
+        this.diffViewMode = diffViewMode;
+      });
     changeComments$
       .pipe(takeUntil(this.disconnected$))
       .subscribe(changeComments => {
@@ -632,8 +661,8 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    this._throttledToggleChangeStar = throttleWrap<IronKeyboardEvent>(e =>
-      this._handleToggleChangeStar(e)
+    this._throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ =>
+      this._handleToggleChangeStar()
     );
     this._getServerConfig().then(config => {
       this._serverConfig = config;
@@ -647,7 +676,6 @@
           this._account = acct;
         });
       }
-      this._setDiffViewMode();
     });
 
     this.replyDialogResizeObserver = new ResizeObserver(() =>
@@ -716,24 +744,6 @@
     return this.shadowRoot!.querySelector<GrThreadList>('gr-thread-list');
   }
 
-  _setDiffViewMode(opt_reset?: boolean) {
-    if (!opt_reset && this.viewState.diffViewMode) {
-      return;
-    }
-
-    return this._getPreferences()
-      .then(prefs => {
-        if (!this.viewState.diffMode && prefs) {
-          this.set('viewState.diffMode', prefs.default_diff_view);
-        }
-      })
-      .then(() => {
-        if (!this.viewState.diffMode) {
-          this.set('viewState.diffMode', 'SIDE_BY_SIDE');
-        }
-      });
-  }
-
   _onOpenFixPreview(e: OpenFixPreviewEvent) {
     this.$.applyFixDialog.open(e);
   }
@@ -742,16 +752,13 @@
     if (e.detail.fixApplied) fireReload(this);
   }
 
-  _handleToggleDiffMode(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
-    if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
+  _handleToggleDiffMode() {
+    if (this.diffViewMode === DiffViewMode.SIDE_BY_SIDE) {
+      this.userService.updatePreferences({diff_view: DiffViewMode.UNIFIED});
     } else {
-      this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
+      this.userService.updatePreferences({
+        diff_view: DiffViewMode.SIDE_BY_SIDE,
+      });
     }
   }
 
@@ -1402,9 +1409,6 @@
       !!this.viewState.changeNum &&
       this.viewState.changeNum !== this._changeNum
     ) {
-      // Reset the diff mode to null when navigating from one change to
-      // another, so that the user's preference is restored.
-      this._setDiffViewMode(true);
       this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
     }
     this.set('viewState.changeNum', this._changeNum);
@@ -1486,52 +1490,22 @@
     return label;
   }
 
-  _handleOpenReplyDialog(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
-      return;
-    }
+  _handleOpenReplyDialog() {
     this._getLoggedIn().then(isLoggedIn => {
       if (!isLoggedIn) {
         fireEvent(this, 'show-auth-required');
         return;
       }
-
-      e.preventDefault();
       this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
     });
   }
 
-  _handleOpenDownloadDialogShortcut(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
-    this._handleOpenDownloadDialog();
-  }
-
-  _handleEditTopic(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
-    this.$.metadata.editTopic();
-  }
-
-  _handleOpenSubmitDialog(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || !this._submitEnabled) {
-      return;
-    }
-
-    e.preventDefault();
+  _handleOpenSubmitDialog() {
+    if (!this._submitEnabled) return;
     this.$.actions.showSubmitDialog();
   }
 
-  _handleToggleAttentionSet(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) {
-      return;
-    }
+  _handleToggleAttentionSet() {
     if (!this._change || !this._account?._account_id) return;
     if (!this._loggedIn || !isInvolved(this._change, this._account)) return;
     if (!this._change.attention_set) this._change.attention_set = {};
@@ -1570,10 +1544,7 @@
     this._change = {...this._change};
   }
 
-  _handleDiffAgainstBase(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) {
-      return;
-    }
+  _handleDiffAgainstBase() {
     assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
@@ -1584,10 +1555,7 @@
     GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
   }
 
-  _handleDiffBaseAgainstLeft(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) {
-      return;
-    }
+  _handleDiffBaseAgainstLeft() {
     assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
@@ -1598,10 +1566,7 @@
     GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
   }
 
-  _handleDiffAgainstLatest(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) {
-      return;
-    }
+  _handleDiffAgainstLatest() {
     assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
@@ -1617,10 +1582,7 @@
     );
   }
 
-  _handleDiffRightAgainstLatest(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) {
-      return;
-    }
+  _handleDiffRightAgainstLatest() {
     assertIsDefined(this._change, '_change');
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
     if (!this._patchRange)
@@ -1636,10 +1598,7 @@
     );
   }
 
-  _handleDiffBaseAgainstLatest(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) {
-      return;
-    }
+  _handleDiffBaseAgainstLatest() {
     assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
@@ -1654,59 +1613,24 @@
     GerritNav.navigateToChange(this._change, latestPatchNum);
   }
 
-  _handleRefreshChange(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) {
-      return;
-    }
-    e.preventDefault();
-    fireReload(this, true);
-  }
-
-  _handleToggleChangeStar(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
-      return;
-    }
-    e.preventDefault();
+  _handleToggleChangeStar() {
     this.$.changeStar.toggleStar();
   }
 
-  _handleUpToDashboard(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
-    this._determinePageBack();
-  }
-
-  _handleExpandAllMessages(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _handleExpandAllMessages() {
     if (this.messagesList) {
       this.messagesList.handleExpandCollapse(true);
     }
   }
 
-  _handleCollapseAllMessages(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _handleCollapseAllMessages() {
     if (this.messagesList) {
       this.messagesList.handleExpandCollapse(false);
     }
   }
 
-  _handleOpenDiffPrefsShortcut(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
-      return;
-    }
+  _handleOpenDiffPrefsShortcut() {
     if (!this._loggedIn) return;
-    e.preventDefault();
     this.$.fileList.openDiffPrefs();
   }
 
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 0b77bc7..d42fc17 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
@@ -533,7 +533,6 @@
           server-config="[[_serverConfig]]"
           shown-file-count="[[_shownFileCount]]"
           diff-prefs="[[_diffPrefs]]"
-          diff-view-mode="{{viewState.diffMode}}"
           patch-num="{{_patchRange.patchNum}}"
           base-patch-num="{{_patchRange.basePatchNum}}"
           files-expanded="[[_filesExpanded]]"
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index e508c63..5fcc3b0 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -26,6 +26,7 @@
   HttpMethod,
   MessageTag,
   PrimaryTab,
+  createDefaultPreferences,
 } from '../../../constants/constants';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
@@ -35,7 +36,7 @@
 import {EventType, PluginApi} from '../../../api/plugin';
 
 import 'lodash/lodash';
-import {mockPromise, stubRestApi} from '../../../test/test-utils';
+import {mockPromise, stubRestApi, stubUsers} from '../../../test/test-utils';
 import {
   createAppElementChangeViewParams,
   createApproval,
@@ -82,23 +83,19 @@
 } from '../../../types/common';
 import {
   pressAndReleaseKeyOn,
-  keyUpOn,
   tap,
 } from '@polymer/iron-test-helpers/mock-interactions';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
 import {AppElementChangeViewParams} from '../../gr-app-types';
 import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-import {
-  IronKeyboardEvent,
-  IronKeyboardEventDetail,
-} from '../../../types/events';
 import {CommentThread, UIRobot} from '../../../utils/comment-util';
 import {GerritView} from '../../../services/router/router-model';
 import {ParsedChangeInfo} from '../../../types/types';
 import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
 import {appContext} from '../../../services/app-context';
 import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
+import {_testOnly_setState} from '../../../services/user/user-model';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 const fixture = fixtureFromElement('gr-change-view');
@@ -403,7 +400,7 @@
       patchNum: 3 as RevisionPatchSetNum,
       basePatchNum: 1 as BasePatchSetNum,
     };
-    element._handleDiffAgainstBase(new CustomEvent('') as IronKeyboardEvent);
+    element._handleDiffAgainstBase();
     assert(navigateToChangeStub.called);
     const args = navigateToChangeStub.getCall(0).args;
     assert.equal(args[0], element._change);
@@ -419,7 +416,7 @@
       basePatchNum: 1 as BasePatchSetNum,
       patchNum: 3 as RevisionPatchSetNum,
     };
-    element._handleDiffAgainstLatest(new CustomEvent('') as IronKeyboardEvent);
+    element._handleDiffAgainstLatest();
     assert(navigateToChangeStub.called);
     const args = navigateToChangeStub.getCall(0).args;
     assert.equal(args[0], element._change);
@@ -436,9 +433,7 @@
       patchNum: 3 as RevisionPatchSetNum,
       basePatchNum: 1 as BasePatchSetNum,
     };
-    element._handleDiffBaseAgainstLeft(
-      new CustomEvent('') as IronKeyboardEvent
-    );
+    element._handleDiffBaseAgainstLeft();
     assert(navigateToChangeStub.called);
     const args = navigateToChangeStub.getCall(0).args;
     assert.equal(args[0], element._change);
@@ -454,9 +449,7 @@
       basePatchNum: 1 as BasePatchSetNum,
       patchNum: 3 as RevisionPatchSetNum,
     };
-    element._handleDiffRightAgainstLatest(
-      new CustomEvent('') as IronKeyboardEvent
-    );
+    element._handleDiffRightAgainstLatest();
     assert(navigateToChangeStub.called);
     const args = navigateToChangeStub.getCall(0).args;
     assert.equal(args[1], 10 as PatchSetNum);
@@ -472,9 +465,7 @@
       basePatchNum: 1 as BasePatchSetNum,
       patchNum: 3 as RevisionPatchSetNum,
     };
-    element._handleDiffBaseAgainstLatest(
-      new CustomEvent('') as IronKeyboardEvent
-    );
+    element._handleDiffBaseAgainstLatest();
     assert(navigateToChangeStub.called);
     const args = navigateToChangeStub.getCall(0).args;
     assert.equal(args[1], 10 as PatchSetNum);
@@ -501,11 +492,11 @@
     assert.isNotOk(element._change.attention_set);
     await element._getLoggedIn();
     await element.restApiService.getAccount();
-    element._handleToggleAttentionSet(new CustomEvent('') as IronKeyboardEvent);
+    element._handleToggleAttentionSet();
     assert.isTrue(addToAttentionSetStub.called);
     assert.isFalse(removeFromAttentionSetStub.called);
 
-    element._handleToggleAttentionSet(new CustomEvent('') as IronKeyboardEvent);
+    element._handleToggleAttentionSet();
     assert.isTrue(removeFromAttentionSetStub.called);
   });
 
@@ -650,7 +641,7 @@
       sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
       const loggedInErrorSpy = sinon.spy();
       element.addEventListener('show-auth-required', loggedInErrorSpy);
-      keyUpOn(element, 65, null, 'a');
+      pressAndReleaseKeyOn(element, 65, null, 'a');
       await flush();
       assert.isFalse(element.$.replyOverlay.opened);
       assert.isTrue(loggedInErrorSpy.called);
@@ -683,7 +674,7 @@
 
       const openSpy = sinon.spy(element, '_openReplyDialog');
 
-      keyUpOn(element, 65, null, 'a');
+      pressAndReleaseKeyOn(element, 65, null, 'a');
       await flush();
       assert.isTrue(element.$.replyOverlay.opened);
       element.$.replyOverlay.close();
@@ -796,7 +787,7 @@
       const stub = sinon
         .stub(element.$.downloadOverlay, 'open')
         .returns(Promise.resolve());
-      keyUpOn(element, 68, null, 'd');
+      pressAndReleaseKeyOn(element, 68, null, 'd');
       assert.isTrue(stub.called);
     });
 
@@ -814,23 +805,30 @@
       assert.isTrue(stub.called);
     });
 
-    test('m should toggle diff mode', () => {
-      const setModeStub = sinon.stub(
-        element.$.fileListHeader,
-        'setDiffViewMode'
+    test('m should toggle diff mode', async () => {
+      const updatePreferencesStub = stubUsers('updatePreferences');
+      await flush();
+
+      const prefs = {
+        ...createDefaultPreferences(),
+        diff_view: DiffViewMode.SIDE_BY_SIDE,
+      };
+      _testOnly_setState({preferences: prefs});
+      element._handleToggleDiffMode();
+      assert.isTrue(
+        updatePreferencesStub.calledWith({diff_view: DiffViewMode.UNIFIED})
       );
-      const e = new CustomEvent<IronKeyboardEventDetail>('keydown', {
-        detail: {keyboardEvent: new KeyboardEvent('keydown'), key: 'x'},
-      });
-      flush();
 
-      element.viewState.diffMode = DiffViewMode.SIDE_BY_SIDE;
-      element._handleToggleDiffMode(e);
-      assert.isTrue(setModeStub.calledWith(DiffViewMode.UNIFIED));
-
-      element.viewState.diffMode = DiffViewMode.UNIFIED;
-      element._handleToggleDiffMode(e);
-      assert.isTrue(setModeStub.calledWith(DiffViewMode.SIDE_BY_SIDE));
+      const newPrefs = {
+        ...createDefaultPreferences(),
+        diff_view: DiffViewMode.UNIFIED,
+      };
+      _testOnly_setState({preferences: newPrefs});
+      await flush();
+      element._handleToggleDiffMode();
+      assert.isTrue(
+        updatePreferencesStub.calledWith({diff_view: DiffViewMode.SIDE_BY_SIDE})
+      );
     });
   });
 
@@ -1292,52 +1290,6 @@
     assert.equal(element._numFilesShown, 200);
   });
 
-  test('_setDiffViewMode is called with reset when new change is loaded', () => {
-    const setDiffViewModeStub = sinon.stub(element, '_setDiffViewMode');
-    element.viewState = {changeNum: 1 as NumericChangeId};
-    element._changeNum = 2 as NumericChangeId;
-    element._resetFileListViewState();
-    assert.isTrue(setDiffViewModeStub.calledWithExactly(true));
-  });
-
-  test('diffViewMode is propagated from file list header', () => {
-    element.viewState = {diffMode: DiffViewMode.UNIFIED};
-    element.$.fileListHeader.diffViewMode = DiffViewMode.SIDE_BY_SIDE;
-    assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
-  });
-
-  test('diffMode defaults to side by side without preferences', async () => {
-    stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
-    // No user prefs or diff view mode set.
-
-    await element._setDiffViewMode()!;
-    assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
-  });
-
-  test('diffMode defaults to preference when not already set', async () => {
-    stubRestApi('getPreferences').returns(
-      Promise.resolve({
-        ...createPreferences(),
-        default_diff_view: DiffViewMode.UNIFIED,
-      })
-    );
-
-    await element._setDiffViewMode()!;
-    assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
-  });
-
-  test('existing diffMode overrides preference', async () => {
-    element.viewState.diffMode = DiffViewMode.SIDE_BY_SIDE;
-    stubRestApi('getPreferences').returns(
-      Promise.resolve({
-        ...createPreferences(),
-        default_diff_view: DiffViewMode.UNIFIED,
-      })
-    );
-    await element._setDiffViewMode()!;
-    assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
-  });
-
   test('don’t reload entire page when patchRange changes', async () => {
     const reloadStub = sinon
       .stub(element, 'loadData')
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
index 07da53f..df537e0 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
@@ -19,9 +19,9 @@
 import '../../../styles/shared-styles';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-abandon-dialog_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {customElement, property} from '@polymer/decorators';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
 
 export interface GrConfirmAbandonDialog {
   $: {
@@ -35,11 +35,8 @@
   }
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-confirm-abandon-dialog')
-export class GrConfirmAbandonDialog extends base {
+export class GrConfirmAbandonDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -59,20 +56,33 @@
   @property({type: String})
   message = '';
 
-  get keyBindings() {
-    return {
-      'ctrl+enter meta+enter': '_handleEnterKey',
-    };
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
+
+  override disconnectedCallback() {
+    super.disconnectedCallback();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, _ =>
+        this._confirm()
+      )
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, _ =>
+        this._confirm()
+      )
+    );
   }
 
   resetFocus() {
     this.$.messageInput.textarea.focus();
   }
 
-  _handleEnterKey() {
-    this._confirm();
-  }
-
   _handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
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 8e6521d..b3bbc8a 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
@@ -19,17 +19,14 @@
 import '../../shared/gr-dialog/gr-dialog';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-move-dialog_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {customElement, property} from '@polymer/decorators';
 import {BranchName, RepoName} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
 import {GrTypedAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
 
 const SUGGESTIONS_LIMIT = 15;
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 // This is used to make sure 'branch'
 // can be typed as BranchName.
 export interface GrConfirmMoveDialog {
@@ -39,7 +36,7 @@
 }
 
 @customElement('gr-confirm-move-dialog')
-export class GrConfirmMoveDialog extends base {
+export class GrConfirmMoveDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -68,10 +65,27 @@
   @property({type: Object})
   _query?: (input: string) => Promise<{name: BranchName}[]>;
 
-  get keyBindings() {
-    return {
-      'ctrl+enter meta+enter': '_handleConfirmTap',
-    };
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
+
+  override disconnectedCallback() {
+    super.disconnectedCallback();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, e =>
+        this._handleConfirmTap(e)
+      )
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, e =>
+        this._handleConfirmTap(e)
+      )
+    );
   }
 
   private readonly restApiService = appContext.restApiService;
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 d0865c9..92f4a87 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
@@ -31,8 +31,8 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {GrOverlayStops} from '../../shared/gr-overlay/gr-overlay';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {addShortcut} from '../../../utils/dom-util';
 
 export interface GrDownloadDialog {
   $: {
@@ -42,11 +42,8 @@
   };
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-download-dialog')
-export class GrDownloadDialog extends base {
+export class GrDownloadDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -69,14 +66,22 @@
   @property({type: String})
   _selectedScheme?: string;
 
-  get keyBindings() {
-    return {
-      1: '_handleNumberKey',
-      2: '_handleNumberKey',
-      3: '_handleNumberKey',
-      4: '_handleNumberKey',
-      5: '_handleNumberKey',
-    };
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
+
+  override disconnectedCallback() {
+    super.disconnectedCallback();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    for (const key of ['1', '2', '3', '4', '5']) {
+      this.cleanups.push(
+        addShortcut(this, {key}, e => this._handleNumberKey(e))
+      );
+    }
   }
 
   @computed('change', 'patchNum')
@@ -98,8 +103,8 @@
     return [];
   }
 
-  _handleNumberKey(e: CustomEvent) {
-    const index = Number(e.detail.key) - 1;
+  _handleNumberKey(e: KeyboardEvent) {
+    const index = Number(e.key) - 1;
     const commands = this._computeDownloadCommands(
       this.change,
       this.patchNum,
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index 50bb665..1b44e35 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -41,11 +41,9 @@
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {GrDiffModeSelector} from '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
-import {DiffViewMode} from '../../../constants/constants';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fireEvent} from '../../../utils/event-util';
 import {
-  KeyboardShortcutMixin,
   Shortcut,
   ShortcutSection,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
@@ -65,11 +63,8 @@
   };
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-file-list-header')
-export class GrFileListHeader extends base {
+export class GrFileListHeader extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -126,9 +121,6 @@
   @property({type: Object})
   diffPrefs?: DiffPreferencesInfo;
 
-  @property({type: String, notify: true})
-  diffViewMode?: DiffViewMode;
-
   @property({type: String})
   patchNum?: PatchSetNum;
 
@@ -148,10 +140,6 @@
 
   private readonly shortcuts = appContext.shortcutsService;
 
-  setDiffViewMode(mode: DiffViewMode) {
-    this.$.modeSelect.setMode(mode);
-  }
-
   _expandAllDiffs() {
     fireEvent(this, 'expand-diffs');
   }
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
index 73d0819..5a85531 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
@@ -169,7 +169,6 @@
         <span class="fileViewActionsLabel">Diff view:</span>
         <gr-diff-mode-selector
           id="modeSelect"
-          mode="{{diffViewMode}}"
           save-on-change="[[loggedIn]]"
         ></gr-diff-mode-selector>
         <span
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 74c079b..5506bc7 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -36,6 +36,7 @@
 import {
   KeyboardShortcutMixin,
   Shortcut,
+  ShortcutListener,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {FilesExpandedState} from '../gr-file-list-constants';
 import {pluralize} from '../../../utils/string-util';
@@ -49,9 +50,10 @@
   SpecialFilePath,
 } from '../../../constants/constants';
 import {
+  addGlobalShortcut,
+  addShortcut,
   descendedFromClass,
-  isShiftPressed,
-  modifierPressed,
+  Key,
   toggleClass,
 } from '../../../utils/dom-util';
 import {
@@ -78,7 +80,6 @@
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
-import {IronKeyboardEvent} from '../../../types/events';
 import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
 import {Timing} from '../../../constants/reporting';
 import {RevisionInfo} from '../../shared/revision-info/revision-info';
@@ -86,6 +87,8 @@
 import {changeComments$} from '../../../services/comments/comments-model';
 import {Subject} from 'rxjs';
 import {takeUntil} from 'rxjs/operators';
+import {listen} from '../../../services/shortcuts/shortcuts-service';
+import {diffViewMode$} from '../../../services/browser/browser-model';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
@@ -198,9 +201,6 @@
   selectedIndex = -1;
 
   @property({type: Object})
-  keyEventTarget = document.body;
-
-  @property({type: Object})
   change?: ParsedChangeInfo;
 
   @property({type: String, notify: true, observer: '_updateDiffPreferences'})
@@ -319,51 +319,56 @@
 
   disconnected$ = new Subject();
 
-  get keyBindings() {
-    return {
-      esc: '_handleEscKey',
-    };
-  }
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
 
-  override keyboardShortcuts() {
-    return {
-      [Shortcut.LEFT_PANE]: '_handleLeftPane',
-      [Shortcut.RIGHT_PANE]: '_handleRightPane',
-      [Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
-      [Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
-      [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
-        '_handleToggleHideAllCommentThreads',
-      [Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
-      [Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
-      [Shortcut.NEXT_LINE]: '_handleCursorNext',
-      [Shortcut.PREV_LINE]: '_handleCursorPrev',
-      [Shortcut.NEW_COMMENT]: '_handleNewComment',
-      [Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
-      [Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
-      [Shortcut.OPEN_FILE]: '_handleOpenFile',
-      [Shortcut.NEXT_CHUNK]: '_handleNextChunk',
-      [Shortcut.PREV_CHUNK]: '_handlePrevChunk',
-      [Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
-      [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
-
-      // Final two are actually handled by gr-comment-thread.
-      [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
-      [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
-    };
+  override keyboardShortcuts(): ShortcutListener[] {
+    return [
+      listen(Shortcut.LEFT_PANE, _ => this._handleLeftPane()),
+      listen(Shortcut.RIGHT_PANE, _ => this._handleRightPane()),
+      listen(Shortcut.TOGGLE_INLINE_DIFF, _ => this._handleToggleInlineDiff()),
+      listen(Shortcut.TOGGLE_ALL_INLINE_DIFFS, _ => this._toggleInlineDiffs()),
+      listen(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, _ =>
+        toggleClass(this, 'hideComments')
+      ),
+      listen(Shortcut.CURSOR_NEXT_FILE, e => this._handleCursorNext(e)),
+      listen(Shortcut.CURSOR_PREV_FILE, e => this._handleCursorPrev(e)),
+      // This is already been taken care of by CURSOR_NEXT_FILE above. The two
+      // shortcuts share the same bindings. It depends on whether all files
+      // are expanded whether the cursor moves to the next file or line.
+      listen(Shortcut.NEXT_LINE, _ => {}), // docOnly
+      // This is already been taken care of by CURSOR_PREV_FILE above. The two
+      // shortcuts share the same bindings. It depends on whether all files
+      // are expanded whether the cursor moves to the previous file or line.
+      listen(Shortcut.PREV_LINE, _ => {}), // docOnly
+      listen(Shortcut.NEW_COMMENT, _ => this._handleNewComment()),
+      listen(Shortcut.OPEN_LAST_FILE, _ =>
+        this._openSelectedFile(this._files.length - 1)
+      ),
+      listen(Shortcut.OPEN_FIRST_FILE, _ => this._openSelectedFile(0)),
+      listen(Shortcut.OPEN_FILE, _ => this.handleOpenFile()),
+      listen(Shortcut.NEXT_CHUNK, _ => this._handleNextChunk()),
+      listen(Shortcut.PREV_CHUNK, _ => this._handlePrevChunk()),
+      listen(Shortcut.NEXT_COMMENT_THREAD, _ => this._handleNextComment()),
+      listen(Shortcut.PREV_COMMENT_THREAD, _ => this._handlePrevComment()),
+      listen(Shortcut.TOGGLE_FILE_REVIEWED, _ =>
+        this._handleToggleFileReviewed()
+      ),
+      listen(Shortcut.TOGGLE_LEFT_PANE, _ => this._handleToggleLeftPane()),
+      listen(Shortcut.EXPAND_ALL_COMMENT_THREADS, _ => {}), // docOnly
+      listen(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, _ => {}), // docOnly
+    ];
   }
 
   private fileCursor = new GrCursorManager();
 
   private diffCursor = new GrDiffCursor();
 
-  private readonly shortcuts = appContext.shortcutsService;
-
   constructor() {
     super();
     this.fileCursor.scrollMode = ScrollMode.KEEP_VISIBLE;
     this.fileCursor.cursorTargetClass = 'selected';
     this.fileCursor.focusOnMove = true;
-    this.addEventListener('keydown', e => this._scopedKeydownHandler(e));
   }
 
   override connectedCallback() {
@@ -373,6 +378,10 @@
       .subscribe(changeComments => {
         this.changeComments = changeComments;
       });
+    diffViewMode$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(diffView => (this.diffViewMode = diffView));
+
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
@@ -415,6 +424,12 @@
           this.reporting.error(new Error('dynamic header/content mismatch'));
         }
       });
+    this.cleanups.push(
+      addGlobalShortcut({key: Key.ESC}, _ => this._handleEscKey()),
+      addShortcut(this, {key: Key.ENTER}, _ => this.handleOpenFile(), {
+        shouldSuppress: true,
+      })
+    );
   }
 
   override disconnectedCallback() {
@@ -423,20 +438,11 @@
     this.fileCursor.unsetCursor();
     this._cancelDiffs();
     this.loadingTask?.cancel();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
     super.disconnectedCallback();
   }
 
-  /**
-   * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
-   * events must be scoped to a component level (e.g. `enter`) in order to not
-   * override native browser functionality.
-   *
-   * Context: Issue 7277
-   */
-  _scopedKeydownHandler(e: KeyboardEvent) {
-    if (e.keyCode === 13) this.handleOpenFile(e);
-  }
-
   reload() {
     if (!this.changeNum || !this.patchRange?.patchNum) {
       return Promise.resolve();
@@ -881,196 +887,84 @@
     return fileData;
   }
 
-  _handleLeftPane(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this._noDiffsExpanded()) {
-      return;
-    }
-
-    e.preventDefault();
+  _handleLeftPane() {
+    if (this._noDiffsExpanded()) return;
     this.diffCursor.moveLeft();
   }
 
-  _handleRightPane(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this._noDiffsExpanded()) {
-      return;
-    }
-
-    e.preventDefault();
+  _handleRightPane() {
+    if (this._noDiffsExpanded()) return;
     this.diffCursor.moveRight();
   }
 
-  _handleToggleInlineDiff(e: IronKeyboardEvent) {
-    if (
-      this.shortcuts.shouldSuppress(e) ||
-      this.shortcuts.modifierPressed(e) ||
-      e.detail?.keyboardEvent?.repeat ||
-      this.fileCursor.index === -1
-    ) {
-      return;
-    }
-
-    e.preventDefault();
+  _handleToggleInlineDiff() {
+    if (this.fileCursor.index === -1) return;
     this._toggleFileExpandedByIndex(this.fileCursor.index);
   }
 
-  _handleToggleAllInlineDiffs(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || e.detail?.keyboardEvent?.repeat) {
-      return;
-    }
-
-    e.preventDefault();
-    this._toggleInlineDiffs();
-  }
-
-  _handleToggleHideAllCommentThreads(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
-    toggleClass(this, 'hideComments');
-  }
-
-  _handleCursorNext(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
-      return;
-    }
-
+  _handleCursorNext(e: KeyboardEvent) {
     if (this.filesExpanded === FilesExpandedState.ALL) {
-      e.preventDefault();
       this.diffCursor.moveDown();
       this._displayLine = true;
     } else {
-      // Down key
-      if (e.detail.keyboardEvent.keyCode === 40) {
-        return;
-      }
-      e.preventDefault();
+      if (e.key === Key.DOWN) return;
       this.fileCursor.next({circular: true});
       this.selectedIndex = this.fileCursor.index;
     }
   }
 
-  _handleCursorPrev(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
-      return;
-    }
-
+  _handleCursorPrev(e: KeyboardEvent) {
     if (this.filesExpanded === FilesExpandedState.ALL) {
-      e.preventDefault();
       this.diffCursor.moveUp();
       this._displayLine = true;
     } else {
-      // Up key
-      if (e.detail.keyboardEvent.keyCode === 38) {
-        return;
-      }
-      e.preventDefault();
+      if (e.key === Key.UP) return;
       this.fileCursor.previous({circular: true});
       this.selectedIndex = this.fileCursor.index;
     }
   }
 
-  _handleNewComment(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
-      return;
-    }
-    e.preventDefault();
+  _handleNewComment() {
     this.classList.remove('hideComments');
     this.diffCursor.createCommentInPlace();
   }
 
-  _handleOpenLastFile(e: IronKeyboardEvent) {
-    // Check for meta key to avoid overriding native chrome shortcut.
-    if (this.shortcuts.shouldSuppress(e) || e.detail.keyboardEvent.metaKey) {
-      return;
-    }
-
-    e.preventDefault();
-    this._openSelectedFile(this._files.length - 1);
-  }
-
-  _handleOpenFirstFile(e: IronKeyboardEvent) {
-    // Check for meta key to avoid overriding native chrome shortcut.
-    if (this.shortcuts.shouldSuppress(e) || e.detail.keyboardEvent.metaKey) {
-      return;
-    }
-
-    e.preventDefault();
-    this._openSelectedFile(0);
-  }
-
-  _handleOpenFile(e: IronKeyboardEvent) {
-    if (this.shortcuts.modifierPressed(e)) return;
-    this.handleOpenFile(e.detail.keyboardEvent);
-  }
-
-  handleOpenFile(e: KeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || modifierPressed(e)) {
-      return;
-    }
-    e.preventDefault();
-
+  handleOpenFile() {
     if (this.filesExpanded === FilesExpandedState.ALL) {
       this._openCursorFile();
       return;
     }
-
     this._openSelectedFile();
   }
 
-  _handleNextChunk(e: IronKeyboardEvent) {
-    if (
-      this.shortcuts.shouldSuppress(e) ||
-      (this.shortcuts.modifierPressed(e) && !isShiftPressed(e)) ||
-      this._noDiffsExpanded()
-    ) {
-      return;
-    }
-
-    e.preventDefault();
-    if (isShiftPressed(e)) {
-      this.diffCursor.moveToNextCommentThread();
-    } else {
-      this.diffCursor.moveToNextChunk();
-    }
+  _handleNextChunk() {
+    if (this._noDiffsExpanded()) return;
+    this.diffCursor.moveToNextChunk();
   }
 
-  _handlePrevChunk(e: IronKeyboardEvent) {
-    if (
-      this.shortcuts.shouldSuppress(e) ||
-      (this.shortcuts.modifierPressed(e) && !isShiftPressed(e)) ||
-      this._noDiffsExpanded()
-    ) {
-      return;
-    }
-
-    e.preventDefault();
-    if (isShiftPressed(e)) {
-      this.diffCursor.moveToPreviousCommentThread();
-    } else {
-      this.diffCursor.moveToPreviousChunk();
-    }
+  _handleNextComment() {
+    if (this._noDiffsExpanded()) return;
+    this.diffCursor.moveToNextCommentThread();
   }
 
-  _handleToggleFileReviewed(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
-      return;
-    }
+  _handlePrevChunk() {
+    if (this._noDiffsExpanded()) return;
+    this.diffCursor.moveToPreviousChunk();
+  }
 
-    e.preventDefault();
+  _handlePrevComment() {
+    if (this._noDiffsExpanded()) return;
+    this.diffCursor.moveToPreviousCommentThread();
+  }
+
+  _handleToggleFileReviewed() {
     if (!this._files[this.fileCursor.index]) {
       return;
     }
     this._reviewFile(this._files[this.fileCursor.index].__path);
   }
 
-  _handleToggleLeftPane(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _handleToggleLeftPane() {
     this._forEachDiff(diff => {
       diff.toggleLeftDiff();
     });
@@ -1542,11 +1436,7 @@
     return undefined;
   }
 
-  _handleEscKey(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e) || this.shortcuts.modifierPressed(e)) {
-      return;
-    }
-    e.preventDefault();
+  _handleEscKey() {
     this._displayLine = false;
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
index f7be36b..e8371e3 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
@@ -643,7 +643,6 @@
             prefs="[[diffPrefs]]"
             project-name="[[change.project]]"
             no-render-on-prefs-change=""
-            view-mode="[[diffViewMode]]"
           ></gr-diff-host>
         </template>
       </div>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index 062d6a2..0db7690 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -27,11 +27,11 @@
 import {runA11yAudit} from '../../../test/a11y-test-utils.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {
-  stubRestApi,
-  spyRestApi,
   listenOnce,
   mockPromise,
   query,
+  spyRestApi,
+  stubRestApi,
 } from '../../../test/test-utils.js';
 import {EditPatchSetNum} from '../../../types/common.js';
 import {createCommentThreads} from '../../../utils/comment-util.js';
@@ -470,7 +470,7 @@
         // https://github.com/sinonjs/sinon/issues/781
         const diffsStub = sinon.stub(element, 'diffs')
             .get(() => [{toggleLeftDiff: toggleLeftDiffStub}]);
-        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'A');
         assert.isTrue(toggleLeftDiffStub.calledOnce);
         diffsStub.restore();
       });
@@ -486,7 +486,7 @@
         assert.isFalse(items[1].classList.contains('selected'));
         assert.isFalse(items[2].classList.contains('selected'));
         // j with a modifier should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 74, 'shift', 'j');
+        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'J');
         assert.equal(element.fileCursor.index, 0);
         // down should not move the cursor.
         MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'down');
@@ -502,7 +502,7 @@
         assert.equal(element.selectedIndex, 2);
 
         // k with a modifier should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 75, 'shift', 'k');
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'K');
         assert.equal(element.fileCursor.index, 2);
 
         // up should not move the cursor.
@@ -560,7 +560,7 @@
         assert.equal(element._expandedFiles.length, 1);
         assert.equal(element._expandedFiles[0].path, paths[1]);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
         flush();
         assert.equal(element.diffs.length, paths.length);
         assert.equal(element._expandedFiles.length, paths.length);
@@ -569,7 +569,7 @@
         }
         // since _expandedFilesChanged is stubbed
         element.filesExpanded = FilesExpandedState.ALL;
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
         flush();
         assert.equal(element.diffs.length, 0);
         assert.equal(element._expandedFiles.length, 0);
@@ -584,16 +584,16 @@
         assert.equal(getNumReviewed(), 0);
 
         // Press the review key to toggle it (set the flag).
-        MockInteractions.keyUpOn(element, 82, null, 'r');
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
         flush();
         assert.equal(getNumReviewed(), 1);
 
         // Press the review key to toggle it (clear the flag).
-        MockInteractions.keyUpOn(element, 82, null, 'r');
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
         assert.equal(getNumReviewed(), 0);
       });
 
-      suite('_handleOpenFile', () => {
+      suite('handleOpenFile', () => {
         let interact;
 
         setup(() => {
@@ -605,14 +605,7 @@
             openCursorStub.reset();
             openSelectedStub.reset();
             expandStub.reset();
-
-            const keyboardEvent = new KeyboardEvent('keydown');
-            const e = new CustomEvent('keydown', {
-              detail: {keyboardEvent, key: 'x'},
-            });
-            sinon.stub(keyboardEvent, 'preventDefault');
-            element._handleOpenFile(e);
-            assert.isTrue(keyboardEvent.preventDefault.called);
+            element.handleOpenFile();
             const result = {};
             if (openCursorStub.called) {
               result.opened_cursor = true;
@@ -653,16 +646,20 @@
         sinon.stub(element, '_noDiffsExpanded')
             .callsFake(() => noDiffsExpanded);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
+        MockInteractions.pressAndReleaseKeyOn(
+            element, 73, 'shift', 'ArrowLeft');
         assert.isFalse(moveLeftStub.called);
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
+        MockInteractions.pressAndReleaseKeyOn(
+            element, 73, 'shift', 'ArrowRight');
         assert.isFalse(moveRightStub.called);
 
         noDiffsExpanded = false;
 
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
+        MockInteractions.pressAndReleaseKeyOn(
+            element, 73, 'shift', 'ArrowLeft');
         assert.isTrue(moveLeftStub.called);
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
+        MockInteractions.pressAndReleaseKeyOn(
+            element, 73, 'shift', 'ArrowRight');
         assert.isTrue(moveRightStub.called);
       });
     });
@@ -823,10 +820,8 @@
 
       MockInteractions.tap(row);
       flush();
-      const diffDisplay = element.diffs[0];
-      element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
+      element._userPrefs = {diff_view: 'SIDE_BY_SIDE'};
       element.set('diffViewMode', 'UNIFIED_DIFF');
-      assert.equal(diffDisplay.viewMode, 'UNIFIED_DIFF');
       assert.isTrue(element._updateDiffPreferences.called);
     });
 
@@ -1590,7 +1585,7 @@
     });
 
     test('cursor with toggle all files', async () => {
-      MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
+      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
       await flush();
 
       const diffs = await renderAndGetNewDiffs(0);
@@ -1620,14 +1615,12 @@
     });
 
     suite('n key presses', () => {
-      let nKeySpy;
       let nextCommentStub;
       let nextChunkStub;
       let fileRows;
 
       setup(() => {
         sinon.stub(element, '_renderInOrder').returns(Promise.resolve());
-        nKeySpy = sinon.spy(element, '_handleNextChunk');
         nextCommentStub = sinon.stub(element.diffCursor,
             'moveToNextCommentThread');
         nextChunkStub = sinon.stub(element.diffCursor,
@@ -1636,58 +1629,40 @@
             element.root.querySelectorAll('.row:not(.header-row)');
       });
 
-      test('n key with some files expanded and no shift key', async () => {
+      test('n key with some files expanded', async () => {
         MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
         await flush();
+        assert.equal(element.filesExpanded, FilesExpandedState.SOME);
 
-        // Handle N key should return before calling diff cursor functions.
         MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-        assert.isTrue(nKeySpy.called);
-        assert.isFalse(nextCommentStub.called);
-
-        // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 1);
-        assert.equal(element.filesExpanded, 'some');
+        assert.isTrue(nextChunkStub.calledOnce);
       });
 
-      test('n key with some files expanded and shift key', async () => {
+      test('N key with some files expanded', async () => {
         MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
         await flush();
-        assert.equal(nextChunkStub.callCount, 0);
+        assert.equal(element.filesExpanded, FilesExpandedState.SOME);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
-        assert.isTrue(nKeySpy.called);
-        assert.isTrue(nextCommentStub.called);
-
-        // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 0);
-        assert.equal(element.filesExpanded, 'some');
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
+        assert.isTrue(nextCommentStub.calledOnce);
       });
 
-      test('n key without all files expanded and shift key', async () => {
-        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, 'shift', 'i');
+      test('n key with all files expanded', async () => {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'I');
         await flush();
+        assert.equal(element.filesExpanded, FilesExpandedState.ALL);
 
         MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-        assert.isTrue(nKeySpy.called);
-        assert.isFalse(nextCommentStub.called);
-
-        // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 1);
-        assert.equal(element.filesExpanded, FilesExpandedState.ALL);
+        assert.isTrue(nextChunkStub.calledOnce);
       });
 
-      test('n key without all files expanded and no shift key', async () => {
-        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, 'shift', 'i');
+      test('N key with all files expanded', async () => {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'I');
         await flush();
-
-        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
-        assert.isTrue(nKeySpy.called);
-        assert.isTrue(nextCommentStub.called);
-
-        // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 0);
         assert.equal(element.filesExpanded, FilesExpandedState.ALL);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
+        assert.isTrue(nextCommentStub.called);
       });
     });
 
@@ -1708,26 +1683,17 @@
 
     test('_displayLine', () => {
       element.filesExpanded = FilesExpandedState.ALL;
-      const mockEvent = {
-        preventDefault() {},
-        composedPath() { return []; },
-        detail: {
-          keyboardEvent: {
-            composedPath() { return []; },
-          },
-        },
-      };
 
       element._displayLine = false;
-      element._handleCursorNext(mockEvent);
+      element._handleCursorNext(new KeyboardEvent('keydown'));
       assert.isTrue(element._displayLine);
 
       element._displayLine = false;
-      element._handleCursorPrev(mockEvent);
+      element._handleCursorPrev(new KeyboardEvent('keydown'));
       assert.isTrue(element._displayLine);
 
       element._displayLine = true;
-      element._handleEscKey(mockEvent);
+      element._handleEscKey();
       assert.isFalse(element._displayLine);
     });
 
@@ -1737,13 +1703,13 @@
         const saveReviewStub = sinon.stub(element, '_saveReviewedState');
 
         element.editMode = false;
-        MockInteractions.keyUpOn(element, 82, null, 'r');
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
         assert.isTrue(saveReviewStub.calledOnce);
 
         element.editMode = true;
         await flush();
 
-        MockInteractions.keyUpOn(element, 82, null, 'r');
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
         assert.isTrue(saveReviewStub.calledOnce);
       });
 
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts
index e21584e..e57a216 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts
@@ -53,29 +53,31 @@
       );
       padding: 0 var(--spacing-m);
     }
-    gr-tooltip-content.iron-selected > gr-button[vote='max'] {
+    gr-button[vote='max'].iron-selected {
       --button-background-color: var(--vote-color-approved);
     }
-    gr-tooltip-content.iron-selected > gr-button[vote='positive'] {
+    gr-button[vote='positive'].iron-selected {
       --button-background-color: var(--vote-color-recommended);
     }
-    gr-tooltip-content.iron-selected > gr-button[vote='min'] {
+    gr-button[vote='min'].iron-selected {
       --button-background-color: var(--vote-color-rejected);
     }
-    gr-tooltip-content.iron-selected > gr-button[vote='negative'] {
+    gr-button[vote='negative'].iron-selected {
       --button-background-color: var(--vote-color-disliked);
     }
-    gr-tooltip-content.iron-selected > gr-button[vote='neutral'] {
+    gr-button[vote='neutral'].iron-selected {
       --button-background-color: var(--vote-color-neutral);
     }
-    gr-tooltip-content.iron-selected
-      > gr-button[vote='positive']::part(paper-button) {
+    gr-button[vote='positive'].iron-selected::part(paper-button) {
       border-color: var(--vote-outline-recommended);
     }
-    gr-tooltip-content.iron-selected
-      > gr-button[vote='negative']::part(paper-button) {
+    gr-button[vote='negative'].iron-selected::part(paper-button) {
       border-color: var(--vote-outline-disliked);
     }
+    gr-button > gr-tooltip-content {
+      margin: 0px -10px;
+      padding: 0px 10px;
+    }
     .placeholder {
       display: inline-block;
       width: 42px;
@@ -118,20 +120,22 @@
       aria-labelledby="labelName"
     >
       <template is="dom-repeat" items="[[_items]]" as="value">
-        <gr-tooltip-content
-          has-tooltip
+        <gr-button
+          role="radio"
+          vote$="[[_computeVoteAttribute(value, index, _items.length)]]"
           title$="[[_computeLabelValueTitle(labels, label.name, value)]]"
           data-name$="[[label.name]]"
           data-value$="[[value]]"
+          aria-label$="[[value]]"
+          voteChip
         >
-          <gr-button
-            role="radio"
-            vote$="[[_computeVoteAttribute(value, index, _items.length)]]"
-            voteChip
+          <gr-tooltip-content
+            has-tooltip
+            title$="[[_computeLabelValueTitle(labels, label.name, value)]]"
           >
             [[value]]
-          </gr-button>
-        </gr-tooltip-content>
+          </gr-tooltip-content>
+        </gr-button>
       </template>
     </iron-selector>
     <template
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
index 34e959b..51d76b2 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
@@ -101,15 +101,12 @@
     const labelsChangedHandler = sinon.stub();
     element.addEventListener('labels-changed', labelsChangedHandler);
     assert.ok(element.$.labelSelector);
-    MockInteractions.tap(element.shadowRoot
-        .querySelector(
-            'gr-tooltip-content[data-value="-1"] > gr-button'));
+    MockInteractions.tap(
+        element.shadowRoot.querySelector('gr-button[data-value="-1"]'));
     await flush();
     assert.strictEqual(element.selectedValue, '-1');
-    assert.strictEqual(element.selectedItem
-        .textContent.trim(), '-1');
-    assert.strictEqual(
-        element.$.selectedValueLabel.textContent.trim(), 'bad');
+    assert.strictEqual(element.selectedItem.textContent.trim(), '-1');
+    assert.strictEqual(element.$.selectedValueLabel.textContent.trim(), 'bad');
     const detail = labelsChangedHandler.args[0][0].detail;
     assert.equal(detail.name, 'Verified');
     assert.equal(detail.value, '-1');
@@ -121,49 +118,48 @@
     let index = 0;
     const totalItems = 5;
     // positive and first position
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'positive');
+    assert.equal(
+        element._computeVoteAttribute(value, index, totalItems), 'positive');
     // negative and first position
     value = -1;
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'min');
+    assert.equal(
+        element._computeVoteAttribute(value, index, totalItems), 'min');
     // negative but not first position
     index = 1;
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'negative');
+    assert.equal(
+        element._computeVoteAttribute(value, index, totalItems), 'negative');
     // neutral
     value = 0;
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'neutral');
+    assert.equal(
+        element._computeVoteAttribute(value, index, totalItems), 'neutral');
     // positive but not last position
     value = 1;
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'positive');
+    assert.equal(
+        element._computeVoteAttribute(value, index, totalItems), 'positive');
     // positive and last position
     index = 4;
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'max');
+    assert.equal(
+        element._computeVoteAttribute(value, index, totalItems), 'max');
     // negative and last position
     value = -1;
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'negative');
+    assert.equal(
+        element._computeVoteAttribute(value, index, totalItems), 'negative');
   });
 
   test('correct item is selected', () => {
     // 1 should be the value of the selected item
     assert.strictEqual(element.$.labelSelector.selected, '+1');
     assert.strictEqual(
-        element.$.labelSelector.selectedItem
-            .textContent.trim(), '+1');
-    assert.strictEqual(
-        element.$.selectedValueLabel.textContent.trim(), 'good');
+        element.$.labelSelector.selectedItem.textContent.trim(), '+1');
+    assert.strictEqual(element.$.selectedValueLabel.textContent.trim(), 'good');
     checkAriaCheckedValid();
   });
 
   test('_computeLabelValue', () => {
-    assert.strictEqual(element._computeLabelValue(element.labels,
-        element.permittedLabels,
-        element.label), '+1');
+    assert.strictEqual(
+        element._computeLabelValue(
+            element.labels, element.permittedLabels, element.label),
+        '+1');
   });
 
   test('_computeBlankItems', () => {
@@ -175,18 +171,21 @@
       '2': 4,
     };
 
-    assert.strictEqual(element._computeBlankItems(element.permittedLabels,
-        'Code-Review').length, 0);
+    assert.strictEqual(
+        element._computeBlankItems(element.permittedLabels, 'Code-Review')
+            .length,
+        0);
 
-    assert.strictEqual(element._computeBlankItems(element.permittedLabels,
-        'Verified').length, 1);
+    assert.strictEqual(
+        element._computeBlankItems(element.permittedLabels, 'Verified').length,
+        1);
   });
 
   test('labelValues returns no keys', () => {
     element.labelValues = {};
 
-    assert.deepEqual(element._computeBlankItems(element.permittedLabels,
-        'Code-Review'), []);
+    assert.deepEqual(
+        element._computeBlankItems(element.permittedLabels, 'Code-Review'), []);
   });
 
   test('changes in label score are reflected in the DOM', async () => {
@@ -260,11 +259,8 @@
       ],
     };
     await flush();
-    assert.strictEqual(element.$.labelSelector
-        .items.length, 2);
-    assert.strictEqual(
-        element.root.querySelectorAll('.placeholder').length,
-        3);
+    assert.strictEqual(element.$.labelSelector.items.length, 2);
+    assert.strictEqual(element.root.querySelectorAll('.placeholder').length, 3);
 
     element.permittedLabels = {
       'Code-Review': [
@@ -280,11 +276,8 @@
       ],
     };
     await flush();
-    assert.strictEqual(element.$.labelSelector
-        .items.length, 5);
-    assert.strictEqual(
-        element.root.querySelectorAll('.placeholder').length,
-        0);
+    assert.strictEqual(element.$.labelSelector.items.length, 5);
+    assert.strictEqual(element.root.querySelectorAll('.placeholder').length, 0);
   });
 
   test('default_value', () => {
@@ -344,4 +337,3 @@
     assert.isNull(element.selectedValue);
   });
 });
-
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index cd79bba..e16c073 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -22,7 +22,6 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-messages-list_html';
 import {
-  KeyboardShortcutMixin,
   Shortcut,
   ShortcutSection,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
@@ -201,11 +200,8 @@
   };
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-messages-list')
-export class GrMessagesList extends base {
+export class GrMessagesList extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
index b8c9319..486f37a 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
@@ -16,10 +16,11 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import {queryAndAssert, resetPlugins, stubRestApi} from '../../../test/test-utils.js';
 import './gr-reply-dialog.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+
+import {queryAndAssert, resetPlugins, stubRestApi} from '../../../test/test-utils.js';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 
 const basicFixture = fixtureFromElement('gr-reply-dialog');
 const pluginApi = _testOnly_initGerritPluginApi();
@@ -89,14 +90,12 @@
     const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
 
     element.$.ccs.$.entry.setText('test');
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button.send'));
+    MockInteractions.tap(element.shadowRoot.querySelector('gr-button.send'));
     assert.isFalse(sendStub.called);
     flush();
 
     element.$.ccs.$.entry.setText('test@test.test');
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button.send'));
+    MockInteractions.tap(element.shadowRoot.querySelector('gr-button.send'));
     assert.isTrue(sendStub.called);
   });
 
@@ -107,9 +106,7 @@
       replyApi.addReplyTextChangedCallback(text => {
         const label = 'Code-Review';
         const labelValue = replyApi.getLabelValue(label);
-        if (labelValue &&
-            labelValue === ' 0' &&
-            text.indexOf('LGTM') === 0) {
+        if (labelValue && labelValue === ' 0' && text.indexOf('LGTM') === 0) {
           replyApi.setLabelValue(label, '+1');
         }
       });
@@ -121,13 +118,13 @@
     await flush();
     const textarea = queryAndAssert(element, 'gr-textarea').getNativeTextarea();
     textarea.value = 'LGTM';
-    textarea.dispatchEvent(new CustomEvent(
-        'input', {bubbles: true, composed: true}));
+    textarea.dispatchEvent(
+        new CustomEvent('input', {bubbles: true, composed: true}));
     await flush();
-    const labelScoreRows = element.getLabelScores().shadowRoot
-        .querySelector('gr-label-score-row[name="Code-Review"]');
-    const selectedBtn = labelScoreRows.shadowRoot
-        .querySelector('gr-tooltip-content[data-value="+1"] > gr-button');
+    const labelScoreRows = element.getLabelScores().shadowRoot.querySelector(
+        'gr-label-score-row[name="Code-Review"]');
+    const selectedBtn =
+        labelScoreRows.shadowRoot.querySelector('gr-button[data-value="+1"]');
     assert.isOk(selectedBtn);
   });
 });
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 17036e6..b8932e5 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
@@ -38,7 +38,6 @@
   ReviewerState,
   SpecialFilePath,
 } from '../../../constants/constants';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {
   accountOrGroupKey,
   isReviewerOrCC,
@@ -99,9 +98,9 @@
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {
-  CODE_REVIEW,
   getApprovalInfo,
   getMaxAccounts,
+  StandardLabels,
 } from '../../../utils/label-util';
 import {pluralize} from '../../../utils/string-util';
 import {
@@ -116,6 +115,7 @@
 import {StorageLocation} from '../../../services/storage/gr-storage';
 import {Interaction, Timing} from '../../../constants/reporting';
 import {getReplyByReason} from '../../../utils/attention-set-util';
+import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -163,11 +163,8 @@
   };
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-reply-dialog')
-export class GrReplyDialog extends base {
+export class GrReplyDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -368,12 +365,8 @@
 
   private storeTask?: DelayedTask;
 
-  get keyBindings() {
-    return {
-      esc: '_handleEscKey',
-      'ctrl+enter meta+enter': '_handleEnterKey',
-    };
-  }
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
 
   constructor() {
     super();
@@ -391,6 +384,17 @@
       if (account) this._account = account;
     });
 
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, _ =>
+        this._submit()
+      )
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, _ =>
+        this._submit()
+      )
+    );
+    this.cleanups.push(addShortcut(this, {key: Key.ESC}, _ => this.cancel()));
     this.addEventListener('comment-editing-changed', e => {
       this._commentEditing = (e as CustomEvent).detail;
     });
@@ -418,6 +422,8 @@
 
   override disconnectedCallback() {
     this.storeTask?.cancel();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
     super.disconnectedCallback();
   }
 
@@ -492,14 +498,6 @@
     return (selectorEl as GrLabelScoreRow).selectedValue;
   }
 
-  _handleEscKey() {
-    this.cancel();
-  }
-
-  _handleEnterKey() {
-    this._submit();
-  }
-
   @observe('_ccs.splices')
   _ccsChanged(splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>) {
     this._reviewerTypeChanged(splices, ReviewerType.CC);
@@ -986,7 +984,7 @@
   }
 
   _computeCommentAccounts(threads: CommentThread[]) {
-    const crLabel = this.change?.labels?.[CODE_REVIEW];
+    const crLabel = this.change?.labels?.[StandardLabels.CODE_REVIEW];
     const maxCrVoteAccountIds = getMaxAccounts(crLabel).map(a => a._account_id);
     const accountIds = new Set<AccountId>();
     threads.forEach(thread => {
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 e57ffc7..5a11f47 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
@@ -32,7 +32,7 @@
 import {addListenerForTest} from '../../../test/test-utils';
 import {stubRestApi} from '../../../test/test-utils';
 import {JSON_PREFIX} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
-import {CODE_REVIEW} from '../../../utils/label-util';
+import {StandardLabels} from '../../../utils/label-util';
 import {
   createAccountWithId,
   createChange,
@@ -1799,7 +1799,7 @@
   test('emits cancel on esc key', () => {
     const cancelHandler = sinon.spy();
     element.addEventListener('cancel', cancelHandler);
-    pressAndReleaseKeyOn(element, 27, null, 'esc');
+    pressAndReleaseKeyOn(element, 27, null, 'Escape');
     flush();
 
     assert.isTrue(cancelHandler.called);
@@ -1808,14 +1808,14 @@
   test('should not send on enter key', () => {
     stubSaveReview(() => undefined);
     element.addEventListener('send', () => assert.fail('wrongly called'));
-    pressAndReleaseKeyOn(element, 13, null, 'enter');
+    pressAndReleaseKeyOn(element, 13, null, 'Enter');
   });
 
   test('emit send on ctrl+enter key', async () => {
     stubSaveReview(() => undefined);
     const promise = mockPromise();
     element.addEventListener('send', () => promise.resolve());
-    pressAndReleaseKeyOn(element, 13, 'ctrl', 'enter');
+    pressAndReleaseKeyOn(element, 13, 'ctrl', 'Enter');
     await promise;
   });
 
@@ -2116,9 +2116,9 @@
 
   test('_computeSendButtonDisabled_existingVote', async () => {
     const account = createAccountWithId();
-    (element.change!.labels![CODE_REVIEW]! as DetailedLabelInfo).all = [
-      account,
-    ];
+    (
+      element.change!.labels![StandardLabels.CODE_REVIEW]! as DetailedLabelInfo
+    ).all = [account];
     await flush();
 
     // User has already voted.
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard.ts
new file mode 100644
index 0000000..ebe3ce3
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-dashboard-hovercard/gr-submit-requirement-dashboard-hovercard.ts
@@ -0,0 +1,57 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../gr-submit-requirements/gr-submit-requirements';
+import {customElement, property} from 'lit/decorators';
+import {css, html, LitElement} from 'lit';
+import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
+import {ParsedChangeInfo} from '../../../types/types';
+
+// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
+const base = HovercardMixin(LitElement);
+
+@customElement('gr-submit-requirement-dashboard-hovercard')
+export class GrSubmitRequirementDashboardHovercard extends base {
+  @property({type: Object})
+  change?: ParsedChangeInfo;
+
+  static override get styles() {
+    return [
+      base.styles || [],
+      css`
+        #container {
+          padding: var(--spacing-xl);
+          padding-left: var(--spacing-s);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`<div id="container" role="tooltip" tabindex="-1">
+      <gr-submit-requirements
+        .change=${this.change}
+        suppress-title
+      ></gr-submit-requirements>
+    </div>`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-submit-requirement-dashboard-hovercard': GrSubmitRequirementDashboardHovercard;
+  }
+}
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 e302b43..157336d 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
@@ -36,6 +36,7 @@
   hasNeutralStatus,
   hasVotes,
   iconForStatus,
+  orderSubmitRequirements,
 } from '../../../utils/label-util';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {charsOnly, pluralize} from '../../../utils/string-util';
@@ -48,6 +49,9 @@
 import {Category} from '../../../api/checks';
 import '../../shared/gr-vote-chip/gr-vote-chip';
 
+/**
+ * @attr {Boolean} suppress-title - hide titles, currently for hovercard view
+ */
 @customElement('gr-submit-requirements')
 export class GrSubmitRequirements extends LitElement {
   @property({type: Object})
@@ -66,6 +70,9 @@
     return [
       fontStyles,
       css`
+        :host([suppress-title]) .metadata-title {
+          display: none;
+        }
         .metadata-title {
           color: var(--deemphasized-text-color);
           padding-left: var(--metadata-horizontal-padding);
@@ -77,7 +84,8 @@
           width: var(--line-height-normal, 20px);
           height: var(--line-height-normal, 20px);
         }
-        iron-icon.check {
+        iron-icon.check,
+        iron-icon.overridden {
           color: var(--success-foreground);
         }
         iron-icon.close {
@@ -106,6 +114,7 @@
         }
         td {
           padding: var(--spacing-s);
+          white-space: nowrap;
         }
         .votes-cell {
           display: flex;
@@ -130,9 +139,19 @@
   }
 
   override render() {
-    const submit_requirements = (this.change?.submit_requirements ?? []).filter(
-      req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE
+    let submit_requirements = orderSubmitRequirements(
+      this.change?.submit_requirements ?? []
+    ).filter(req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE);
+
+    const hasNonLegacyRequirements = submit_requirements.some(
+      req => req.is_legacy === false
     );
+    if (hasNonLegacyRequirements) {
+      submit_requirements = submit_requirements.filter(
+        req => req.is_legacy === false
+      );
+    }
+
     return html` <h3
         class="metadata-title heading-3"
         id="submit-requirements-caption"
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
index 541d877..8610999 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
@@ -21,13 +21,12 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-keyboard-shortcuts-dialog_html';
 import {
-  KeyboardShortcutMixin,
   ShortcutSection,
-  ShortcutListener,
   SectionView,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {property, customElement} from '@polymer/decorators';
 import {appContext} from '../../../services/app-context';
+import {ShortcutViewListener} from '../../../services/shortcuts/shortcuts-service';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -40,11 +39,8 @@
   shortcuts?: SectionView;
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-keyboard-shortcuts-dialog')
-export class GrKeyboardShortcutsDialog extends base {
+export class GrKeyboardShortcutsDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -61,7 +57,7 @@
   @property({type: Array})
   _right?: SectionShortcut[];
 
-  private readonly shortcutListener: ShortcutListener;
+  private readonly shortcutListener: ShortcutViewListener;
 
   private readonly shortcuts = appContext.shortcutsService;
 
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index dd1ebd1..2901b8a 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -22,6 +22,7 @@
 import {
   KeyboardShortcutMixin,
   Shortcut,
+  ShortcutListener,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {customElement, property} from '@polymer/decorators';
 import {ServerInfo} from '../../../types/common';
@@ -31,9 +32,9 @@
   GrAutocomplete,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getDocsBaseUrl} from '../../../utils/url-util';
-import {IronKeyboardEvent} from '../../../types/events';
 import {MergeabilityComputationBehavior} from '../../../constants/constants';
 import {appContext} from '../../../services/app-context';
+import {listen} from '../../../services/shortcuts/shortcuts-service';
 
 // Possible static search options for auto complete, without negations.
 const SEARCH_OPERATORS: ReadonlyArray<string> = [
@@ -169,9 +170,6 @@
   value = '';
 
   @property({type: Object})
-  keyEventTarget: unknown = document.body;
-
-  @property({type: Object})
   query: AutocompleteQuery;
 
   @property({type: Object})
@@ -197,8 +195,6 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  private readonly shortcuts = appContext.shortcutsService;
-
   constructor() {
     super();
     this.query = (input: string) => this._getSearchSuggestions(input);
@@ -245,10 +241,8 @@
     }
   }
 
-  override keyboardShortcuts() {
-    return {
-      [Shortcut.SEARCH]: '_handleSearch',
-    };
+  override keyboardShortcuts(): ShortcutListener[] {
+    return [listen(Shortcut.SEARCH, _ => this._handleSearch())];
   }
 
   _valueChanged(value: string) {
@@ -396,16 +390,7 @@
     });
   }
 
-  _handleSearch(e: IronKeyboardEvent) {
-    const keyboardEvent = e.detail.keyboardEvent;
-    if (
-      this.shortcuts.shouldSuppress(e) ||
-      (this.shortcuts.modifierPressed(e) && !keyboardEvent.shiftKey)
-    ) {
-      return;
-    }
-
-    e.preventDefault();
+  _handleSearch() {
     this.$.searchInput.focus();
     this.$.searchInput.selectAll();
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index b99a17b..fc22a58 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -580,7 +580,10 @@
   /**
    * Computes a number of unresolved comment threads in a given file and path.
    */
-  computeUnresolvedNum(file: PatchSetFile | PatchNumOnly) {
+  computeUnresolvedNum(
+    file: PatchSetFile | PatchNumOnly,
+    ignorePatchsetLevelComments?: boolean
+  ) {
     let comments: Comment[] = [];
     let drafts: Comment[] = [];
 
@@ -595,7 +598,11 @@
 
     comments = comments.concat(drafts);
     const threads = createCommentThreads(comments);
-    const unresolvedThreads = threads.filter(isUnresolved);
+    let unresolvedThreads = threads.filter(isUnresolved);
+    if (ignorePatchsetLevelComments)
+      unresolvedThreads = unresolvedThreads.filter(
+        thread => !isPatchsetLevel(thread)
+      );
     return unresolvedThreads.length;
   }
 
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 b1bad1c..a828b9c 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
@@ -88,10 +88,11 @@
 import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
 import {Timing} from '../../../constants/reporting';
 import {changeComments$} from '../../../services/comments/comments-model';
-import {takeUntil} from 'rxjs/operators';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api';
 import {Subject} from 'rxjs';
 import {RenderPreferences} from '../../../api/diff';
+import {diffViewMode$} from '../../../services/browser/browser-model';
+import {takeUntil} from 'rxjs/operators';
 
 const EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -205,12 +206,12 @@
   @property({type: Boolean})
   lineWrapping = false;
 
-  @property({type: String})
-  viewMode = DiffViewMode.SIDE_BY_SIDE;
-
   @property({type: Object})
   lineOfInterest?: LineOfInterest;
 
+  @property({type: String})
+  viewMode = DiffViewMode.SIDE_BY_SIDE;
+
   @property({type: Boolean})
   showLoadFailure?: boolean;
 
@@ -312,6 +313,9 @@
 
   override connectedCallback() {
     super.connectedCallback();
+    diffViewMode$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(diffView => (this.viewMode = diffView));
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index b47c51c..3d43ef3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -26,6 +26,9 @@
 import {FixIronA11yAnnouncer} from '../../../types/types';
 import {appContext} from '../../../services/app-context';
 import {fireIronAnnounce} from '../../../utils/event-util';
+import {diffViewMode$} from '../../../services/browser/browser-model';
+import {Subject} from 'rxjs';
+import {takeUntil} from 'rxjs/operators';
 
 @customElement('gr-diff-mode-selector')
 export class GrDiffModeSelector extends PolymerElement {
@@ -34,7 +37,7 @@
   }
 
   @property({type: String, notify: true})
-  mode?: DiffViewMode;
+  mode: DiffViewMode = DiffViewMode.SIDE_BY_SIDE;
 
   /**
    * If set to true, the user's preference will be updated every time a
@@ -48,11 +51,24 @@
 
   private readonly userService = appContext.userService;
 
+  disconnected$ = new Subject();
+
+  constructor() {
+    super();
+  }
+
   override connectedCallback() {
     super.connectedCallback();
     (
       IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
     ).requestAvailability();
+    diffViewMode$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(diffView => (this.mode = diffView));
+  }
+
+  override disconnectedCallback() {
+    this.disconnected$.next();
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
index 8b06c75..fe5f389 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
@@ -20,6 +20,7 @@
 import {GrDiffModeSelector} from './gr-diff-mode-selector';
 import {DiffViewMode} from '../../../constants/constants';
 import {stubUsers} from '../../../test/test-utils';
+import {_testOnly_setState} from '../../../services/browser/browser-model';
 
 const basicFixture = fixtureFromElement('gr-diff-mode-selector');
 
@@ -47,8 +48,10 @@
   });
 
   test('setMode', () => {
+    _testOnly_setState({screenWidth: 0});
     const saveStub = stubUsers('updatePreferences');
 
+    flush();
     // Setting the mode initially does not save prefs.
     element.saveOnChange = true;
     element.setMode(DiffViewMode.SIDE_BY_SIDE);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 084f9f6..d892c79 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -37,6 +37,7 @@
 import {
   KeyboardShortcutMixin,
   Shortcut,
+  ShortcutListener,
   ShortcutSection,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {
@@ -94,26 +95,22 @@
 import {RevisionInfo as RevisionInfoObj} from '../../shared/revision-info/revision-info';
 import {
   CommentMap,
-  isInBaseOfPatchRange,
   getPatchRangeForCommentUrl,
+  isInBaseOfPatchRange,
 } from '../../../utils/comment-util';
 import {AppElementParams} from '../../gr-app-types';
-import {
-  IronKeyboardEventListener,
-  IronKeyboardEvent,
-  EventType,
-  OpenFixPreviewEvent,
-} from '../../../types/events';
+import {EventType, OpenFixPreviewEvent} from '../../../types/events';
 import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
 import {assertIsDefined} from '../../../utils/common-util';
-import {toggleClass} from '../../../utils/dom-util';
+import {addGlobalShortcut, Key, toggleClass} from '../../../utils/dom-util';
 import {CursorMoveResult} from '../../../api/core';
 import {throttleWrap} from '../../../utils/async-util';
 import {changeComments$} from '../../../services/comments/comments-model';
 import {takeUntil} from 'rxjs/operators';
 import {Subject} from 'rxjs';
 import {preferences$} from '../../../services/user/user-model';
+import {listen} from '../../../services/shortcuts/shortcuts-service';
 
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
 const LOADING_BLAME = 'Loading blame...';
@@ -168,10 +165,7 @@
   @property({type: Object, observer: '_paramsChanged'})
   params?: AppElementParams;
 
-  @property({type: Object})
-  keyEventTarget: HTMLElement = document.body;
-
-  @property({type: Object, notify: true, observer: '_changeViewStateChanged'})
+  @property({type: Object, notify: true})
   changeViewState: Partial<ChangeViewState> = {};
 
   @property({type: Object})
@@ -228,12 +222,6 @@
   @property({type: Object})
   _userPrefs?: PreferencesInfo;
 
-  @property({
-    type: String,
-    computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
-  })
-  _diffMode?: string;
-
   @property({type: Boolean})
   _isImageDiff?: boolean;
 
@@ -281,63 +269,87 @@
     patchNum?: PatchSetNum;
   } = {};
 
-  get keyBindings() {
-    return {
-      esc: '_handleEscKey',
-    };
-  }
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
 
-  override keyboardShortcuts() {
-    return {
-      [Shortcut.LEFT_PANE]: '_handleLeftPane',
-      [Shortcut.RIGHT_PANE]: '_handleRightPane',
-      [Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
-      [Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
-      [Shortcut.VISIBLE_LINE]: '_handleVisibleLine',
-      [Shortcut.NEXT_FILE_WITH_COMMENTS]: '_handleNextLineOrFileWithComments',
-      [Shortcut.PREV_FILE_WITH_COMMENTS]: '_handlePrevLineOrFileWithComments',
-      [Shortcut.NEW_COMMENT]: '_handleNewComment',
-      [Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
-      [Shortcut.NEXT_FILE]: '_handleNextFile',
-      [Shortcut.PREV_FILE]: '_handlePrevFile',
-      [Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
-      [Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
-      [Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
-      [Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
-      [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
-      [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
-      [Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialog',
-      [Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
-      [Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
-      [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
-      [Shortcut.TOGGLE_FILE_REVIEWED]: '_throttledToggleFileReviewed',
-      [Shortcut.TOGGLE_ALL_DIFF_CONTEXT]: '_handleToggleAllDiffContext',
-      [Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
-      [Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
-      [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
-        '_handleToggleHideAllCommentThreads',
-      [Shortcut.OPEN_FILE_LIST]: '_handleOpenFileList',
-      [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
-      [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
-      [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
-      [Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
-      [Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
-
-      // Final two are actually handled by gr-comment-thread.
-      [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
-      [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
-    };
+  override keyboardShortcuts(): ShortcutListener[] {
+    return [
+      listen(Shortcut.LEFT_PANE, _ => this.cursor.moveLeft()),
+      listen(Shortcut.RIGHT_PANE, _ => this.cursor.moveRight()),
+      listen(Shortcut.NEXT_LINE, _ => this._handleNextLine()),
+      listen(Shortcut.PREV_LINE, _ => this._handlePrevLine()),
+      listen(Shortcut.VISIBLE_LINE, _ => this.cursor.moveToVisibleArea()),
+      listen(Shortcut.NEXT_FILE_WITH_COMMENTS, _ =>
+        this._moveToNextFileWithComment()
+      ),
+      listen(Shortcut.PREV_FILE_WITH_COMMENTS, _ =>
+        this._moveToPreviousFileWithComment()
+      ),
+      listen(Shortcut.NEW_COMMENT, _ => this._handleNewComment()),
+      listen(Shortcut.SAVE_COMMENT, _ => {}),
+      listen(Shortcut.NEXT_FILE, _ => this._handleNextFile()),
+      listen(Shortcut.PREV_FILE, _ => this._handlePrevFile()),
+      listen(Shortcut.NEXT_CHUNK, _ => this._handleNextChunk()),
+      listen(Shortcut.PREV_CHUNK, _ => this._handlePrevChunk()),
+      listen(Shortcut.NEXT_COMMENT_THREAD, _ =>
+        this._handleNextCommentThread()
+      ),
+      listen(Shortcut.PREV_COMMENT_THREAD, _ =>
+        this._handlePrevCommentThread()
+      ),
+      listen(Shortcut.OPEN_REPLY_DIALOG, _ => this._handleOpenReplyDialog()),
+      listen(Shortcut.TOGGLE_LEFT_PANE, _ => this._handleToggleLeftPane()),
+      listen(Shortcut.OPEN_DOWNLOAD_DIALOG, _ =>
+        this._handleOpenDownloadDialog()
+      ),
+      listen(Shortcut.UP_TO_CHANGE, _ => this._handleUpToChange()),
+      listen(Shortcut.OPEN_DIFF_PREFS, _ => this._handleCommaKey()),
+      listen(Shortcut.TOGGLE_DIFF_MODE, _ => this._handleToggleDiffMode()),
+      listen(Shortcut.TOGGLE_FILE_REVIEWED, e => {
+        if (this._throttledToggleFileReviewed) {
+          this._throttledToggleFileReviewed(e);
+        }
+      }),
+      listen(Shortcut.TOGGLE_ALL_DIFF_CONTEXT, _ =>
+        this._handleToggleAllDiffContext()
+      ),
+      listen(Shortcut.NEXT_UNREVIEWED_FILE, _ =>
+        this._handleNextUnreviewedFile()
+      ),
+      listen(Shortcut.TOGGLE_BLAME, _ => this._handleToggleBlame()),
+      listen(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, _ =>
+        this._handleToggleHideAllCommentThreads()
+      ),
+      listen(Shortcut.OPEN_FILE_LIST, _ => this._handleOpenFileList()),
+      listen(Shortcut.DIFF_AGAINST_BASE, _ => this._handleDiffAgainstBase()),
+      listen(Shortcut.DIFF_AGAINST_LATEST, _ =>
+        this._handleDiffAgainstLatest()
+      ),
+      listen(Shortcut.DIFF_BASE_AGAINST_LEFT, _ =>
+        this._handleDiffBaseAgainstLeft()
+      ),
+      listen(Shortcut.DIFF_RIGHT_AGAINST_LATEST, _ =>
+        this._handleDiffRightAgainstLatest()
+      ),
+      listen(Shortcut.DIFF_BASE_AGAINST_LATEST, _ =>
+        this._handleDiffBaseAgainstLatest()
+      ),
+      listen(Shortcut.EXPAND_ALL_COMMENT_THREADS, _ => {}), // docOnly
+      listen(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, _ => {}), // docOnly
+    ];
   }
 
   private readonly reporting = appContext.reportingService;
 
   private readonly restApiService = appContext.restApiService;
 
+  private readonly userService = appContext.userService;
+
   private readonly commentsService = appContext.commentsService;
 
   private readonly shortcuts = appContext.shortcutsService;
 
-  _throttledToggleFileReviewed?: IronKeyboardEventListener;
+  _throttledToggleFileReviewed?: (e: KeyboardEvent) => void;
 
   _onRenderHandler?: EventListener;
 
@@ -347,8 +359,8 @@
 
   override connectedCallback() {
     super.connectedCallback();
-    this._throttledToggleFileReviewed = throttleWrap(e =>
-      this._handleToggleFileReviewed(e)
+    this._throttledToggleFileReviewed = throttleWrap(_ =>
+      this._handleToggleFileReviewed()
     );
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
@@ -373,6 +385,12 @@
       this.cursor.reInitCursor();
     };
     this.$.diffHost.addEventListener('render', this._onRenderHandler);
+    this.cleanups.push(
+      addGlobalShortcut(
+        {key: Key.ESC},
+        _ => (this.$.diffHost.displayLine = false)
+      )
+    );
   }
 
   override disconnectedCallback() {
@@ -381,6 +399,8 @@
     if (this._onRenderHandler) {
       this.$.diffHost.removeEventListener('render', this._onRenderHandler);
     }
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
     super.disconnectedCallback();
   }
 
@@ -523,83 +543,20 @@
     );
   }
 
-  _handleToggleFileReviewed(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.shortcuts.modifierPressed(e)) return;
-
-    e.preventDefault();
+  _handleToggleFileReviewed() {
     this._setReviewed(!this.$.reviewed.checked);
   }
 
-  _handleEscKey(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.shortcuts.modifierPressed(e)) return;
-
-    e.preventDefault();
-    this.$.diffHost.displayLine = false;
-  }
-
-  _handleLeftPane(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-
-    e.preventDefault();
-    this.cursor.moveLeft();
-  }
-
-  _handleRightPane(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-
-    e.preventDefault();
-    this.cursor.moveRight();
-  }
-
-  _handlePrevLineOrFileWithComments(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-
-    if (
-      e.detail.keyboardEvent?.shiftKey &&
-      e.detail.keyboardEvent?.keyCode === 75
-    ) {
-      // 'K'
-      this._moveToPreviousFileWithComment();
-      return;
-    }
-    if (this.shortcuts.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _handlePrevLine() {
     this.$.diffHost.displayLine = true;
     this.cursor.moveUp();
   }
 
-  _handleVisibleLine(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-
-    e.preventDefault();
-    this.cursor.moveToVisibleArea();
-  }
-
   _onOpenFixPreview(e: OpenFixPreviewEvent) {
     this.$.applyFixDialog.open(e);
   }
 
-  _handleNextLineOrFileWithComments(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-
-    if (
-      e.detail.keyboardEvent?.shiftKey &&
-      e.detail.keyboardEvent?.keyCode === 74
-    ) {
-      // 'J'
-      this._moveToNextFileWithComment();
-      return;
-    }
-    if (this.shortcuts.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _handleNextLine() {
     this.$.diffHost.displayLine = true;
     this.cursor.moveDown();
   }
@@ -643,59 +600,34 @@
     );
   }
 
-  _handleNewComment(ike: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(ike)) return;
-    if (this.shortcuts.modifierPressed(ike)) return;
-
-    ike.preventDefault();
+  _handleNewComment() {
     this.classList.remove('hideComments');
     this.cursor.createCommentInPlace();
   }
 
-  _handlePrevFile(ike: IronKeyboardEvent) {
-    const ke = ike.detail.keyboardEvent;
-    if (this.shortcuts.shouldSuppress(ike)) return;
-    // Check for meta key to avoid overriding native chrome shortcut.
-    if (ke.metaKey) return;
+  _handlePrevFile() {
     if (!this._path) return;
     if (!this._fileList) return;
-
-    ike.preventDefault();
     this._navToFile(this._path, this._fileList, -1);
   }
 
-  _handleNextFile(ike: IronKeyboardEvent) {
-    const ke = ike.detail.keyboardEvent;
-    if (this.shortcuts.shouldSuppress(ike)) return;
-    // Check for meta key to avoid overriding native chrome shortcut.
-    if (ke.metaKey) return;
+  _handleNextFile() {
     if (!this._path) return;
     if (!this._fileList) return;
-
-    ike.preventDefault();
     this._navToFile(this._path, this._fileList, 1);
   }
 
-  _handleNextChunkOrCommentThread(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
+  _handleNextChunk() {
+    const result = this.cursor.moveToNextChunk();
+    if (result === CursorMoveResult.CLIPPED && this.cursor.isAtEnd()) {
+      this.showToastAndNavigateFile('next', 'n');
+    }
+  }
 
-    e.preventDefault();
-    if (e.detail.keyboardEvent?.shiftKey) {
-      const result = this.cursor.moveToNextCommentThread();
-      if (result === CursorMoveResult.CLIPPED) {
-        this._navigateToNextFileWithCommentThread();
-      }
-    } else {
-      if (this.shortcuts.modifierPressed(e)) return;
-      const result = this.cursor.moveToNextChunk();
-      // navigate to next file if key is not being held down
-      if (
-        !e.detail.keyboardEvent?.repeat &&
-        result === CursorMoveResult.CLIPPED &&
-        this.cursor.isAtEnd()
-      ) {
-        this.showToastAndNavigateFile('next', 'n');
-      }
+  _handleNextCommentThread() {
+    const result = this.cursor.moveToNextCommentThread();
+    if (result === CursorMoveResult.CLIPPED) {
+      this._navigateToNextFileWithCommentThread();
     }
   }
 
@@ -737,25 +669,19 @@
     this._navToFile(this._path, unreviewedFiles, direction === 'next' ? 1 : -1);
   }
 
-  _handlePrevChunkOrCommentThread(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-
-    e.preventDefault();
-    if (e.detail.keyboardEvent?.shiftKey) {
-      this.cursor.moveToPreviousCommentThread();
-    } else {
-      if (this.shortcuts.modifierPressed(e)) return;
-      this.cursor.moveToPreviousChunk();
-      if (!e.detail.keyboardEvent?.repeat && this.cursor.isAtStart()) {
-        this.showToastAndNavigateFile('previous', 'p');
-      }
+  _handlePrevChunk() {
+    this.cursor.moveToPreviousChunk();
+    if (this.cursor.isAtStart()) {
+      this.showToastAndNavigateFile('previous', 'p');
     }
   }
 
+  _handlePrevCommentThread() {
+    this.cursor.moveToPreviousCommentThread();
+  }
+
   // Similar to gr-change-view._handleOpenReplyDialog
-  _handleOpenReplyDialog(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.shortcuts.modifierPressed(e)) return;
+  _handleOpenReplyDialog() {
     this._getLoggedIn().then(isLoggedIn => {
       if (!isLoggedIn) {
         fireEvent(this, 'show-auth-required');
@@ -763,54 +689,36 @@
       }
 
       this.set('changeViewState.showReplyDialog', true);
-      e.preventDefault();
       this._navToChangeView();
     });
   }
 
-  _handleToggleLeftPane(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-    if (!e.detail.keyboardEvent?.shiftKey) return;
-
-    e.preventDefault();
+  _handleToggleLeftPane() {
     this.$.diffHost.toggleLeftDiff();
   }
 
-  _handleOpenDownloadDialog(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.shortcuts.modifierPressed(e)) return;
-
+  _handleOpenDownloadDialog() {
     this.set('changeViewState.showDownloadDialog', true);
-    e.preventDefault();
     this._navToChangeView();
   }
 
-  _handleUpToChange(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.shortcuts.modifierPressed(e)) return;
-
-    e.preventDefault();
+  _handleUpToChange() {
     this._navToChangeView();
   }
 
-  _handleCommaKey(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.shortcuts.modifierPressed(e)) return;
+  _handleCommaKey() {
     if (!this._loggedIn) return;
-
-    e.preventDefault();
     this.$.diffPreferencesDialog.open();
   }
 
-  _handleToggleDiffMode(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.shortcuts.modifierPressed(e)) return;
-
-    e.preventDefault();
-    if (this._getDiffViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.modeSelect.setMode(DiffViewMode.UNIFIED);
+  _handleToggleDiffMode() {
+    if (!this._userPrefs) return;
+    if (this._userPrefs.diff_view === DiffViewMode.SIDE_BY_SIDE) {
+      this.userService.updatePreferences({diff_view: DiffViewMode.UNIFIED});
     } else {
-      this.$.modeSelect.setMode(DiffViewMode.SIDE_BY_SIDE);
+      this.userService.updatePreferences({
+        diff_view: DiffViewMode.SIDE_BY_SIDE,
+      });
     }
   }
 
@@ -1217,17 +1125,6 @@
       });
   }
 
-  _changeViewStateChanged(changeViewState: Partial<ChangeViewState>) {
-    if (changeViewState.diffMode === null) {
-      // If screen size is small, always default to unified view.
-      this.restApiService.getPreferences().then(prefs => {
-        if (prefs) {
-          this.set('changeViewState.diffMode', prefs.default_diff_view);
-        }
-      });
-    }
-  }
-
   @observe('_path', '_prefs', '_reviewedFiles', '_patchRange')
   _setReviewedObserver(
     path?: string,
@@ -1442,29 +1339,6 @@
     this.$.diffPreferencesDialog.open();
   }
 
-  /**
-   * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
-   * the current state.
-   *
-   * The expected behavior is to use the mode specified in the user's
-   * preferences unless they have manually chosen the alternative view or they
-   * are on a mobile device. If the user navigates up to the change view, it
-   * should clear this choice and revert to the preference the next time a
-   * diff is viewed.
-   *
-   * Use side-by-side if the user is not logged in.
-   */
-  _getDiffViewMode() {
-    if (this.changeViewState.diffMode) {
-      return this.changeViewState.diffMode;
-    } else if (this._userPrefs) {
-      this.set('changeViewState.diffMode', this._userPrefs.default_diff_view);
-      return this._userPrefs.default_diff_view;
-    } else {
-      return 'SIDE_BY_SIDE';
-    }
-  }
-
   _computeModeSelectHideClass(diff?: DiffInfo) {
     return !diff || diff.binary ? 'hide' : '';
   }
@@ -1689,28 +1563,19 @@
     this._loadBlame();
   }
 
-  _handleToggleBlame(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.shortcuts.modifierPressed(e)) return;
-
+  _handleToggleBlame() {
     this._toggleBlame();
   }
 
-  _handleToggleHideAllCommentThreads(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.shortcuts.modifierPressed(e)) return;
-
+  _handleToggleHideAllCommentThreads() {
     toggleClass(this, 'hideComments');
   }
 
-  _handleOpenFileList(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-    if (this.shortcuts.modifierPressed(e)) return;
+  _handleOpenFileList() {
     this.$.dropdown.open();
   }
 
-  _handleDiffAgainstBase(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
+  _handleDiffAgainstBase() {
     if (!this._change) return;
     if (!this._path) return;
     if (!this._patchRange) return;
@@ -1726,8 +1591,7 @@
     );
   }
 
-  _handleDiffBaseAgainstLeft(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
+  _handleDiffBaseAgainstLeft() {
     if (!this._change) return;
     if (!this._path) return;
     if (!this._patchRange) return;
@@ -1747,8 +1611,7 @@
     );
   }
 
-  _handleDiffAgainstLatest(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
+  _handleDiffAgainstLatest() {
     if (!this._change) return;
     if (!this._path) return;
     if (!this._patchRange) return;
@@ -1767,8 +1630,7 @@
     );
   }
 
-  _handleDiffRightAgainstLatest(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
+  _handleDiffRightAgainstLatest() {
     if (!this._change) return;
     if (!this._path) return;
     if (!this._patchRange) return;
@@ -1786,8 +1648,7 @@
     );
   }
 
-  _handleDiffBaseAgainstLatest(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
+  _handleDiffBaseAgainstLatest() {
     if (!this._change) return;
     if (!this._path) return;
     if (!this._patchRange) return;
@@ -1824,14 +1685,11 @@
     return '';
   }
 
-  _handleToggleAllDiffContext(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
-
+  _handleToggleAllDiffContext() {
     this.$.diffHost.toggleAllContext();
   }
 
-  _handleNextUnreviewedFile(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
+  _handleNextUnreviewedFile() {
     this._setReviewed(true);
     this.navigateToUnreviewedFile('next');
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
index 308c353..16adb45 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
@@ -339,7 +339,6 @@
           <gr-diff-mode-selector
             id="modeSelect"
             save-on-change="[[_loggedIn]]"
-            mode="{{changeViewState.diffMode}}"
             show-tooltip-below=""
           ></gr-diff-mode-selector>
         </div>
@@ -409,7 +408,6 @@
     path="[[_path]]"
     prefs="[[_prefs]]"
     project-name="[[_change.project]]"
-    view-mode="[[_diffMode]]"
     is-blame-loaded="{{_isBlameLoaded}}"
     on-comment-anchor-tap="_onLineSelected"
     on-line-selected="_onLineSelected"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index cc35c3c..cc9aac0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -18,8 +18,8 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-diff-view.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {ChangeStatus} from '../../../constants/constants.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import {ChangeStatus, DiffViewMode} from '../../../constants/constants.js';
+import {stubRestApi, stubUsers} from '../../../test/test-utils.js';
 import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
 import {GerritView} from '../../../services/router/router-model.js';
 import {
@@ -30,11 +30,10 @@
 import {EditPatchSetNum} from '../../../types/common.js';
 import {CursorMoveResult} from '../../../api/core.js';
 import {EventType} from '../../../types/events.js';
+import {_testOnly_resetState, _testOnly_setState} from '../../../services/browser/browser-model.js';
 
 const basicFixture = fixtureFromElement('gr-diff-view');
 
-const blankFixture = fixtureFromElement('div');
-
 suite('gr-diff-view tests', () => {
   suite('basic tests', () => {
     let element;
@@ -70,6 +69,8 @@
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
       stubRestApi('getPortedComments').returns(Promise.resolve({}));
 
+      _testOnly_resetState();
+
       element = basicFixture.instantiate();
       element._changeNum = '42';
       element._path = 'some/path.txt';
@@ -426,12 +427,13 @@
     test('toggle left diff with a hotkey', () => {
       const toggleLeftDiffStub = sinon.stub(
           element.$.diffHost, 'toggleLeftDiff');
-      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'A');
       assert.isTrue(toggleLeftDiffStub.calledOnce);
     });
 
     test('keyboard shortcuts', () => {
       element._changeNum = '42';
+      _testOnly_setState({screenWidth: 0});
       element._patchRange = {
         basePatchNum: PARENT,
         patchNum: 10,
@@ -498,40 +500,47 @@
       assert(scrollStub.calledOnce);
 
       scrollStub = sinon.stub(element.cursor, 'moveToNextCommentThread');
-      MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+      MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
       assert(scrollStub.calledOnce);
 
       scrollStub = sinon.stub(element.cursor,
           'moveToPreviousCommentThread');
-      MockInteractions.pressAndReleaseKeyOn(element, 80, 'shift', 'p');
+      MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'P');
       assert(scrollStub.calledOnce);
 
       const computeContainerClassStub = sinon.stub(element.$.diffHost.$.diff,
           '_computeContainerClass');
       MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
       assert(computeContainerClassStub.lastCall.calledWithExactly(
-          false, 'SIDE_BY_SIDE', true));
+          false, DiffViewMode.SIDE_BY_SIDE, true));
 
-      MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
+      MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'Escape');
       assert(computeContainerClassStub.lastCall.calledWithExactly(
-          false, 'SIDE_BY_SIDE', false));
+          false, DiffViewMode.SIDE_BY_SIDE, false));
 
+      // Note that stubbing _setReviewed means that the value of the
+      // `element.$.reviewed` checkbox is not flipped.
       sinon.stub(element, '_setReviewed');
       sinon.spy(element, '_handleToggleFileReviewed');
       element.$.reviewed.checked = false;
-      MockInteractions.keyUpOn(element, 82, 'shift', 'r');
+      assert.isFalse(element._handleToggleFileReviewed.called);
       assert.isFalse(element._setReviewed.called);
-      assert.isTrue(element._handleToggleFileReviewed.calledOnce);
 
-      MockInteractions.keyUpOn(element, 82, null, 'r');
+      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
       assert.isTrue(element._handleToggleFileReviewed.calledOnce);
+      assert.isTrue(element._setReviewed.calledOnce);
+      assert.equal(element._setReviewed.lastCall.args[0], true);
+
+      // Handler is throttled, so another key press within 500 ms is ignored.
+      clock.tick(100);
+      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+      assert.isTrue(element._handleToggleFileReviewed.calledOnce);
+      assert.isTrue(element._setReviewed.calledOnce);
 
       clock.tick(1000);
-
-      MockInteractions.keyUpOn(element, 82, null, 'r');
+      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
       assert.isTrue(element._handleToggleFileReviewed.calledTwice);
-      assert.isTrue(element._setReviewed.called);
-      assert.equal(element._setReviewed.lastCall.args[0], true);
+      assert.isTrue(element._setReviewed.calledTwice);
     });
 
     test('moveToNextCommentThread navigates to next file', () => {
@@ -563,14 +572,14 @@
       element.changeViewState.selectedFileIndex = 1;
       element._loggedIn = true;
 
-      MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+      MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
       flush();
       assert.isTrue(diffNavStub.calledWithExactly(
           element._change, 'wheatley.md', 10, PARENT, 21));
 
       element._path = 'wheatley.md'; // navigated to next file
 
-      MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+      MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
       flush();
 
       assert.isTrue(diffChangeStub.called);
@@ -578,7 +587,7 @@
 
     test('shift+x shortcut toggles all diff context', () => {
       const toggleStub = sinon.stub(element.$.diffHost, 'toggleAllContext');
-      MockInteractions.pressAndReleaseKeyOn(element, 88, 'shift', 'x');
+      MockInteractions.pressAndReleaseKeyOn(element, 88, null, 'X');
       flush();
       assert.isTrue(toggleStub.called);
     });
@@ -691,7 +700,7 @@
       sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
       const loggedInErrorSpy = sinon.spy();
       element.addEventListener('show-auth-required', loggedInErrorSpy);
-      MockInteractions.keyUpOn(element, 65, null, 'a');
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       await flush();
       assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
         'should only work when the user is logged in.');
@@ -717,7 +726,7 @@
       sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
       const loggedInErrorSpy = sinon.spy();
       element.addEventListener('show-auth-required', loggedInErrorSpy);
-      MockInteractions.keyUpOn(element, 65, null, 'a');
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       await flush();
       assert.isTrue(element.changeViewState.showReplyDialog);
       assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
@@ -743,7 +752,7 @@
           sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
           const loggedInErrorSpy = sinon.spy();
           element.addEventListener('show-auth-required', loggedInErrorSpy);
-          MockInteractions.keyUpOn(element, 65, null, 'a');
+          MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
           await flush();
           assert.isTrue(element.changeViewState.showReplyDialog);
           assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
@@ -807,7 +816,7 @@
       'Should navigate to /c/42/5..10');
 
       assert.isUndefined(element.changeViewState.showDownloadDialog);
-      MockInteractions.keyUpOn(element, 68, null, 'd');
+      MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
       assert.isTrue(element.changeViewState.showDownloadDialog);
     });
 
@@ -1301,47 +1310,23 @@
     test('diff mode selector correctly toggles the diff', () => {
       const select = element.$.modeSelect;
       const diffDisplay = element.$.diffHost;
-      element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
+      element._userPrefs = {diff_view: DiffViewMode.SIDE_BY_SIDE};
+      _testOnly_setState({screenWidth: 0});
 
+      const userStub = stubUsers('updatePreferences');
+
+      flush();
       // The mode selected in the view state reflects the selected option.
-      assert.equal(element._getDiffViewMode(), select.mode);
+      // assert.equal(element._userPrefs.diff_view, select.mode);
 
       // The mode selected in the view state reflects the view rednered in the
       // diff.
       assert.equal(select.mode, diffDisplay.viewMode);
 
       // We will simulate a user change of the selected mode.
-      const newMode = 'UNIFIED_DIFF';
-
-      // Set the mode, and simulate the change event.
-      element.set('changeViewState.diffMode', newMode);
-
-      // Make sure the handler was called and the state is still coherent.
-      assert.equal(element._getDiffViewMode(), newMode);
-      assert.equal(element._getDiffViewMode(), select.mode);
-      assert.equal(element._getDiffViewMode(), diffDisplay.viewMode);
-    });
-
-    test('diff mode selector initializes from preferences', () => {
-      let resolvePrefs;
-      const prefsPromise = new Promise(resolve => {
-        resolvePrefs = resolve;
-      });
-      stubRestApi('getPreferences')
-          .callsFake(() => prefsPromise);
-
-      // Attach a new gr-diff-view so we can intercept the preferences fetch.
-      const view = document.createElement('gr-diff-view');
-      blankFixture.instantiate().appendChild(view);
-      flush();
-
-      // At this point the diff mode doesn't yet have the user's preference.
-      assert.equal(view._getDiffViewMode(), 'SIDE_BY_SIDE');
-
-      // Receive the overriding preference.
-      resolvePrefs({default_diff_view: 'UNIFIED'});
-      flush();
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+      element._handleToggleDiffMode();
+      assert.isTrue(userStub.calledWithExactly({
+        diff_view: DiffViewMode.UNIFIED}));
     });
 
     test('diff mode selector should be hidden for binary', async () => {
@@ -1502,32 +1487,22 @@
       assert.isTrue(getUrlStub.lastCall.args[6]);
     });
 
-    test('_getDiffViewMode', () => {
-      // No user prefs or change view state set.
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-
-      // User prefs but no change view state set.
-      element.changeViewState.diffMode = undefined;
-      element._userPrefs = {default_diff_view: 'UNIFIED_DIFF'};
-      assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
-
-      // User prefs and change view state set.
-      element.changeViewState = {diffMode: 'SIDE_BY_SIDE'};
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-    });
-
     test('_handleToggleDiffMode', () => {
+      const userStub = stubUsers('updatePreferences');
       const e = new CustomEvent('keydown', {
         detail: {keyboardEvent: new KeyboardEvent('keydown'), key: 'x'},
       });
-      // Initial state.
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+      element._userPrefs = {diff_view: DiffViewMode.SIDE_BY_SIDE};
 
       element._handleToggleDiffMode(e);
-      assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
+      assert.deepEqual(userStub.lastCall.args[0], {
+        diff_view: DiffViewMode.UNIFIED});
+
+      element._userPrefs = {diff_view: DiffViewMode.UNIFIED};
 
       element._handleToggleDiffMode(e);
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+      assert.deepEqual(userStub.lastCall.args[0], {
+        diff_view: DiffViewMode.SIDE_BY_SIDE});
     });
 
     suite('_initPatchRange', () => {
@@ -1730,7 +1705,7 @@
       test('toggle blame with shortcut', () => {
         const toggleBlame = sinon.stub(
             element.$.diffHost, 'loadBlame').callsFake(() => Promise.resolve());
-        MockInteractions.keyUpOn(element, 66, null, 'b');
+        MockInteractions.pressAndReleaseKeyOn(element, 66, null, 'b');
         assert.isTrue(toggleBlame.calledOnce);
       });
     });
@@ -1890,7 +1865,7 @@
       element._path = 'file1';
       const reviewedStub = sinon.stub(element, '_setReviewed');
       const navStub = sinon.stub(element, '_navToFile');
-      MockInteractions.pressAndReleaseKeyOn(element, 77, 'shift', 'm');
+      MockInteractions.pressAndReleaseKeyOn(element, 77, null, 'M');
       flush();
 
       assert.isTrue(reviewedStub.lastCall.args[0]);
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 0d6cadc..857ffa2 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
@@ -381,7 +381,10 @@
     );
     const commentThreadString = pluralize(commentThreadCount, 'comment');
 
-    const unresolvedCount = changeComments.computeUnresolvedNum({patchNum});
+    const unresolvedCount = changeComments.computeUnresolvedNum(
+      {patchNum},
+      true
+    );
     const unresolvedString =
       unresolvedCount === 0 ? '' : `${unresolvedCount} unresolved`;
 
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
index 0fe1fe2..28ebbac 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
@@ -351,15 +351,15 @@
       ],
       abc: [],
       // Patchset level comment does not contribute to the count
-      [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: {
+      [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
         id: '27dcee4d_f7b77cfa',
         message: 'test',
         patch_set: 1,
         unresolved: true,
         updated: '2017-10-11 20:48:40.000000000',
-      },
+      }],
     };
-    element.changeComments = new ChangeComments(comments, {}, {}, 123);
+    element.changeComments = new ChangeComments(comments);
 
     assert.equal(element._computePatchSetCommentsString(
         element.changeComments, 1), ' (3 comments, 1 unresolved)');
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
index 8821725..551889f 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -47,9 +47,6 @@
    * @event create-comment-requested
    */
 
-  @property({type: Object})
-  keyEventTarget = document.body;
-
   @property({type: Boolean})
   positionBelow = false;
 
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 3adb0f3..173a27e 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
@@ -14,28 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/gr-table-styles';
-import '../../../styles/shared-styles';
 import '../../shared/gr-list-view/gr-list-view';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-documentation-search_html';
 import {getBaseUrl} from '../../../utils/url-util';
-import {customElement, property} from '@polymer/decorators';
 import {DocResult} from '../../../types/common';
 import {fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {ListViewParams} from '../../gr-app-types';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {LitElement, PropertyValues, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 @customElement('gr-documentation-search')
-export class GrDocumentationSearch extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrDocumentationSearch extends LitElement {
   /**
    * URL params passed from the router.
    */
-  @property({type: Object, observer: '_paramsChanged'})
+  @property({type: Object})
   params?: ListViewParams;
 
   @property({type: Array})
@@ -54,7 +49,57 @@
     fireTitleChange(this, 'Documentation Search');
   }
 
-  _paramsChanged(params: ListViewParams) {
+  static override get styles() {
+    return [sharedStyles, tableStyles];
+  }
+
+  override render() {
+    return html` <gr-list-view
+      .filter="${this._filter}"
+      .offset="${0}"
+      .loading="${this._loading}"
+      .path="/Documentation"
+    >
+      <table id="list" class="genericList">
+        <tbody>
+          <tr class="headerRow">
+            <th class="name topHeader">Name</th>
+            <th class="name topHeader"></th>
+            <th class="name topHeader"></th>
+          </tr>
+          <tr
+            id="loading"
+            class="loadingMsg ${this.computeLoadingClass(this._loading)}"
+          >
+            <td>Loading...</td>
+          </tr>
+        </tbody>
+        <tbody class="${this.computeLoadingClass(this._loading)}">
+          ${this._documentationSearches?.map(
+            search => html`
+              <tr class="table">
+                <td class="name">
+                  <a href="${this._computeSearchUrl(search.url)}"
+                    >${search.title}</a
+                  >
+                </td>
+                <td></td>
+                <td></td>
+              </tr>
+            `
+          )}
+        </tbody>
+      </table>
+    </gr-list-view>`;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('params')) {
+      this._paramsChanged(this.params);
+    }
+  }
+
+  _paramsChanged(params?: ListViewParams) {
     this._loading = true;
     this._filter = params?.filter ?? '';
 
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
deleted file mode 100644
index 95ce1ec..0000000
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
+++ /dev/null
@@ -1,56 +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-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <gr-list-view
-    filter="[[_filter]]"
-    offset="0"
-    loading="[[_loading]]"
-    path="/Documentation"
-  >
-    <table id="list" class="genericList">
-      <tbody>
-        <tr class="headerRow">
-          <th class="name topHeader">Name</th>
-          <th class="name topHeader"></th>
-          <th class="name topHeader"></th>
-        </tr>
-        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
-          <td>Loading...</td>
-        </tr>
-      </tbody>
-      <tbody class$="[[computeLoadingClass(_loading)]]">
-        <template is="dom-repeat" items="[[_documentationSearches]]">
-          <tr class="table">
-            <td class="name">
-              <a href$="[[_computeSearchUrl(item.url)]]">[[item.title]]</a>
-            </td>
-            <td></td>
-            <td></td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </gr-list-view>
-`;
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
index bf6a0d5..47c83da 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
@@ -20,7 +20,7 @@
 import {GrDocumentationSearch} from './gr-documentation-search';
 import {page} from '../../../utils/page-wrapper-utils';
 import 'lodash/lodash';
-import {stubRestApi} from '../../../test/test-utils';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {DocResult} from '../../../types/common';
 import {ListViewParams} from '../../gr-app-types';
 
@@ -40,10 +40,11 @@
 
   let value: ListViewParams;
 
-  setup(() => {
+  setup(async () => {
     sinon.stub(page, 'show');
     element = basicFixture.instantiate();
     counter = 0;
+    await flush();
   });
 
   suite('list with searches for documentation', () => {
@@ -87,13 +88,19 @@
     test('correct contents are displayed', async () => {
       assert.isTrue(element._loading);
       assert.equal(element.computeLoadingClass(element._loading), 'loading');
-      assert.equal(getComputedStyle(element.$.loading).display, 'block');
+      assert.equal(
+        getComputedStyle(queryAndAssert(element, '#loading')).display,
+        'block'
+      );
 
       element._loading = false;
 
       await flush();
       assert.equal(element.computeLoadingClass(element._loading), '');
-      assert.equal(getComputedStyle(element.$.loading).display, 'none');
+      assert.equal(
+        getComputedStyle(queryAndAssert(element, '#loading')).display,
+        'none'
+      );
     });
   });
 });
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 ad7e015..24ebd67 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
@@ -22,7 +22,6 @@
 import '../../../styles/shared-styles';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-editor-view_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {
   GerritNav,
   GenerateUrlEditViewParameters,
@@ -47,7 +46,7 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrDefaultEditor} from '../gr-default-editor/gr-default-editor';
 import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {IronKeyboardEvent} from '../../../types/events';
+import {addShortcut, Modifier} from '../../../utils/dom-util';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const SAVING_MESSAGE = 'Saving changes...';
@@ -69,11 +68,8 @@
   };
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-editor-view')
-export class GrEditorView extends base {
+export class GrEditorView extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -141,11 +137,8 @@
   // Tests use this so needs to be non private
   storeTask?: DelayedTask;
 
-  get keyBindings() {
-    return {
-      'ctrl+s meta+s': '_handleSaveShortcut',
-    };
-  }
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
 
   constructor() {
     super();
@@ -159,10 +152,22 @@
     this._getEditPrefs().then(prefs => {
       this._prefs = prefs;
     });
+    this.cleanups.push(
+      addShortcut(this, {key: 's', modifiers: [Modifier.CTRL_KEY]}, e =>
+        this._handleSaveShortcut(e)
+      )
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: 's', modifiers: [Modifier.META_KEY]}, e =>
+        this._handleSaveShortcut(e)
+      )
+    );
   }
 
   override disconnectedCallback() {
     this.storeTask?.cancel();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
     super.disconnectedCallback();
   }
 
@@ -394,7 +399,7 @@
     );
   }
 
-  _handleSaveShortcut(e: IronKeyboardEvent) {
+  _handleSaveShortcut(e: KeyboardEvent) {
     e.preventDefault();
     if (!this._saveDisabled) {
       this._saveEdit();
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index 38f55bd..7f7749a 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -43,6 +43,7 @@
 import {
   KeyboardShortcutMixin,
   Shortcut,
+  ShortcutListener,
 } from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {GerritNav} from './core/gr-navigation/gr-navigation';
 import {appContext} from '../services/app-context';
@@ -68,7 +69,6 @@
 import {GrMainHeader} from './core/gr-main-header/gr-main-header';
 import {GrSettingsView} from './settings/gr-settings-view/gr-settings-view';
 import {
-  IronKeyboardEvent,
   DialogChangeEventDetail,
   EventType,
   LocationChangeEvent,
@@ -81,6 +81,7 @@
 import {LifeCycle} from '../constants/reporting';
 import {fireIronAnnounce} from '../utils/event-util';
 import {assertIsDefined} from '../utils/common-util';
+import {listen} from '../services/shortcuts/shortcuts-service';
 
 interface ErrorInfo {
   text: string;
@@ -120,9 +121,6 @@
   @property({type: Object})
   params?: AppElementParams;
 
-  @property({type: Object})
-  keyEventTarget = document.body;
-
   @property({type: Object, observer: '_accountChanged'})
   _account?: AccountDetailInfo;
 
@@ -214,17 +212,21 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  private readonly shortcuts = appContext.shortcutsService;
+  private readonly browserService = appContext.browserService;
 
-  override keyboardShortcuts() {
-    return {
-      [Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
-      [Shortcut.GO_TO_USER_DASHBOARD]: '_goToUserDashboard',
-      [Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges',
-      [Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges',
-      [Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges',
-      [Shortcut.GO_TO_WATCHED_CHANGES]: '_goToWatchedChanges',
-    };
+  override keyboardShortcuts(): ShortcutListener[] {
+    return [
+      listen(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, _ =>
+        this._showKeyboardShortcuts()
+      ),
+      listen(Shortcut.GO_TO_USER_DASHBOARD, _ => this._goToUserDashboard()),
+      listen(Shortcut.GO_TO_OPENED_CHANGES, _ => this._goToOpenedChanges()),
+      listen(Shortcut.GO_TO_MERGED_CHANGES, _ => this._goToMergedChanges()),
+      listen(Shortcut.GO_TO_ABANDONED_CHANGES, _ =>
+        this._goToAbandonedChanges()
+      ),
+      listen(Shortcut.GO_TO_WATCHED_CHANGES, _ => this._goToWatchedChanges()),
+    ];
   }
 
   constructor() {
@@ -252,6 +254,8 @@
       this.handleRecreateView(GerritView.DIFF)
     );
     document.addEventListener(EventType.GR_RPC_LOG, e => this._handleRpcLog(e));
+    const resizeObserver = this.browserService.observeWidth();
+    resizeObserver.observe(this);
   }
 
   override ready() {
@@ -502,8 +506,7 @@
     (this.shadowRoot!.querySelector('#keyboardShortcuts') as GrOverlay).open();
   }
 
-  _showKeyboardShortcuts(e: IronKeyboardEvent) {
-    if (this.shortcuts.shouldSuppress(e)) return;
+  _showKeyboardShortcuts() {
     // same shortcut should close the dialog if pressed again
     // when dialog is open
     this.loadKeyboardShortcutsDialog = true;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
index df5f441..acb8348 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
@@ -62,9 +62,6 @@
   @property({type: String})
   placeholder = '';
 
-  @property({type: Number})
-  suggestFrom = 0;
-
   @property({type: Object, notify: true})
   querySuggestions: AutocompleteQuery = () => Promise.resolve([]);
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts
index c6c2b7f..d84ef62 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts
@@ -28,7 +28,6 @@
     id="input"
     borderless="[[borderless]]"
     placeholder="[[placeholder]]"
-    threshold="[[suggestFrom]]"
     query="[[querySuggestions]]"
     allow-non-suggested-values="[[allowAnyInput]]"
     on-commit="_handleInputCommit"
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 d97e38e..5449981 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
@@ -178,12 +178,6 @@
   @property({type: Object})
   _querySuggestions: (input: string) => Promise<SuggestionItem[]>;
 
-  /**
-   * Set to true to disable suggestions on empty input.
-   */
-  @property({type: Boolean})
-  skipSuggestOnEmpty = false;
-
   reporting: ReportingService;
 
   private pendingRemoval: Set<AccountInput> = new Set();
@@ -206,17 +200,10 @@
   }
 
   _getSuggestions(input: string) {
-    if (this.skipSuggestOnEmpty && !input) {
-      return Promise.resolve([]);
-    }
     const provider = this.suggestionsProvider;
-    if (!provider) {
-      return Promise.resolve([]);
-    }
+    if (!provider) return Promise.resolve([]);
     return provider.getSuggestions(input).then(suggestions => {
-      if (!suggestions) {
-        return [];
-      }
+      if (!suggestions) return [];
       if (this.filter) {
         suggestions = suggestions.filter(this.filter);
       }
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 b667aba..23e5a72 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
@@ -398,53 +398,6 @@
     assert.equal(makeSuggestionItemSpy.getCalls().length, 2);
   });
 
-  test('suggestion on empty', async () => {
-    element.skipSuggestOnEmpty = false;
-    const suggestions: Suggestion[] = [
-      {
-        email: 'abc@example.com' as EmailAddress,
-        text: 'abcd',
-      } as AccountInfo,
-      {
-        email: 'qwe@example.com' as EmailAddress,
-        text: 'qwer',
-      } as AccountInfo,
-    ];
-    const getSuggestionsStub = sinon
-      .stub(suggestionsProvider, 'getSuggestions')
-      .returns(Promise.resolve(suggestions));
-
-    const makeSuggestionItemSpy = sinon.spy(
-      suggestionsProvider,
-      'makeSuggestionItem'
-    );
-
-    const input = element.$.entry.$.input;
-
-    input.text = '';
-    MockInteractions.focus(input.$.input);
-    input.noDebounce = true;
-    await flush();
-    assert.isTrue(getSuggestionsStub.calledOnce);
-    assert.equal(getSuggestionsStub.lastCall.args[0], '');
-    assert.equal(makeSuggestionItemSpy.getCalls().length, 2);
-  });
-
-  test('skip suggestion on empty', async () => {
-    element.skipSuggestOnEmpty = true;
-    const getSuggestionsStub = sinon
-      .stub(suggestionsProvider, 'getSuggestions')
-      .returns(Promise.resolve([]));
-
-    const input = element.$.entry.$.input;
-
-    input.text = '';
-    MockInteractions.focus(input.$.input);
-    input.noDebounce = true;
-    await flush();
-    assert.isTrue(getSuggestionsStub.notCalled);
-  });
-
   suite('allowAnyInput', () => {
     setup(() => {
       element.allowAnyInput = true;
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index a629d0e..e7137e4 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -20,12 +20,12 @@
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-autocomplete-dropdown_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {IronFitMixin} from '../../../mixins/iron-fit-mixin/iron-fit-mixin';
 import {customElement, property, observe} from '@polymer/decorators';
 import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior';
 import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
 import {fireEvent} from '../../../utils/event-util';
+import {addShortcut, Key} from '../../../utils/dom-util';
 
 export interface GrAutocompleteDropdown {
   $: {
@@ -53,10 +53,7 @@
 }
 
 // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = IronFitMixin(
-  KeyboardShortcutMixin(PolymerElement),
-  IronFitBehavior as IronFitBehavior
-);
+const base = IronFitMixin(PolymerElement, IronFitBehavior as IronFitBehavior);
 
 @customElement('gr-autocomplete-dropdown')
 export class GrAutocompleteDropdown extends base {
@@ -91,15 +88,8 @@
   @property({type: Array})
   suggestions: Item[] = [];
 
-  get keyBindings() {
-    return {
-      up: '_handleUp',
-      down: '_handleDown',
-      enter: '_handleEnter',
-      esc: '_handleEscape',
-      tab: '_handleTab',
-    };
-  }
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
 
   // visible for testing
   cursor = new GrCursorManager();
@@ -110,8 +100,29 @@
     this.cursor.focusOnMove = true;
   }
 
+  override connectedCallback() {
+    super.connectedCallback();
+    this.cleanups.push(
+      addShortcut(this, {key: Key.UP}, e => this._handleUp(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.DOWN}, e => this._handleDown(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ESC}, _ => this._handleEscape())
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.TAB}, e => this._handleTab(e))
+    );
+  }
+
   override disconnectedCallback() {
     this.cursor.unsetCursor();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
     super.disconnectedCallback();
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
index bb47dbc0..86de3b3 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
@@ -50,7 +50,7 @@
 
   test('escape key', () => {
     const closeSpy = sinon.spy(element, 'close');
-    MockInteractions.pressAndReleaseKeyOn(element, 27);
+    MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'Escape');
     flush();
     assert.isTrue(closeSpy.called);
   });
@@ -59,7 +59,7 @@
     const handleTabSpy = sinon.spy(element, '_handleTab');
     const itemSelectedStub = sinon.stub();
     element.addEventListener('item-selected', itemSelectedStub);
-    MockInteractions.pressAndReleaseKeyOn(element, 9);
+    MockInteractions.pressAndReleaseKeyOn(element, 9, null, 'Tab');
     assert.isTrue(handleTabSpy.called);
     assert.equal(element.cursor.index, 0);
     assert.isTrue(itemSelectedStub.called);
@@ -73,7 +73,7 @@
     const handleEnterSpy = sinon.spy(element, '_handleEnter');
     const itemSelectedStub = sinon.stub();
     element.addEventListener('item-selected', itemSelectedStub);
-    MockInteractions.pressAndReleaseKeyOn(element, 13);
+    MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
     assert.isTrue(handleEnterSpy.called);
     assert.equal(element.cursor.index, 0);
     assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
@@ -85,11 +85,11 @@
   test('down key', () => {
     element.isHidden = true;
     const nextSpy = sinon.spy(element.cursor, 'next');
-    MockInteractions.pressAndReleaseKeyOn(element, 40);
+    MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
     assert.isFalse(nextSpy.called);
     assert.equal(element.cursor.index, 0);
     element.isHidden = false;
-    MockInteractions.pressAndReleaseKeyOn(element, 40);
+    MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
     assert.isTrue(nextSpy.called);
     assert.equal(element.cursor.index, 1);
   });
@@ -97,13 +97,13 @@
   test('up key', () => {
     element.isHidden = true;
     const prevSpy = sinon.spy(element.cursor, 'previous');
-    MockInteractions.pressAndReleaseKeyOn(element, 38);
+    MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
     assert.isFalse(prevSpy.called);
     assert.equal(element.cursor.index, 0);
     element.isHidden = false;
     element.cursor.setCursorAtIndex(1);
     assert.equal(element.cursor.index, 1);
-    MockInteractions.pressAndReleaseKeyOn(element, 38);
+    MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
     assert.isTrue(prevSpy.called);
     assert.equal(element.cursor.index, 0);
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index 524b197..8e84aa2 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -22,7 +22,6 @@
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-autocomplete_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {property, customElement, observe} from '@polymer/decorators';
 import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {PaperInputElementExt} from '../../../types/types';
@@ -65,11 +64,8 @@
 export type AutocompleteCommitEvent =
   CustomEvent<AutocompleteCommitEventDetail>;
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-autocomplete')
-export class GrAutocomplete extends base {
+export class GrAutocomplete extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
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 8dc23e2..ea5b5bb 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -88,7 +88,9 @@
           background-color: var(--background-color);
           color: var(--text-color);
           display: flex;
-          font-family: inherit;
+          font-family: var(--font-family, inherit);
+          /** Without this '.keyboard-focus' buttons will get bolded. */
+          font-weight: var(--font-weight-normal, inherit);
           justify-content: center;
           margin: var(--margin, 0);
           min-width: var(--border, 0);
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 e7417f4..6b2e5c4 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
@@ -70,7 +70,7 @@
 import {anyLineTooLong} from '../../diff/gr-diff/gr-diff-utils';
 import {getUserName} from '../../../utils/display-name-util';
 import {generateAbsoluteUrl} from '../../../utils/url-util';
-import {addShortcut} from '../../../utils/dom-util';
+import {addGlobalShortcut} from '../../../utils/dom-util';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
@@ -84,8 +84,6 @@
 
 @customElement('gr-comment-thread')
 export class GrCommentThread extends PolymerElement {
-  // KeyboardShortcutMixin Not used in this element rather other elements tests
-
   static get template() {
     return htmlTemplate;
   }
@@ -239,10 +237,10 @@
   override connectedCallback() {
     super.connectedCallback();
     this.cleanups.push(
-      addShortcut({key: 'e'}, e => this.handleExpandShortcut(e))
+      addGlobalShortcut({key: 'e'}, e => this.handleExpandShortcut(e))
     );
     this.cleanups.push(
-      addShortcut({key: 'E'}, e => this.handleCollapseShortcut(e))
+      addGlobalShortcut({key: 'E'}, e => this.handleCollapseShortcut(e))
     );
     this._getLoggedIn().then(loggedIn => {
       this._showActions = loggedIn;
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 53b3bb9..154a045 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -30,7 +30,6 @@
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-comment_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {getRootElement} from '../../../scripts/rootElement';
 import {appContext} from '../../../services/app-context';
 import {customElement, observe, property} from '@polymer/decorators';
@@ -60,6 +59,7 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {StorageLocation} from '../../../services/storage/gr-storage';
+import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
 
 const STORAGE_DEBOUNCE_INTERVAL = 400;
 const TOAST_DEBOUNCE_INTERVAL = 200;
@@ -100,11 +100,8 @@
   };
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-comment')
-export class GrComment extends base {
+export class GrComment extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -273,12 +270,8 @@
   @property({type: Boolean})
   showPortedComment = false;
 
-  get keyBindings() {
-    return {
-      'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
-      esc: '_handleEsc',
-    };
-  }
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
 
   private readonly restApiService = appContext.restApiService;
 
@@ -307,9 +300,21 @@
     this._getIsAdmin().then(isAdmin => {
       this._isAdmin = !!isAdmin;
     });
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ESC}, e => this._handleEsc(e))
+    );
+    for (const key of ['s', Key.ENTER]) {
+      for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
+        addShortcut(this, {key, modifiers: [modifier]}, e =>
+          this._handleSaveKey(e)
+        );
+      }
+    }
   }
 
   override disconnectedCallback() {
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
     this.fireUpdateTask?.cancel();
     this.storeTask?.cancel();
     this.draftToastTask?.cancel();
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 53e62ff..00edc07 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -322,43 +322,43 @@
         });
 
         test('esc closes comment when text is empty', () => {
-          pressAndReleaseKeyOn(element.textarea!, 27); // esc
+          pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
           assert.isTrue(handleCancelStub.called);
         });
 
         test('ctrl+enter does not save', () => {
-          pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl'); // ctrl + enter
+          pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl', 'Enter');
           assert.isFalse(handleSaveStub.called);
         });
 
         test('meta+enter does not save', () => {
-          pressAndReleaseKeyOn(element.textarea!, 13, 'meta'); // meta + enter
+          pressAndReleaseKeyOn(element.textarea!, 13, 'meta', 'Enter');
           assert.isFalse(handleSaveStub.called);
         });
 
         test('ctrl+s does not save', () => {
-          pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl'); // ctrl + s
+          pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl', 's');
           assert.isFalse(handleSaveStub.called);
         });
       });
 
       test('esc does not close comment that has content', () => {
-        pressAndReleaseKeyOn(element.textarea!, 27); // esc
+        pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
         assert.isFalse(handleCancelStub.called);
       });
 
       test('ctrl+enter saves', () => {
-        pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl'); // ctrl + enter
+        pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl', 'Enter');
         assert.isTrue(handleSaveStub.called);
       });
 
       test('meta+enter saves', () => {
-        pressAndReleaseKeyOn(element.textarea!, 13, 'meta'); // meta + enter
+        pressAndReleaseKeyOn(element.textarea!, 13, 'meta', 'Enter');
         assert.isTrue(handleSaveStub.called);
       });
 
       test('ctrl+s saves', () => {
-        pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl'); // ctrl + s
+        pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl', 's');
         assert.isTrue(handleSaveStub.called);
       });
     });
@@ -1015,7 +1015,7 @@
       element._messageText = '';
       element.editing = true;
       await flush();
-      pressAndReleaseKeyOn(element.textarea!, 27); // esc
+      pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
       await promise;
     });
 
@@ -1093,7 +1093,12 @@
       element._messageText = 'is that the horse from horsing around??';
       element.editing = true;
       await flush();
-      pressAndReleaseKeyOn(element.textarea!.$.textarea.textarea, 83, 'ctrl'); // 'ctrl + s'
+      pressAndReleaseKeyOn(
+        element.textarea!.$.textarea.textarea,
+        83,
+        'ctrl',
+        's'
+      );
       await promise;
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
index f4179f4..2b56de6 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -24,10 +24,10 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-dropdown_html';
 import {getBaseUrl} from '../../../utils/url-util';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
 import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
 import {property, customElement, observe} from '@polymer/decorators';
+import {addShortcut, Key} from '../../../utils/dom-util';
 
 const REL_NOOPENER = 'noopener';
 const REL_EXTERNAL = 'external';
@@ -67,11 +67,8 @@
   bold?: boolean;
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-dropdown')
-export class GrDropdown extends base {
+export class GrDropdown extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -121,14 +118,8 @@
   @property({type: Array})
   disabledIds: string[] = [];
 
-  get keyBindings() {
-    return {
-      down: '_handleDown',
-      'enter space': '_handleEnter',
-      tab: '_handleTab',
-      up: '_handleUp',
-    };
-  }
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
 
   // Used within the tests so needs to be non-private.
   cursor = new GrCursorManager();
@@ -139,15 +130,36 @@
     this.cursor.focusOnMove = true;
   }
 
+  override connectedCallback() {
+    super.connectedCallback();
+    this.cleanups.push(
+      addShortcut(this, {key: Key.UP}, e => this._handleUp(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.DOWN}, e => this._handleDown(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.TAB}, e => this._handleTab(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.SPACE}, e => this._handleEnter(e))
+    );
+  }
+
   override disconnectedCallback() {
     this.cursor.unsetCursor();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
     super.disconnectedCallback();
   }
 
   /**
    * Handle the up key.
    */
-  _handleUp(e: MouseEvent) {
+  _handleUp(e: Event) {
     if (this.$.dropdown.opened) {
       e.preventDefault();
       e.stopPropagation();
@@ -160,7 +172,7 @@
   /**
    * Handle the down key.
    */
-  _handleDown(e: MouseEvent) {
+  _handleDown(e: Event) {
     if (this.$.dropdown.opened) {
       e.preventDefault();
       e.stopPropagation();
@@ -173,7 +185,7 @@
   /**
    * Handle the tab key.
    */
-  _handleTab(e: MouseEvent) {
+  _handleTab(e: Event) {
     if (this.$.dropdown.opened) {
       // Tab in a native select is a no-op. Emulate this.
       e.preventDefault();
@@ -184,7 +196,7 @@
   /**
    * Handle the enter key.
    */
-  _handleEnter(e: MouseEvent) {
+  _handleEnter(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     if (this.$.dropdown.opened) {
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
index e14d523..393f44e 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
@@ -170,18 +170,18 @@
     test('down', () => {
       const stub = sinon.stub(element.cursor, 'next');
       assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 40);
+      MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
       assert.isTrue(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 40);
+      MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
       assert.isTrue(stub.called);
     });
 
     test('up', () => {
       const stub = sinon.stub(element.cursor, 'previous');
       assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 38);
+      MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
       assert.isTrue(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 38);
+      MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
       assert.isTrue(stub.called);
     });
 
@@ -189,7 +189,7 @@
       // Because enter and space are handled by the same fn, we need only to
       // test one.
       assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
+      MockInteractions.pressAndReleaseKeyOn(element, 32, null, ' ');
       assert.isTrue(element.$.dropdown.opened);
 
       const el = queryAndAssert<HTMLAnchorElement>(
@@ -197,7 +197,7 @@
         ':not([hidden]) a'
       );
       const stub = sinon.stub(el, 'click');
-      MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
+      MockInteractions.pressAndReleaseKeyOn(element, 32, null, ' ');
       assert.isTrue(stub.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 13b195e..e0d1d15 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
@@ -20,17 +20,16 @@
 import '../gr-button/gr-button';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {customElement, property} from '@polymer/decorators';
 import {htmlTemplate} from './gr-editable-label_html';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PaperInputElementExt} from '../../../types/types';
-import {IronKeyboardEvent} from '../../../types/events';
 import {
   AutocompleteQuery,
   GrAutocomplete,
 } from '../gr-autocomplete/gr-autocomplete';
+import {addShortcut, Key} from '../../../utils/dom-util';
+import {queryAndAssert} from '../../../utils/common-util';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -47,11 +46,8 @@
   };
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-editable-label')
-export class GrEditableLabel extends base {
+export class GrEditableLabel extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -106,11 +102,23 @@
     this._ensureAttribute('tabindex', '0');
   }
 
-  get keyBindings() {
-    return {
-      enter: '_handleEnter',
-      esc: '_handleEsc',
-    };
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
+
+  override disconnectedCallback() {
+    super.disconnectedCallback();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ESC}, e => this._handleEsc(e))
+    );
   }
 
   _usePlaceholder(value?: string, placeholder?: string) {
@@ -204,20 +212,24 @@
       this.getGrAutocomplete()) as HTMLInputElement;
   }
 
-  _handleEnter(event: IronKeyboardEvent) {
-    const e = event.detail.keyboardEvent;
-    const target = (dom(e) as EventApi).rootTarget;
-    if (target === this._nativeInput) {
-      e.preventDefault();
+  _handleEnter(event: KeyboardEvent) {
+    const inputContainer = queryAndAssert(this, '.inputContainer');
+    const isEventFromInput = event
+      .composedPath()
+      .some(element => element === inputContainer);
+    if (isEventFromInput) {
+      event.preventDefault();
       this._save();
     }
   }
 
-  _handleEsc(event: IronKeyboardEvent) {
-    const e = event.detail.keyboardEvent;
-    const target = (dom(e) as EventApi).rootTarget;
-    if (target === this._nativeInput) {
-      e.preventDefault();
+  _handleEsc(event: KeyboardEvent) {
+    const inputContainer = queryAndAssert(this, '.inputContainer');
+    const isEventFromInput = event
+      .composedPath()
+      .some(element => element === inputContainer);
+    if (isEventFromInput) {
+      event.preventDefault();
       this._cancel();
     }
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
index 3e217f0..b6bb87b 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
@@ -101,7 +101,7 @@
 
     element._inputText = 'new text';
     // Press enter:
-    MockInteractions.keyDownOn(input, 13);
+    MockInteractions.keyDownOn(input, 13, null, 'Enter');
     flush();
 
     assert.isTrue(editedSpy.called);
@@ -122,7 +122,7 @@
 
     element._inputText = 'new text';
     // Press enter:
-    MockInteractions.tap(element.$.saveBtn, 13);
+    MockInteractions.tap(element.$.saveBtn, 13, null, 'Enter');
     flush();
 
     assert.isTrue(editedSpy.called);
@@ -143,7 +143,7 @@
 
     element._inputText = 'new text';
     // Press escape:
-    MockInteractions.keyDownOn(input, 27);
+    MockInteractions.keyDownOn(input, 27, null, 'Escape');
     flush();
 
     assert.isFalse(editedSpy.called);
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index da1a782..1a6239f 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -160,6 +160,8 @@
       <g id="feedback"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 12h-2v-2h2v2zm0-4h-2V6h2v4z"/></g>
       <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=description -->
       <g id="description"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=settings_backup_restore and 0.65 scale and 4 translate https://fonts.google.com/icons?selected=Material+Icons&icon.query=done-->
+      <g id="overridden"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M12 15 zM2 4v6h6V8H5.09C6.47 5.61 9.04 4 12 4c4.42 0 8 3.58 8 8s-3.58 8-8 8-8-3.58-8-8H2c0 5.52 4.48 10 10.01 10C17.53 22 22 17.52 22 12S17.53 2 12.01 2C8.73 2 5.83 3.58 4 6.01V4H2z"/><path xmlns="http://www.w3.org/2000/svg" d="M9.85 14.53 7.12 11.8l-.91.91L9.85 16.35 17.65 8.55l-.91-.91L9.85 14.53z"/></g>
     </defs>
   </svg>
 </iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index 63576c2..c2b0269 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -149,7 +149,6 @@
   createDefaultDiffPrefs,
   createDefaultEditPrefs,
   createDefaultPreferences,
-  DiffViewMode,
   HttpMethod,
   ReviewerState,
 } from '../../../constants/constants';
@@ -159,8 +158,6 @@
 import {FlagsService, KnownExperimentId} from '../../../services/flags/flags';
 
 const MAX_PROJECT_RESULTS = 25;
-// This value is somewhat arbitrary and not based on research or calculations.
-const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
 
 const Requests = {
   SEND_DIFF_DRAFT: 'sendDiffDraft',
@@ -977,13 +974,6 @@
             return res;
           }
           const prefInfo = res as unknown as PreferencesInfo;
-          if (this._isNarrowScreen()) {
-            // Note that this can be problematic, because the diff will stay
-            // unified even after increasing the window width.
-            prefInfo.default_diff_view = DiffViewMode.UNIFIED;
-          } else {
-            prefInfo.default_diff_view = prefInfo.diff_view;
-          }
           return prefInfo;
         });
       }
@@ -1019,10 +1009,6 @@
     });
   }
 
-  _isNarrowScreen() {
-    return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX;
-  }
-
   getChanges(
     changesPerPage?: number,
     query?: string,
@@ -1145,7 +1131,8 @@
   _getChangesOptionsHex() {
     if (
       window.DEFAULT_DETAIL_HEXES &&
-      window.DEFAULT_DETAIL_HEXES.dashboardPage
+      window.DEFAULT_DETAIL_HEXES.dashboardPage &&
+      !this.flagService?.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)
     ) {
       return window.DEFAULT_DETAIL_HEXES.dashboardPage;
     }
@@ -1153,6 +1140,9 @@
       ListChangesOption.LABELS,
       ListChangesOption.DETAILED_ACCOUNTS,
     ];
+    if (this.flagService?.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)) {
+      options.push(ListChangesOption.SUBMIT_REQUIREMENTS);
+    }
 
     return listChangesOptionsToHex(...options);
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
index a60a1ef..6a02985 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
@@ -333,26 +333,23 @@
     stub.lastCall.args[0].errFn({});
   });
 
-  const preferenceSetup = function(testJSON, loggedIn, smallScreen) {
+  const preferenceSetup = function(testJSON, loggedIn) {
     sinon.stub(element, 'getLoggedIn')
         .callsFake(() => Promise.resolve(loggedIn));
-    sinon.stub(element, '_isNarrowScreen').callsFake(() => smallScreen);
     sinon.stub(
         element._restApiHelper,
         'fetchCacheURL')
         .callsFake(() => Promise.resolve(testJSON));
   };
 
-  test('getPreferences returns correctly on small screens logged in',
+  test('getPreferences returns correctly logged in',
       () => {
         const testJSON = {diff_view: 'SIDE_BY_SIDE'};
         const loggedIn = true;
-        const smallScreen = true;
 
-        preferenceSetup(testJSON, loggedIn, smallScreen);
+        preferenceSetup(testJSON, loggedIn);
 
         return element.getPreferences().then(obj => {
-          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
           assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
         });
       });
@@ -361,12 +358,10 @@
       () => {
         const testJSON = {diff_view: 'UNIFIED_DIFF'};
         const loggedIn = true;
-        const smallScreen = false;
 
-        preferenceSetup(testJSON, loggedIn, smallScreen);
+        preferenceSetup(testJSON, loggedIn);
 
         return element.getPreferences().then(obj => {
-          assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
           assert.equal(obj.diff_view, 'UNIFIED_DIFF');
         });
       });
@@ -375,12 +370,10 @@
       () => {
         const testJSON = {diff_view: 'UNIFIED_DIFF'};
         const loggedIn = false;
-        const smallScreen = false;
 
-        preferenceSetup(testJSON, loggedIn, smallScreen);
+        preferenceSetup(testJSON, loggedIn);
 
         return element.getPreferences().then(obj => {
-          assert.equal(obj.default_diff_view, 'SIDE_BY_SIDE');
           assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
         });
       });
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index 434da1f..9e6b42a 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -17,13 +17,11 @@
 import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import '../gr-cursor-manager/gr-cursor-manager';
 import '../gr-overlay/gr-overlay';
-import '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior';
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-textarea_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {appContext} from '../../../services/app-context';
 import {customElement, property} from '@polymer/decorators';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
@@ -33,7 +31,7 @@
   Item,
   ItemSelectedEvent,
 } from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
-import {IronKeyboardEvent} from '../../../types/events';
+import {addShortcut, Key} from '../../../utils/dom-util';
 
 const MAX_ITEMS_DROPDOWN = 10;
 
@@ -85,11 +83,8 @@
   }
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = KeyboardShortcutMixin(PolymerElement);
-
 @customElement('gr-textarea')
-export class GrTextarea extends base {
+export class GrTextarea extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -150,21 +145,39 @@
 
   disableEnterKeyForSelectingEmoji = false;
 
-  get keyBindings() {
-    return {
-      esc: '_handleEscKey',
-      tab: '_handleTabKey',
-      enter: '_handleEnterByKey',
-      up: '_handleUpKey',
-      down: '_handleDownKey',
-    };
-  }
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
 
   constructor() {
     super();
     this.reporting = appContext.reportingService;
   }
 
+  override disconnectedCallback() {
+    super.disconnectedCallback();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.cleanups.push(
+      addShortcut(this, {key: Key.UP}, e => this._handleUpKey(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.DOWN}, e => this._handleDownKey(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.TAB}, e => this._handleTabKey(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER}, e => this._handleEnterByKey(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ESC}, e => this._handleEscKey(e))
+    );
+  }
+
   override ready() {
     super.ready();
     if (this.monospace) {
@@ -238,16 +251,11 @@
     this._setEmoji(this.$.emojiSuggestions.getCurrentText());
   }
 
-  _handleEnterByKey(e: IronKeyboardEvent) {
+  _handleEnterByKey(e: KeyboardEvent) {
     // Enter should have newline behavior if the picker is closed or if the user
     // has only typed ':'. Also make sure that shortcuts aren't clobbered.
     if (this._hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
-      if (
-        !e.detail.keyboardEvent?.metaKey &&
-        !e.detail.keyboardEvent?.ctrlKey
-      ) {
-        this.indent(e);
-      }
+      this.indent(e);
       return;
     }
 
@@ -420,7 +428,7 @@
     );
   }
 
-  private indent(e: IronKeyboardEvent): void {
+  private indent(e: KeyboardEvent): void {
     if (!document.queryCommandSupported('insertText')) {
       return;
     }
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
index 7e59692..318c720 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
@@ -20,7 +20,6 @@
 import {GrTextarea} from './gr-textarea';
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {IronKeyboardEvent} from '../../../types/events';
 import {ItemSelectedEvent} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 
 const basicFixture = fixtureFromElement('gr-textarea');
@@ -238,34 +237,12 @@
     const indentCommand = sinon.stub(document, 'execCommand');
     element.$.textarea.value = '    a';
     element._handleEnterByKey(
-      new CustomEvent('keydown', {
-        detail: {keyboardEvent: {keyCode: 13}},
-      }) as IronKeyboardEvent
+      new KeyboardEvent('keydown', {key: 'Enter', keyCode: 13})
     );
     await flush();
     assert.deepEqual(indentCommand.args[0], ['insertText', false, '\n    ']);
   });
 
-  test('ctrl+enter and meta+enter do not indent', async () => {
-    const indentCommand = sinon.stub(document, 'execCommand');
-    element.$.textarea.value = '    a';
-    element._handleEnterByKey(
-      new CustomEvent('keydown', {
-        detail: {keyboardEvent: {keyCode: 13, ctrlKey: true}},
-      }) as IronKeyboardEvent
-    );
-    await flush();
-    assert.isTrue(indentCommand.notCalled);
-
-    element._handleEnterByKey(
-      new CustomEvent('keydown', {
-        detail: {keyboardEvent: {keyCode: 13, metaKey: true}},
-      }) as IronKeyboardEvent
-    );
-    await flush();
-    assert.isTrue(indentCommand.notCalled);
-  });
-
   test('emoji dropdown is closed when iron-overlay-closed is fired', () => {
     const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
     element.$.emojiSuggestions.dispatchEvent(
@@ -301,38 +278,78 @@
 
     test('escape key', () => {
       const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
+      MockInteractions.pressAndReleaseKeyOn(
+        element.$.textarea,
+        27,
+        null,
+        'Escape'
+      );
       assert.isFalse(resetSpy.called);
       setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
+      MockInteractions.pressAndReleaseKeyOn(
+        element.$.textarea,
+        27,
+        null,
+        'Escape'
+      );
       assert.isTrue(resetSpy.called);
       assert.isFalse(!element.$.emojiSuggestions.isHidden);
     });
 
     test('up key', () => {
       const upSpy = sinon.spy(element.$.emojiSuggestions, 'cursorUp');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
+      MockInteractions.pressAndReleaseKeyOn(
+        element.$.textarea,
+        38,
+        null,
+        'ArrowUp'
+      );
       assert.isFalse(upSpy.called);
       setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
+      MockInteractions.pressAndReleaseKeyOn(
+        element.$.textarea,
+        38,
+        null,
+        'ArrowUp'
+      );
       assert.isTrue(upSpy.called);
     });
 
     test('down key', () => {
       const downSpy = sinon.spy(element.$.emojiSuggestions, 'cursorDown');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
+      MockInteractions.pressAndReleaseKeyOn(
+        element.$.textarea,
+        40,
+        null,
+        'ArrowDown'
+      );
       assert.isFalse(downSpy.called);
       setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
+      MockInteractions.pressAndReleaseKeyOn(
+        element.$.textarea,
+        40,
+        null,
+        'ArrowDown'
+      );
       assert.isTrue(downSpy.called);
     });
 
     test('enter key', () => {
       const enterSpy = sinon.spy(element.$.emojiSuggestions, 'getCursorTarget');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      MockInteractions.pressAndReleaseKeyOn(
+        element.$.textarea,
+        13,
+        null,
+        'Enter'
+      );
       assert.isFalse(enterSpy.called);
       setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      MockInteractions.pressAndReleaseKeyOn(
+        element.$.textarea,
+        13,
+        null,
+        'Enter'
+      );
       assert.isTrue(enterSpy.called);
       flush();
       assert.equal(element.text, '💯');
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
index 793e5d6..fd4da4f 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
@@ -159,7 +159,12 @@
     }
 
     private addTargetEventListeners() {
-      this._target?.addEventListener('mouseenter', this.debounceShow);
+      // We intentionally listen on 'mousemove' instead of 'mouseenter', because
+      // otherwise the target appearing under the mouse cursor would also
+      // trigger the hovercard, which can annoying for the user, for example
+      // when added reviewer chips appear in the reply dialog via keyboard
+      // interaction.
+      this._target?.addEventListener('mousemove', this.debounceShow);
       this._target?.addEventListener('focus', this.debounceShow);
       this._target?.addEventListener('mouseleave', this.debounceHide);
       this._target?.addEventListener('blur', this.debounceHide);
@@ -167,7 +172,7 @@
     }
 
     private removeTargetEventListeners() {
-      this._target?.removeEventListener('mouseenter', this.debounceShow);
+      this._target?.removeEventListener('mousemove', this.debounceShow);
       this._target?.removeEventListener('focus', this.debounceShow);
       this._target?.removeEventListener('mouseleave', this.debounceHide);
       this._target?.removeEventListener('blur', this.debounceHide);
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
index bd12789..dd86b38 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
@@ -130,12 +130,12 @@
   test('card is scheduled to show on enter and hides on leave', async () => {
     const button = document.querySelector('button');
     const enterPromise = mockPromise();
-    button!.addEventListener('mouseenter', () => enterPromise.resolve());
+    button!.addEventListener('mousemove', () => enterPromise.resolve());
     const leavePromise = mockPromise();
     button!.addEventListener('mouseleave', () => leavePromise.resolve());
 
     assert.isFalse(element._isShowing);
-    button!.dispatchEvent(new CustomEvent('mouseenter'));
+    button!.dispatchEvent(new CustomEvent('mousemove'));
 
     await enterPromise;
     await flush();
@@ -158,12 +158,12 @@
     const button = document.querySelector('button');
     const enterPromise = mockPromise();
     const clickPromise = mockPromise();
-    button!.addEventListener('mouseenter', () => enterPromise.resolve());
+    button!.addEventListener('mousemove', () => enterPromise.resolve());
     button!.addEventListener('click', () => clickPromise.resolve());
 
     assert.isFalse(element._isShowing);
 
-    button!.dispatchEvent(new CustomEvent('mouseenter'));
+    button!.dispatchEvent(new CustomEvent('mousemove'));
 
     await enterPromise;
     await flush();
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index 02cfa9f..f133c116 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -14,12 +14,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {IronA11yKeysBehavior} from '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
 import {property} from '@polymer/decorators';
 import {PolymerElement} from '@polymer/polymer';
 import {check, Constructor} from '../../utils/common-util';
-import {IronKeyboardEvent} from '../../types/events';
 import {appContext} from '../../services/app-context';
 import {
   Shortcut,
@@ -27,7 +24,6 @@
   SPECIAL_SHORTCUT,
 } from '../../services/shortcuts/shortcuts-config';
 import {
-  ComboKey,
   SectionView,
   ShortcutListener,
 } from '../../services/shortcuts/shortcuts-service';
@@ -40,18 +36,7 @@
   SectionView,
 };
 
-interface IronA11yKeysMixinConstructor {
-  // Note: this is needed to have same interface as other mixins
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  new (...args: any[]): IronA11yKeysBehavior;
-}
-/**
- * @polymer
- * @mixinFunction
- */
-const InternalKeyboardShortcutMixin = <
-  T extends Constructor<PolymerElement> & IronA11yKeysMixinConstructor
->(
+export const KeyboardShortcutMixin = <T extends Constructor<PolymerElement>>(
   superClass: T
 ) => {
   /**
@@ -59,14 +44,10 @@
    * @mixinClass
    */
   class Mixin extends superClass {
-    @property({type: Object})
-    _shortcut_go_table: Map<string, string> = new Map<string, string>();
-
-    @property({type: Object})
-    _shortcut_v_table: Map<string, string> = new Map<string, string>();
-
+    // This enables `Shortcut` to be used in the html template.
     Shortcut = Shortcut;
 
+    // This enables `ShortcutSection` to be used in the html template.
     ShortcutSection = ShortcutSection;
 
     private readonly shortcuts = appContext.shortcutsService;
@@ -88,30 +69,6 @@
     /** Are shortcuts currently enabled? True only when element is visible. */
     private bindingsEnabled = false;
 
-    _addOwnKeyBindings(shortcut: Shortcut, handler: string) {
-      const bindings = this.shortcuts.getBindingsForShortcut(shortcut);
-      if (!bindings) {
-        return;
-      }
-      if (bindings[0] === SPECIAL_SHORTCUT.DOC_ONLY) {
-        return;
-      }
-      if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
-        bindings
-          .slice(1)
-          .forEach(binding => this._shortcut_go_table.set(binding, handler));
-      } else if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
-        // for each binding added with the go/v key, we set the handler to be
-        // handleVKeyAction. handleVKeyAction then looks up in th
-        // shortcut_table to see what the relevant handler should be
-        bindings
-          .slice(1)
-          .forEach(binding => this._shortcut_v_table.set(binding, handler));
-      } else {
-        this.addOwnKeyBinding(bindings.join(' '), handler);
-      }
-    }
-
     override connectedCallback() {
       super.connectedCallback();
       this.createVisibilityObserver();
@@ -157,28 +114,7 @@
       if (this.bindingsEnabled) return;
       this.bindingsEnabled = true;
 
-      const shortcuts = new Map<string, string>(
-        Object.entries(this.keyboardShortcuts())
-      );
-      this.shortcuts.attachHost(this, shortcuts);
-
-      for (const [key, value] of shortcuts.entries()) {
-        this._addOwnKeyBindings(key as Shortcut, value);
-      }
-
-      // If any of the shortcuts utilized GO_KEY, then they are handled
-      // directly by this behavior.
-      if (this._shortcut_go_table.size > 0) {
-        this._shortcut_go_table.forEach((_, key) => {
-          this.addOwnKeyBinding(key, '_handleGoAction');
-        });
-      }
-
-      if (this._shortcut_v_table.size > 0) {
-        this._shortcut_v_table.forEach((_, key) => {
-          this.addOwnKeyBinding(key, '_handleVAction');
-        });
-      }
+      this.shortcuts.attachHost(this, this.keyboardShortcuts());
     }
 
     /**
@@ -189,76 +125,22 @@
     private disableBindings() {
       if (!this.bindingsEnabled) return;
       this.bindingsEnabled = false;
-      if (this.shortcuts.detachHost(this)) {
-        this.removeOwnKeyBindings();
-      }
+      this.shortcuts.detachHost(this);
     }
 
     private hasKeyboardShortcuts() {
-      return Object.entries(this.keyboardShortcuts()).length > 0;
+      return this.keyboardShortcuts().length > 0;
     }
 
-    keyboardShortcuts() {
-      return {};
-    }
-
-    _handleVAction(e: IronKeyboardEvent) {
-      if (
-        !this.shortcuts.isInSpecificComboKeyMode(ComboKey.V) ||
-        !this._shortcut_v_table.has(e.detail.key) ||
-        this.shortcuts.shouldSuppress(e)
-      ) {
-        return;
-      }
-      e.preventDefault();
-      const handler = this._shortcut_v_table.get(e.detail.key);
-      if (handler) {
-        // TODO(TS): should fix this
-        // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        (this as any)[handler](e);
-      }
-    }
-
-    _handleGoAction(e: IronKeyboardEvent) {
-      if (
-        !this.shortcuts.isInSpecificComboKeyMode(ComboKey.G) ||
-        !this._shortcut_go_table.has(e.detail.key) ||
-        this.shortcuts.shouldSuppress(e)
-      ) {
-        return;
-      }
-      e.preventDefault();
-      const handler = this._shortcut_go_table.get(e.detail.key);
-      if (handler) {
-        // TODO(TS): should fix this
-        // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        (this as any)[handler](e);
-      }
+    keyboardShortcuts(): ShortcutListener[] {
+      return [];
     }
   }
 
   return Mixin as T & Constructor<KeyboardShortcutMixinInterface>;
 };
 
-// The following doesn't work (IronA11yKeysBehavior crashes):
-// const KeyboardShortcutMixin = superClass => {
-//    class Mixin extends mixinBehaviors([IronA11yKeysBehavior], superClass) {
-//    ...
-//    }
-//    return Mixin;
-// }
-// This is a workaround
-export const KeyboardShortcutMixin = <T extends Constructor<PolymerElement>>(
-  superClass: T
-): T & Constructor<KeyboardShortcutMixinInterface> =>
-  InternalKeyboardShortcutMixin(
-    // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
-    // which will fail the type check due to missing IronA11yKeysBehavior interface
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    mixinBehaviors([IronA11yKeysBehavior], superClass) as any
-  );
-
 /** The interface corresponding to KeyboardShortcutMixin */
 export interface KeyboardShortcutMixinInterface {
-  keyboardShortcuts(): {[key: string]: string | null};
+  keyboardShortcuts(): ShortcutListener[];
 }
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 2ad4e79..281a1eb 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -6,7 +6,6 @@
     "@polymer/decorators": "^3.0.0",
     "@polymer/font-roboto-local": "^3.0.2",
     "@polymer/iron-a11y-announcer": "^3.1.0",
-    "@polymer/iron-a11y-keys-behavior": "^3.0.1",
     "@polymer/iron-autogrow-textarea": "^3.0.3",
     "@polymer/iron-dropdown": "^3.0.1",
     "@polymer/iron-fit-behavior": "^3.1.0",
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 3a6f7c5..b9c4f49 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -28,6 +28,7 @@
 import {UserService} from './user/user-service';
 import {CommentsService} from './comments/comments-service';
 import {ShortcutsService} from './shortcuts/shortcuts-service';
+import {BrowserService} from './browser/browser-service';
 
 type ServiceName = keyof AppContext;
 type ServiceCreator<T> = () => T;
@@ -84,5 +85,6 @@
     configService: () => new ConfigService(),
     userService: () => new UserService(appContext.restApiService),
     shortcutsService: () => new ShortcutsService(appContext.reportingService),
+    browserService: () => new BrowserService(),
   });
 }
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index e5828d6..47da722 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -27,6 +27,7 @@
 import {UserService} from './user/user-service';
 import {CommentsService} from './comments/comments-service';
 import {ShortcutsService} from './shortcuts/shortcuts-service';
+import {BrowserService} from './browser/browser-service';
 
 export interface AppContext {
   flagsService: FlagsService;
@@ -41,6 +42,7 @@
   storageService: StorageService;
   configService: ConfigService;
   userService: UserService;
+  browserService: BrowserService;
   shortcutsService: ShortcutsService;
 }
 
diff --git a/polygerrit-ui/app/services/browser/browser-model.ts b/polygerrit-ui/app/services/browser/browser-model.ts
new file mode 100644
index 0000000..db790f6
--- /dev/null
+++ b/polygerrit-ui/app/services/browser/browser-model.ts
@@ -0,0 +1,74 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {BehaviorSubject, Observable, combineLatest} from 'rxjs';
+import {distinctUntilChanged, map} from 'rxjs/operators';
+import {preferenceDiffViewMode$} from '../user/user-model';
+import {DiffViewMode} from '../../api/diff';
+
+// This value is somewhat arbitrary and not based on research or calculations.
+const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
+
+interface BrowserState {
+  /**
+   * We maintain the screen width in the state so that the app can react to
+   * changes in the width such as automatically changing to unified diff view
+   */
+  screenWidth?: number;
+}
+
+const initialState: BrowserState = {};
+
+// Mutable for testing
+let privateState$ = new BehaviorSubject(initialState);
+
+export function _testOnly_resetState() {
+  privateState$ = new BehaviorSubject(initialState);
+}
+
+export function _testOnly_setState(state: BrowserState) {
+  privateState$.next(state);
+}
+
+export function _testOnly_getState() {
+  return privateState$.getValue();
+}
+
+export const viewState$: Observable<BrowserState> = privateState$;
+
+export function updateStateScreenWidth(screenWidth: number) {
+  privateState$.next({...privateState$.getValue(), screenWidth});
+}
+
+export const isScreenTooSmall$ = viewState$.pipe(
+  map(
+    state =>
+      !!state.screenWidth &&
+      state.screenWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX
+  ),
+  distinctUntilChanged()
+);
+
+export const diffViewMode$: Observable<DiffViewMode> = combineLatest([
+  isScreenTooSmall$,
+  preferenceDiffViewMode$,
+]).pipe(
+  map(([isScreenTooSmall, preferenceDiffViewMode]) => {
+    if (isScreenTooSmall) return DiffViewMode.UNIFIED;
+    else return preferenceDiffViewMode;
+  }, distinctUntilChanged())
+);
diff --git a/polygerrit-ui/app/services/browser/browser-service.ts b/polygerrit-ui/app/services/browser/browser-service.ts
new file mode 100644
index 0000000..d98f8f7
--- /dev/null
+++ b/polygerrit-ui/app/services/browser/browser-service.ts
@@ -0,0 +1,29 @@
+import {updateStateScreenWidth} from './browser-model';
+
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export class BrowserService {
+  /* Observer the screen width so that the app can react to changes to it */
+  observeWidth() {
+    return new ResizeObserver(entries => {
+      entries.forEach(entry => {
+        updateStateScreenWidth(entry.contentRect.width);
+      });
+    });
+  }
+}
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
index bd004d7..3c9e058 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
@@ -16,6 +16,8 @@
  */
 
 /** Enum for all special shortcuts */
+import {ComboKey, Key, Modifier, Binding} from '../../utils/dom-util';
+
 export enum SPECIAL_SHORTCUT {
   DOC_ONLY = 'DOC_ONLY',
   GO_KEY = 'GO_KEY',
@@ -115,7 +117,7 @@
 export interface ShortcutHelpItem {
   shortcut: Shortcut;
   text: string;
-  bindings: string[];
+  bindings: Binding[];
 }
 
 export const config = new Map<ShortcutSection, ShortcutHelpItem[]>();
@@ -124,8 +126,8 @@
   shortcut: Shortcut,
   section: ShortcutSection,
   text: string,
-  binding: string,
-  ...moreBindings: string[]
+  binding: Binding,
+  ...moreBindings: Binding[]
 ) {
   if (!config.has(section)) {
     config.set(section, []);
@@ -136,417 +138,388 @@
   }
 }
 
-describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search', '/');
+describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search', {key: '/'});
 describe(
   Shortcut.OPEN_SHORTCUT_HELP_DIALOG,
   ShortcutSection.EVERYWHERE,
   'Show this dialog',
-  '?'
+  {key: '?'}
 );
 describe(
   Shortcut.GO_TO_USER_DASHBOARD,
   ShortcutSection.EVERYWHERE,
   'Go to User Dashboard',
-  SPECIAL_SHORTCUT.GO_KEY,
-  'i'
+  {key: 'i', combo: ComboKey.G}
 );
 describe(
   Shortcut.GO_TO_OPENED_CHANGES,
   ShortcutSection.EVERYWHERE,
   'Go to Opened Changes',
-  SPECIAL_SHORTCUT.GO_KEY,
-  'o'
+  {key: 'o', combo: ComboKey.G}
 );
 describe(
   Shortcut.GO_TO_MERGED_CHANGES,
   ShortcutSection.EVERYWHERE,
   'Go to Merged Changes',
-  SPECIAL_SHORTCUT.GO_KEY,
-  'm'
+  {key: 'm', combo: ComboKey.G}
 );
 describe(
   Shortcut.GO_TO_ABANDONED_CHANGES,
   ShortcutSection.EVERYWHERE,
   'Go to Abandoned Changes',
-  SPECIAL_SHORTCUT.GO_KEY,
-  'a'
+  {key: 'a', combo: ComboKey.G}
 );
 describe(
   Shortcut.GO_TO_WATCHED_CHANGES,
   ShortcutSection.EVERYWHERE,
   'Go to Watched Changes',
-  SPECIAL_SHORTCUT.GO_KEY,
-  'w'
+  {key: 'w', combo: ComboKey.G}
 );
 
 describe(
   Shortcut.CURSOR_NEXT_CHANGE,
   ShortcutSection.ACTIONS,
   'Select next change',
-  'j'
+  {key: 'j'}
 );
 describe(
   Shortcut.CURSOR_PREV_CHANGE,
   ShortcutSection.ACTIONS,
   'Select previous change',
-  'k'
+  {key: 'k'}
 );
 describe(
   Shortcut.OPEN_CHANGE,
   ShortcutSection.ACTIONS,
   'Show selected change',
-  'o'
+  {key: 'o'}
 );
 describe(
   Shortcut.NEXT_PAGE,
   ShortcutSection.ACTIONS,
   'Go to next page',
-  'n',
-  ']'
+  {key: 'n'},
+  {key: ']'}
 );
 describe(
   Shortcut.PREV_PAGE,
   ShortcutSection.ACTIONS,
   'Go to previous page',
-  'p',
-  '['
+  {key: 'p'},
+  {key: '['}
 );
 describe(
   Shortcut.OPEN_REPLY_DIALOG,
   ShortcutSection.ACTIONS,
   'Open reply dialog to publish comments and add reviewers',
-  'a:keyup'
+  {key: 'a'}
 );
 describe(
   Shortcut.OPEN_DOWNLOAD_DIALOG,
   ShortcutSection.ACTIONS,
   'Open download overlay',
-  'd:keyup'
+  {key: 'd'}
 );
 describe(
   Shortcut.EXPAND_ALL_MESSAGES,
   ShortcutSection.ACTIONS,
   'Expand all messages',
-  'x'
+  {key: 'x'}
 );
 describe(
   Shortcut.COLLAPSE_ALL_MESSAGES,
   ShortcutSection.ACTIONS,
   'Collapse all messages',
-  'z'
+  {key: 'z'}
 );
 describe(
   Shortcut.REFRESH_CHANGE,
   ShortcutSection.ACTIONS,
   'Reload the change at the latest patch',
-  'shift+r:keyup'
+  {key: 'R'}
 );
 describe(
   Shortcut.TOGGLE_CHANGE_REVIEWED,
   ShortcutSection.ACTIONS,
   'Mark/unmark change as reviewed',
-  'r:keyup'
+  {key: 'r'}
 );
 describe(
   Shortcut.TOGGLE_FILE_REVIEWED,
   ShortcutSection.ACTIONS,
   'Toggle review flag on selected file',
-  'r:keyup'
+  {key: 'r'}
 );
 describe(
   Shortcut.REFRESH_CHANGE_LIST,
   ShortcutSection.ACTIONS,
   'Refresh list of changes',
-  'shift+r:keyup'
+  {key: 'R'}
 );
 describe(
   Shortcut.TOGGLE_CHANGE_STAR,
   ShortcutSection.ACTIONS,
   'Star/unstar change',
-  's:keydown'
+  {key: 's'}
 );
 describe(
   Shortcut.OPEN_SUBMIT_DIALOG,
   ShortcutSection.ACTIONS,
   'Open submit dialog',
-  'shift+s'
+  {key: 'S'}
 );
 describe(
   Shortcut.TOGGLE_ATTENTION_SET,
   ShortcutSection.ACTIONS,
   'Toggle attention set status',
-  'shift+t'
+  {key: 'T'}
 );
-describe(
-  Shortcut.EDIT_TOPIC,
-  ShortcutSection.ACTIONS,
-  'Add a change topic',
-  't'
-);
+describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS, 'Add a change topic', {
+  key: 't',
+});
 describe(
   Shortcut.DIFF_AGAINST_BASE,
   ShortcutSection.DIFFS,
   'Diff against base',
-  SPECIAL_SHORTCUT.V_KEY,
-  'down',
-  's'
+  {key: Key.DOWN, combo: ComboKey.V},
+  {key: 's', combo: ComboKey.V}
 );
 describe(
   Shortcut.DIFF_AGAINST_LATEST,
   ShortcutSection.DIFFS,
   'Diff against latest patchset',
-  SPECIAL_SHORTCUT.V_KEY,
-  'up',
-  'w'
+  {key: Key.UP, combo: ComboKey.V},
+  {key: 'w', combo: ComboKey.V}
 );
 describe(
   Shortcut.DIFF_BASE_AGAINST_LEFT,
   ShortcutSection.DIFFS,
   'Diff base against left',
-  SPECIAL_SHORTCUT.V_KEY,
-  'left',
-  'a'
+  {key: Key.LEFT, combo: ComboKey.V},
+  {key: 'a', combo: ComboKey.V}
 );
 describe(
   Shortcut.DIFF_RIGHT_AGAINST_LATEST,
   ShortcutSection.DIFFS,
   'Diff right against latest',
-  SPECIAL_SHORTCUT.V_KEY,
-  'right',
-  'd'
+  {key: Key.RIGHT, combo: ComboKey.V},
+  {key: 'd', combo: ComboKey.V}
 );
 describe(
   Shortcut.DIFF_BASE_AGAINST_LATEST,
   ShortcutSection.DIFFS,
   'Diff base against latest',
-  SPECIAL_SHORTCUT.V_KEY,
-  'b'
+  {key: 'b', combo: ComboKey.V}
 );
 
 describe(
   Shortcut.NEXT_LINE,
   ShortcutSection.DIFFS,
   'Go to next line',
-  'j',
-  'down'
+  {key: 'j'},
+  {key: Key.DOWN}
 );
 describe(
   Shortcut.PREV_LINE,
   ShortcutSection.DIFFS,
   'Go to previous line',
-  'k',
-  'up'
+  {key: 'k'},
+  {key: Key.UP}
 );
 describe(
   Shortcut.VISIBLE_LINE,
   ShortcutSection.DIFFS,
   'Move cursor to currently visible code',
-  '.'
+  {key: '.'}
 );
-describe(
-  Shortcut.NEXT_CHUNK,
-  ShortcutSection.DIFFS,
-  'Go to next diff chunk',
-  'n'
-);
+describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS, 'Go to next diff chunk', {
+  key: 'n',
+});
 describe(
   Shortcut.PREV_CHUNK,
   ShortcutSection.DIFFS,
   'Go to previous diff chunk',
-  'p'
+  {key: 'p'}
 );
 describe(
   Shortcut.TOGGLE_ALL_DIFF_CONTEXT,
   ShortcutSection.DIFFS,
   'Toggle all diff context',
-  'shift+x'
+  {key: 'X'}
 );
 describe(
   Shortcut.NEXT_COMMENT_THREAD,
   ShortcutSection.DIFFS,
   'Go to next comment thread',
-  'shift+n'
+  {key: 'N'}
 );
 describe(
   Shortcut.PREV_COMMENT_THREAD,
   ShortcutSection.DIFFS,
   'Go to previous comment thread',
-  'shift+p'
+  {key: 'P'}
 );
 describe(
   Shortcut.EXPAND_ALL_COMMENT_THREADS,
   ShortcutSection.DIFFS,
   'Expand all comment threads',
-  SPECIAL_SHORTCUT.DOC_ONLY,
-  'e'
+  {key: 'e', docOnly: true}
 );
 describe(
   Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
   ShortcutSection.DIFFS,
   'Collapse all comment threads',
-  SPECIAL_SHORTCUT.DOC_ONLY,
-  'shift+e'
+  {key: 'E', docOnly: true}
 );
 describe(
   Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
   ShortcutSection.DIFFS,
   'Hide/Display all comment threads',
-  'h'
+  {key: 'h'}
 );
-describe(
-  Shortcut.LEFT_PANE,
-  ShortcutSection.DIFFS,
-  'Select left pane',
-  'shift+left'
-);
-describe(
-  Shortcut.RIGHT_PANE,
-  ShortcutSection.DIFFS,
-  'Select right pane',
-  'shift+right'
-);
+describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane', {
+  key: Key.LEFT,
+  modifiers: [Modifier.SHIFT_KEY],
+});
+describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane', {
+  key: Key.RIGHT,
+  modifiers: [Modifier.SHIFT_KEY],
+});
 describe(
   Shortcut.TOGGLE_LEFT_PANE,
   ShortcutSection.DIFFS,
   'Hide/show left diff',
-  'shift+a'
+  {key: 'A'}
 );
-describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment', 'c');
+describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment', {
+  key: 'c',
+});
 describe(
   Shortcut.SAVE_COMMENT,
   ShortcutSection.DIFFS,
   'Save comment',
-  'ctrl+enter',
-  'meta+enter',
-  'ctrl+s',
-  'meta+s'
+  {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]},
+  {key: Key.ENTER, modifiers: [Modifier.META_KEY]},
+  {key: 's', modifiers: [Modifier.CTRL_KEY]},
+  {key: 's', modifiers: [Modifier.META_KEY]}
 );
 describe(
   Shortcut.OPEN_DIFF_PREFS,
   ShortcutSection.DIFFS,
   'Show diff preferences',
-  ','
+  {key: ','}
 );
 describe(
   Shortcut.TOGGLE_DIFF_REVIEWED,
   ShortcutSection.DIFFS,
   'Mark/unmark file as reviewed',
-  'r:keyup'
+  {key: 'r'}
 );
 describe(
   Shortcut.TOGGLE_DIFF_MODE,
   ShortcutSection.DIFFS,
   'Toggle unified/side-by-side diff',
-  'm:keyup'
+  {key: 'm'}
 );
 describe(
   Shortcut.NEXT_UNREVIEWED_FILE,
   ShortcutSection.DIFFS,
   'Mark file as reviewed and go to next unreviewed file',
-  'shift+m'
+  {key: 'M'}
 );
-describe(
-  Shortcut.TOGGLE_BLAME,
-  ShortcutSection.DIFFS,
-  'Toggle blame',
-  'b:keyup'
-);
-describe(Shortcut.OPEN_FILE_LIST, ShortcutSection.DIFFS, 'Open file list', 'f');
-describe(
-  Shortcut.NEXT_FILE,
-  ShortcutSection.NAVIGATION,
-  'Go to next file',
-  ']'
-);
+describe(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS, 'Toggle blame', {
+  key: 'b',
+});
+describe(Shortcut.OPEN_FILE_LIST, ShortcutSection.DIFFS, 'Open file list', {
+  key: 'f',
+});
+describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Go to next file', {
+  key: ']',
+});
 describe(
   Shortcut.PREV_FILE,
   ShortcutSection.NAVIGATION,
   'Go to previous file',
-  '['
+  {key: '['}
 );
 describe(
   Shortcut.NEXT_FILE_WITH_COMMENTS,
   ShortcutSection.NAVIGATION,
   'Go to next file that has comments',
-  'shift+j'
+  {key: 'J'}
 );
 describe(
   Shortcut.PREV_FILE_WITH_COMMENTS,
   ShortcutSection.NAVIGATION,
   'Go to previous file that has comments',
-  'shift+k'
+  {key: 'K'}
 );
 describe(
   Shortcut.OPEN_FIRST_FILE,
   ShortcutSection.NAVIGATION,
   'Go to first file',
-  ']'
+  {key: ']'}
 );
 describe(
   Shortcut.OPEN_LAST_FILE,
   ShortcutSection.NAVIGATION,
   'Go to last file',
-  '['
+  {key: '['}
 );
 describe(
   Shortcut.UP_TO_DASHBOARD,
   ShortcutSection.NAVIGATION,
   'Up to dashboard',
-  'u'
+  {key: 'u'}
 );
-describe(
-  Shortcut.UP_TO_CHANGE,
-  ShortcutSection.NAVIGATION,
-  'Up to change',
-  'u'
-);
+describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change', {
+  key: 'u',
+});
 
 describe(
   Shortcut.CURSOR_NEXT_FILE,
   ShortcutSection.FILE_LIST,
   'Select next file',
-  'j',
-  'down'
+  {key: 'j'},
+  {key: Key.DOWN}
 );
 describe(
   Shortcut.CURSOR_PREV_FILE,
   ShortcutSection.FILE_LIST,
   'Select previous file',
-  'k',
-  'up'
+  {key: 'k'},
+  {key: Key.UP}
 );
 describe(
   Shortcut.OPEN_FILE,
   ShortcutSection.FILE_LIST,
   'Go to selected file',
-  'o',
-  'enter'
+  {key: 'o'},
+  {key: Key.ENTER}
 );
 describe(
   Shortcut.TOGGLE_ALL_INLINE_DIFFS,
   ShortcutSection.FILE_LIST,
   'Show/hide all inline diffs',
-  'shift+i'
+  {key: 'I'}
 );
 describe(
   Shortcut.TOGGLE_INLINE_DIFF,
   ShortcutSection.FILE_LIST,
   'Show/hide selected inline diff',
-  'i'
+  {key: 'i'}
 );
 
 describe(
   Shortcut.SEND_REPLY,
   ShortcutSection.REPLY_DIALOG,
   'Send reply',
-  SPECIAL_SHORTCUT.DOC_ONLY,
-  'ctrl+enter',
-  'meta+enter'
+  {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY], docOnly: true},
+  {key: Key.ENTER, modifiers: [Modifier.META_KEY], docOnly: true}
 );
 describe(
   Shortcut.EMOJI_DROPDOWN,
   ShortcutSection.REPLY_DIALOG,
   'Emoji dropdown',
-  SPECIAL_SHORTCUT.DOC_ONLY,
-  ':'
+  {key: ':', docOnly: true}
 );
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
index edc31a4..a26fa08 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -19,27 +19,40 @@
   Shortcut,
   ShortcutHelpItem,
   ShortcutSection,
-  SPECIAL_SHORTCUT,
 } from './shortcuts-config';
 import {disableShortcuts$} from '../user/user-model';
-import {IronKeyboardEvent, isIronKeyboardEvent} from '../../types/events';
-import {isElementTarget, isModifierPressed} from '../../utils/dom-util';
+import {
+  ComboKey,
+  eventMatchesShortcut,
+  isElementTarget,
+  Key,
+  Modifier,
+  Binding,
+  shouldSuppress,
+} from '../../utils/dom-util';
 import {ReportingService} from '../gr-reporting/gr-reporting';
 
 export type SectionView = Array<{binding: string[][]; text: string}>;
 
+export interface ShortcutListener {
+  shortcut: Shortcut;
+  listener: (e: KeyboardEvent) => void;
+}
+
+export function listen(
+  shortcut: Shortcut,
+  listener: (e: KeyboardEvent) => void
+): ShortcutListener {
+  return {shortcut, listener};
+}
+
 /**
  * The interface for listener for shortcut events.
  */
-export type ShortcutListener = (
+export type ShortcutViewListener = (
   viewMap?: Map<ShortcutSection, SectionView>
 ) => void;
 
-export enum ComboKey {
-  G = 'g',
-  V = 'v',
-}
-
 function isComboKey(key: string): key is ComboKey {
   return Object.values(ComboKey).includes(key as ComboKey);
 }
@@ -55,12 +68,18 @@
    * show a shortcut help dialog that only shows the shortcuts that are
    * currently relevant.
    */
-  private readonly activeHosts = new Map<unknown, Map<string, string>>();
+  private readonly activeShortcuts = new Map<HTMLElement, Shortcut[]>();
+
+  /**
+   * Keeps track of cleanup callbacks (which remove keyboard listeners) that
+   * have to be invoked when a component unregisters itself.
+   */
+  private readonly cleanupsPerHost = new Map<HTMLElement, (() => void)[]>();
 
   /** Static map built in the constructor by iterating over the config. */
-  private readonly bindings = new Map<Shortcut, string[]>();
+  private readonly bindings = new Map<Shortcut, Binding[]>();
 
-  private readonly listeners = new Set<ShortcutListener>();
+  private readonly listeners = new Set<ShortcutViewListener>();
 
   /**
    * Stores the timestamp of the last combo key being pressed.
@@ -89,7 +108,7 @@
   }
 
   public _testOnly_isEmpty() {
-    return this.activeHosts.size === 0 && this.listeners.size === 0;
+    return this.activeShortcuts.size === 0 && this.listeners.size === 0;
   }
 
   isInComboKeyMode() {
@@ -107,41 +126,37 @@
     );
   }
 
-  modifierPressed(e: IronKeyboardEvent) {
-    return isModifierPressed(e) || this.isInComboKeyMode();
+  /**
+   * TODO(brohlfs): Reconcile with the addShortcut() function in dom-util.
+   * Most likely we will just keep this one here, but that is something for a
+   * follow-up change.
+   */
+  addShortcut(
+    element: HTMLElement,
+    shortcut: Binding,
+    listener: (e: KeyboardEvent) => void
+  ) {
+    const wrappedListener = (e: KeyboardEvent) => {
+      if (e.repeat) return;
+      if (!eventMatchesShortcut(e, shortcut)) return;
+      if (shortcut.combo) {
+        if (!this.isInSpecificComboKeyMode(shortcut.combo)) return;
+      } else {
+        if (this.isInComboKeyMode()) return;
+      }
+      if (this.shouldSuppress(e)) return;
+      e.preventDefault();
+      e.stopPropagation();
+      listener(e);
+    };
+    element.addEventListener('keydown', wrappedListener);
+    return () => element.removeEventListener('keydown', wrappedListener);
   }
 
-  shouldSuppress(event: IronKeyboardEvent | KeyboardEvent) {
+  shouldSuppress(e: KeyboardEvent) {
     if (this.shortcutsDisabled) return true;
-    const e = isIronKeyboardEvent(event) ? event.detail.keyboardEvent : event;
+    if (shouldSuppress(e)) return true;
 
-    // Note that when you listen on document, then `e.currentTarget` will be the
-    // document and `e.target` will be `<gr-app>` due to shadow dom, but by
-    // using the composedPath() you can actually find the true origin of the
-    // event.
-    const rootTarget = e.composedPath()[0];
-    if (!isElementTarget(rootTarget)) return false;
-    const tagName = rootTarget.tagName;
-    const type = rootTarget.getAttribute('type');
-
-    if (
-      // Suppress shortcuts on <input> and <textarea>, but not on
-      // checkboxes, because we want to enable workflows like 'click
-      // mark-reviewed and then press ] to go to the next file'.
-      (tagName === 'INPUT' && type !== 'checkbox') ||
-      tagName === 'TEXTAREA' ||
-      // Suppress shortcuts if the key is 'enter'
-      // and target is an anchor or button or paper-tab.
-      (e.keyCode === 13 &&
-        (tagName === 'A' || tagName === 'BUTTON' || tagName === 'PAPER-TAB'))
-    ) {
-      return true;
-    }
-    const path: EventTarget[] = e.composedPath() ?? [];
-    for (const el of path) {
-      if (!isElementTarget(el)) continue;
-      if (el.tagName === 'GR-OVERLAY') return true;
-    }
     // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
     let key = `${e.key}:${e.type}`;
     if (this.isInSpecificComboKeyMode(ComboKey.G)) key = 'g+' + key;
@@ -168,23 +183,37 @@
     return this.bindings.get(shortcut);
   }
 
-  attachHost(host: unknown, shortcuts: Map<string, string>) {
-    this.activeHosts.set(host, shortcuts);
-    this.notifyListeners();
+  attachHost(host: HTMLElement, shortcuts: ShortcutListener[]) {
+    this.activeShortcuts.set(
+      host,
+      shortcuts.map(s => s.shortcut)
+    );
+    const cleanups: (() => void)[] = [];
+    for (const s of shortcuts) {
+      const bindings = this.getBindingsForShortcut(s.shortcut);
+      for (const binding of bindings ?? []) {
+        if (binding.docOnly) continue;
+        cleanups.push(this.addShortcut(document.body, binding, s.listener));
+      }
+    }
+    this.cleanupsPerHost.set(host, cleanups);
+    this.notifyViewListeners();
   }
 
-  detachHost(host: unknown) {
-    if (!this.activeHosts.delete(host)) return false;
-    this.notifyListeners();
+  detachHost(host: HTMLElement) {
+    this.activeShortcuts.delete(host);
+    const cleanups = this.cleanupsPerHost.get(host);
+    for (const cleanup of cleanups ?? []) cleanup();
+    this.notifyViewListeners();
     return true;
   }
 
-  addListener(listener: ShortcutListener) {
+  addListener(listener: ShortcutViewListener) {
     this.listeners.add(listener);
     listener(this.directoryView());
   }
 
-  removeListener(listener: ShortcutListener) {
+  removeListener(listener: ShortcutViewListener) {
     return this.listeners.delete(listener);
   }
 
@@ -199,15 +228,17 @@
     const bindings = this.bindings.get(shortcutName);
     if (!bindings) return '';
     return bindings
-      .map(binding => this.describeBinding(binding).join('+'))
+      .map(binding => describeBinding(binding).join('+'))
       .join(',');
   }
 
   activeShortcutsBySection() {
-    const activeShortcuts = new Set<string>();
-    this.activeHosts.forEach(shortcuts => {
-      shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
-    });
+    const activeShortcuts = new Set<Shortcut>();
+    for (const shortcuts of this.activeShortcuts.values()) {
+      for (const shortcut of shortcuts) {
+        activeShortcuts.add(shortcut);
+      }
+    }
 
     const activeShortcutsBySection = new Map<
       ShortcutSection,
@@ -219,8 +250,6 @@
           if (!activeShortcutsBySection.has(section)) {
             activeShortcutsBySection.set(section, []);
           }
-          // From previous condition, the `get(section)`
-          // should always return a valid result
           activeShortcutsBySection.get(section)!.push(shortcutHelp);
         }
       });
@@ -282,63 +311,53 @@
 
   describeBindings(shortcut: Shortcut): string[][] | null {
     const bindings = this.bindings.get(shortcut);
-    if (!bindings) {
-      return null;
-    }
-    if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
-      return bindings
-        .slice(1)
-        .map(binding => this._describeKey(binding))
-        .map(binding => ['g'].concat(binding));
-    }
-    if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
-      return bindings
-        .slice(1)
-        .map(binding => this._describeKey(binding))
-        .map(binding => ['v'].concat(binding));
-    }
-
+    if (!bindings) return null;
     return bindings
-      .filter(binding => binding !== SPECIAL_SHORTCUT.DOC_ONLY)
-      .map(binding => this.describeBinding(binding));
+      .filter(binding => !binding.docOnly)
+      .map(binding => describeBinding(binding));
   }
 
-  _describeKey(key: string) {
-    switch (key) {
-      case 'shift':
-        return 'Shift';
-      case 'meta':
-        return 'Meta';
-      case 'ctrl':
-        return 'Ctrl';
-      case 'enter':
-        return 'Enter';
-      case 'up':
-        return '\u2191'; // ↑
-      case 'down':
-        return '\u2193'; // ↓
-      case 'left':
-        return '\u2190'; // ←
-      case 'right':
-        return '\u2192'; // →
-      default:
-        return key;
-    }
-  }
-
-  describeBinding(binding: string) {
-    // single key bindings
-    if (binding.length === 1) {
-      return [binding];
-    }
-    return binding
-      .split(':')[0]
-      .split('+')
-      .map(part => this._describeKey(part));
-  }
-
-  notifyListeners() {
+  notifyViewListeners() {
     const view = this.directoryView();
     this.listeners.forEach(listener => listener(view));
   }
 }
+
+function describeKey(key: string | Key) {
+  switch (key) {
+    case Key.UP:
+      return '\u2191'; // ↑
+    case Key.DOWN:
+      return '\u2193'; // ↓
+    case Key.LEFT:
+      return '\u2190'; // ←
+    case Key.RIGHT:
+      return '\u2192'; // →
+    default:
+      return key;
+  }
+}
+
+export function describeBinding(binding: Binding): string[] {
+  const description: string[] = [];
+  if (binding.combo === ComboKey.G) {
+    description.push('g');
+  }
+  if (binding.combo === ComboKey.V) {
+    description.push('v');
+  }
+  if (binding.modifiers?.includes(Modifier.SHIFT_KEY)) {
+    description.push('Shift');
+  }
+  if (binding.modifiers?.includes(Modifier.ALT_KEY)) {
+    description.push('Alt');
+  }
+  if (binding.modifiers?.includes(Modifier.CTRL_KEY)) {
+    description.push('Ctrl');
+  }
+  if (binding.modifiers?.includes(Modifier.META_KEY)) {
+    description.push('Meta/Cmd');
+  }
+  description.push(describeKey(binding.key));
+  return description;
+}
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
index d8aa11e..05c4f53 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -16,12 +16,14 @@
  */
 import '../../test/common-test-setup-karma';
 import {
-  ShortcutsService,
   COMBO_TIMEOUT_MS,
+  describeBinding,
+  ShortcutsService,
 } from '../../services/shortcuts/shortcuts-service';
 import {Shortcut, ShortcutSection} from './shortcuts-config';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import {SinonFakeTimers} from 'sinon';
+import {Key, Modifier} from '../../utils/dom-util';
 
 async function keyEventOn(
   el: HTMLElement,
@@ -96,13 +98,12 @@
   });
 
   test('getShortcut', () => {
-    const NEXT_FILE = Shortcut.NEXT_FILE;
-    assert.equal(service.getShortcut(NEXT_FILE), ']');
-  });
-
-  test('getShortcut with modifiers', () => {
-    const NEXT_FILE = Shortcut.TOGGLE_LEFT_PANE;
-    assert.equal(service.getShortcut(NEXT_FILE), 'Shift+a');
+    assert.equal(service.getShortcut(Shortcut.NEXT_FILE), ']');
+    assert.equal(service.getShortcut(Shortcut.TOGGLE_LEFT_PANE), 'A');
+    assert.equal(
+      service.getShortcut(Shortcut.SEND_REPLY),
+      'Ctrl+Enter,Meta/Cmd+Enter'
+    );
   });
 
   suite('binding descriptions', () => {
@@ -113,14 +114,18 @@
     }
 
     test('single combo description', () => {
-      assert.deepEqual(service.describeBinding('a'), ['a']);
-      assert.deepEqual(service.describeBinding('a:keyup'), ['a']);
-      assert.deepEqual(service.describeBinding('ctrl+a'), ['Ctrl', 'a']);
-      assert.deepEqual(service.describeBinding('ctrl+shift+up:keyup'), [
-        'Ctrl',
-        'Shift',
-        '↑',
-      ]);
+      assert.deepEqual(describeBinding({key: 'a'}), ['a']);
+      assert.deepEqual(
+        describeBinding({key: 'a', modifiers: [Modifier.CTRL_KEY]}),
+        ['Ctrl', 'a']
+      );
+      assert.deepEqual(
+        describeBinding({
+          key: Key.UP,
+          modifiers: [Modifier.CTRL_KEY, Modifier.SHIFT_KEY],
+        }),
+        ['Shift', 'Ctrl', '↑']
+      );
     });
 
     test('combo set description', () => {
@@ -130,9 +135,9 @@
       );
       assert.deepEqual(service.describeBindings(Shortcut.SAVE_COMMENT), [
         ['Ctrl', 'Enter'],
-        ['Meta', 'Enter'],
+        ['Meta/Cmd', 'Enter'],
         ['Ctrl', 's'],
-        ['Meta', 's'],
+        ['Meta/Cmd', 's'],
       ]);
       assert.deepEqual(service.describeBindings(Shortcut.PREV_FILE), [['[']]);
     });
@@ -187,67 +192,68 @@
     test('active shortcuts by section', () => {
       assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {});
 
-      service.attachHost({}, new Map([[Shortcut.NEXT_FILE, 'null']]));
+      service.attachHost(document.createElement('div'), [
+        {shortcut: Shortcut.NEXT_FILE, listener: _ => {}},
+      ]);
       assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {
         [ShortcutSection.NAVIGATION]: [
           {
             shortcut: Shortcut.NEXT_FILE,
             text: 'Go to next file',
-            bindings: [']'],
+            bindings: [{key: ']'}],
           },
         ],
       });
 
-      service.attachHost({}, new Map([[Shortcut.NEXT_LINE, 'null']]));
+      service.attachHost(document.createElement('div'), [
+        {shortcut: Shortcut.NEXT_LINE, listener: _ => {}},
+      ]);
       assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {
         [ShortcutSection.DIFFS]: [
           {
             shortcut: Shortcut.NEXT_LINE,
             text: 'Go to next line',
-            bindings: ['j', 'down'],
+            bindings: [{key: 'j'}, {key: 'ArrowDown'}],
           },
         ],
         [ShortcutSection.NAVIGATION]: [
           {
             shortcut: Shortcut.NEXT_FILE,
             text: 'Go to next file',
-            bindings: [']'],
+            bindings: [{key: ']'}],
           },
         ],
       });
 
-      service.attachHost(
-        {},
-        new Map([
-          [Shortcut.SEARCH, 'null'],
-          [Shortcut.GO_TO_OPENED_CHANGES, 'null'],
-        ])
-      );
+      service.attachHost(document.createElement('div'), [
+        {shortcut: Shortcut.SEARCH, listener: _ => {}},
+        {shortcut: Shortcut.GO_TO_OPENED_CHANGES, listener: _ => {}},
+      ]);
       assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {
         [ShortcutSection.DIFFS]: [
           {
             shortcut: Shortcut.NEXT_LINE,
             text: 'Go to next line',
-            bindings: ['j', 'down'],
+            bindings: [{key: 'j'}, {key: 'ArrowDown'}],
           },
         ],
         [ShortcutSection.EVERYWHERE]: [
           {
             shortcut: Shortcut.SEARCH,
             text: 'Search',
-            bindings: ['/'],
+            bindings: [{key: '/'}],
           },
           {
             shortcut: Shortcut.GO_TO_OPENED_CHANGES,
             text: 'Go to Opened Changes',
-            bindings: ['GO_KEY', 'o'],
+            bindings: [{key: 'o', combo: 'g'}],
           },
         ],
         [ShortcutSection.NAVIGATION]: [
           {
             shortcut: Shortcut.NEXT_FILE,
             text: 'Go to next file',
-            bindings: [']'],
+            bindings: [{key: ']'}],
           },
         ],
       });
@@ -256,33 +262,31 @@
     test('directory view', () => {
       assert.deepEqual(mapToObject(service.directoryView()), {});
 
-      service.attachHost(
-        {},
-        new Map([
-          [Shortcut.GO_TO_OPENED_CHANGES, 'null'],
-          [Shortcut.NEXT_FILE, 'null'],
-          [Shortcut.NEXT_LINE, 'null'],
-          [Shortcut.SAVE_COMMENT, 'null'],
-          [Shortcut.SEARCH, 'null'],
-        ])
-      );
+      service.attachHost(document.createElement('div'), [
+        {shortcut: Shortcut.GO_TO_OPENED_CHANGES, listener: _ => {}},
+        {shortcut: Shortcut.NEXT_FILE, listener: _ => {}},
+        {shortcut: Shortcut.NEXT_LINE, listener: _ => {}},
+        {shortcut: Shortcut.SAVE_COMMENT, listener: _ => {}},
+        {shortcut: Shortcut.SEARCH, listener: _ => {}},
+      ]);
       assert.deepEqual(mapToObject(service.directoryView()), {
         [ShortcutSection.DIFFS]: [
           {binding: [['j'], ['↓']], text: 'Go to next line'},
           {
-            binding: [
-              ['Ctrl', 'Enter'],
-              ['Meta', 'Enter'],
-            ],
+            binding: [['Ctrl', 'Enter']],
             text: 'Save comment',
           },
           {
             binding: [
+              ['Meta/Cmd', 'Enter'],
               ['Ctrl', 's'],
-              ['Meta', 's'],
             ],
             text: 'Save comment',
           },
+          {
+            binding: [['Meta/Cmd', 's']],
+            text: 'Save comment',
+          },
         ],
         [ShortcutSection.EVERYWHERE]: [
           {binding: [['/']], text: 'Search'},
diff --git a/polygerrit-ui/app/services/user/user-model.ts b/polygerrit-ui/app/services/user/user-model.ts
index 72ce3e1..6d31f6c 100644
--- a/polygerrit-ui/app/services/user/user-model.ts
+++ b/polygerrit-ui/app/services/user/user-model.ts
@@ -17,7 +17,10 @@
 import {AccountDetailInfo, PreferencesInfo} from '../../types/common';
 import {BehaviorSubject, Observable} from 'rxjs';
 import {map, distinctUntilChanged} from 'rxjs/operators';
-import {createDefaultPreferences} from '../../constants/constants';
+import {
+  createDefaultPreferences,
+  DiffViewMode,
+} from '../../constants/constants';
 
 interface UserState {
   /**
@@ -31,7 +34,20 @@
   preferences: createDefaultPreferences(),
 };
 
-const privateState$ = new BehaviorSubject(initialState);
+// Mutable for testing
+let privateState$ = new BehaviorSubject(initialState);
+
+export function _testOnly_resetState() {
+  privateState$ = new BehaviorSubject(initialState);
+}
+
+export function _testOnly_setState(state: UserState) {
+  privateState$.next(state);
+}
+
+export function _testOnly_getState() {
+  return privateState$.getValue();
+}
 
 // Re-exporting as Observable so that you can only subscribe, but not emit.
 export const userState$: Observable<UserState> = privateState$;
@@ -56,6 +72,11 @@
   distinctUntilChanged()
 );
 
+export const preferenceDiffViewMode$ = preferences$.pipe(
+  map(preference => preference.diff_view ?? DiffViewMode.SIDE_BY_SIDE),
+  distinctUntilChanged()
+);
+
 export const myTopMenuItems$ = preferences$.pipe(
   map(preferences => preferences?.my ?? []),
   distinctUntilChanged()
diff --git a/polygerrit-ui/app/services/user/user-service.ts b/polygerrit-ui/app/services/user/user-service.ts
index 125d20c..0588a4a 100644
--- a/polygerrit-ui/app/services/user/user-service.ts
+++ b/polygerrit-ui/app/services/user/user-service.ts
@@ -14,11 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {
-  AccountDetailInfo,
-  PreferencesInfo,
-  PreferencesInput,
-} from '../../types/common';
+import {AccountDetailInfo, PreferencesInfo} from '../../types/common';
 import {from, of} from 'rxjs';
 import {account$, updateAccount, updatePreferences} from './user-model';
 import {switchMap} from 'rxjs/operators';
@@ -44,7 +40,7 @@
       });
   }
 
-  updatePreferences(prefs: PreferencesInput) {
+  updatePreferences(prefs: Partial<PreferencesInfo>) {
     this.restApiService
       .savePreferences(prefs)
       .then((newPrefs: PreferencesInfo | undefined) => {
diff --git a/polygerrit-ui/app/test/common-test-setup-karma.ts b/polygerrit-ui/app/test/common-test-setup-karma.ts
index 3463d3b..39c79d1 100644
--- a/polygerrit-ui/app/test/common-test-setup-karma.ts
+++ b/polygerrit-ui/app/test/common-test-setup-karma.ts
@@ -118,7 +118,7 @@
 }
 
 class TestFixture {
-  constructor(private readonly fixtureId: string) {}
+  constructor(readonly fixtureId: string) {}
 
   /**
    * Create an instance of a fixture's template.
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 39c30ad..49ecac8 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -25,6 +25,7 @@
 import {ReportingService} from '../services/gr-reporting/gr-reporting';
 import {CommentsService} from '../services/comments/comments-service';
 import {UserService} from '../services/user/user-service';
+import {ShortcutsService} from '../services/shortcuts/shortcuts-service';
 export {query, queryAll, queryAndAssert} from '../utils/common-util';
 
 export interface MockPromise extends Promise<unknown> {
@@ -116,6 +117,10 @@
   return sinon.stub(appContext.userService, method);
 }
 
+export function stubShortcuts<K extends keyof ShortcutsService>(method: K) {
+  return sinon.stub(appContext.shortcutsService, method);
+}
+
 export function stubStorage<K extends keyof StorageService>(method: K) {
   return sinon.stub(appContext.storageService, method);
 }
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 1617aa3..3ac7c7b 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -1147,8 +1147,6 @@
   work_in_progress_by_default?: boolean;
   // The email_format doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo
   email_format?: EmailFormat;
-  // The following property doesn't exist in RestAPI, it is added by GrRestApiInterface
-  default_diff_view?: DiffViewMode;
 }
 
 /**
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index c78f61a..b6376c4 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -241,30 +241,3 @@
   title: string;
 }
 export type TitleChangeEvent = CustomEvent<TitleChangeEventDetail>;
-
-/**
- * Keyboard events emitted from elements using IronA11yKeysBehavior: That means
- * that the element returns a list of handlers from either `keyBindings()` or
- * from `keyboardShortcuts()`. This event should not be used in Lit elements
- * and will be obsolete once the Lit migration is completed.
- */
-export interface IronKeyboardEvent extends CustomEvent {
-  detail: IronKeyboardEventDetail;
-}
-
-export interface IronKeyboardEventDetail {
-  keyboardEvent: KeyboardEvent;
-  key: string;
-  combo?: string;
-}
-
-export function isIronKeyboardEvent(
-  e: IronKeyboardEvent | Event | CustomEvent
-): e is IronKeyboardEvent {
-  const ike = e as IronKeyboardEvent;
-  return !!ike?.detail?.keyboardEvent;
-}
-
-export interface IronKeyboardEventListener {
-  (evt: IronKeyboardEvent): void;
-}
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 2d4d412..b90b12f 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -189,7 +189,6 @@
   showDownloadDialog: boolean;
   diffMode: DiffViewMode | null;
   numFilesShown: number | null;
-  diffViewMode?: boolean;
 }
 
 export interface ChangeListViewState {
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index e3e342e..e2fa8fe 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -16,7 +16,6 @@
  */
 import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {check} from './common-util';
-import {IronKeyboardEvent} from '../types/events';
 
 /**
  * Event emitted from polymer elements.
@@ -326,30 +325,95 @@
   SHIFT_KEY,
 }
 
-export interface Shortcut {
-  key: string;
+export enum ComboKey {
+  G = 'g',
+  V = 'v',
+}
+
+export interface Binding {
+  key: string | Key;
+  /** Defaults to false. */
+  docOnly?: boolean;
+  /** Defaults to not being a combo shortcut. */
+  combo?: ComboKey;
+  /** Defaults to no modifiers. */
   modifiers?: Modifier[];
 }
 
-export function addShortcut(
-  shortcut: Shortcut,
+const ALPHA_NUM = new RegExp(/^[A-Za-z0-9]$/);
+
+/**
+ * For "normal" keys we do not check that the SHIFT modifier is pressed or not,
+ * because that depends on the keyboard layout. Just checking the key string is
+ * sufficient.
+ *
+ * But for some special keys it is important whether SHIFT is pressed at the
+ * same time, for example we want to distinguish Enter from Shift+Enter.
+ */
+function shiftMustMatch(key: string | Key) {
+  return Object.values(Key).includes(key as Key);
+}
+
+/**
+ * For a-zA-Z0-9 and for Enter, Tab, etc. we want to check the ALT modifier.
+ *
+ * But for special chars like []/? we don't care whether the user is pressing
+ * the ALT modifier to produce the special char. For example on a German
+ * keyboard layout you have to press ALT to produce a [.
+ */
+function altMustMatch(key: string | Key) {
+  return ALPHA_NUM.test(key) || Object.values(Key).includes(key as Key);
+}
+
+export function eventMatchesShortcut(
+  e: KeyboardEvent,
+  shortcut: Binding
+): boolean {
+  if (e.key !== shortcut.key) return false;
+  const modifiers = shortcut.modifiers ?? [];
+  if (e.ctrlKey !== modifiers.includes(Modifier.CTRL_KEY)) return false;
+  if (e.metaKey !== modifiers.includes(Modifier.META_KEY)) return false;
+  if (
+    altMustMatch(e.key) &&
+    e.altKey !== modifiers.includes(Modifier.ALT_KEY)
+  ) {
+    return false;
+  }
+  if (
+    shiftMustMatch(e.key) &&
+    e.shiftKey !== modifiers.includes(Modifier.SHIFT_KEY)
+  ) {
+    return false;
+  }
+  return true;
+}
+
+export function addGlobalShortcut(
+  shortcut: Binding,
   listener: (e: KeyboardEvent) => void
 ) {
+  return addShortcut(document.body, shortcut, listener);
+}
+
+export function addShortcut(
+  element: HTMLElement,
+  shortcut: Binding,
+  listener: (e: KeyboardEvent) => void,
+  options: {
+    shouldSuppress: boolean;
+  } = {
+    shouldSuppress: false,
+  }
+) {
   const wrappedListener = (e: KeyboardEvent) => {
-    if (e.key !== shortcut.key) return;
-    const modifiers = shortcut.modifiers ?? [];
-    if (e.ctrlKey !== modifiers.includes(Modifier.CTRL_KEY)) return;
-    if (e.metaKey !== modifiers.includes(Modifier.META_KEY)) return;
-    // TODO(brohlfs): Refine the matching of modifiers. For "normal" keys we
-    // don't want to check ALT and SHIFT, because we don't know what the
-    // keyboard layout looks like. The user may have to use ALT and/or SHIFT for
-    // certain keys. Comparing the `key` string is sufficient in that case.
-    // if (e.altKey !== modifiers.includes(Modifier.ALT_KEY)) return;
-    // if (e.shiftKey !== modifiers.includes(Modifier.SHIFT_KEY)) return;
-    listener(e);
+    if (e.repeat) return;
+    if (options.shouldSuppress && shouldSuppress(e)) return;
+    if (eventMatchesShortcut(e, shortcut)) {
+      listener(e);
+    }
   };
-  document.addEventListener('keydown', wrappedListener);
-  return () => document.removeEventListener('keydown', wrappedListener);
+  element.addEventListener('keydown', wrappedListener);
+  return () => element.removeEventListener('keydown', wrappedListener);
 }
 
 export function modifierPressed(e: KeyboardEvent) {
@@ -360,10 +424,42 @@
   return e.shiftKey;
 }
 
-export function isModifierPressed(e: IronKeyboardEvent) {
-  return modifierPressed(e.detail.keyboardEvent);
-}
+/**
+ * When you listen on keyboard events, then within Gerrit's web app you may want
+ * to avoid firing in certain common scenarios such as key strokes from <input>
+ * elements. But this can also be undesirable, for example Ctrl-Enter from
+ * <input> should trigger a save event.
+ *
+ * The shortcuts-service has a stateful method `shouldSuppress()` with
+ * reporting functionality, which delegates to here.
+ */
+export function shouldSuppress(e: KeyboardEvent): boolean {
+  // Note that when you listen on document, then `e.currentTarget` will be the
+  // document and `e.target` will be `<gr-app>` due to shadow dom, but by
+  // using the composedPath() you can actually find the true origin of the
+  // event.
+  const rootTarget = e.composedPath()[0];
+  if (!isElementTarget(rootTarget)) return false;
+  const tagName = rootTarget.tagName;
+  const type = rootTarget.getAttribute('type');
 
-export function isShiftPressed(e: IronKeyboardEvent) {
-  return shiftPressed(e.detail.keyboardEvent);
+  if (
+    // Suppress shortcuts on <input> and <textarea>, but not on
+    // checkboxes, because we want to enable workflows like 'click
+    // mark-reviewed and then press ] to go to the next file'.
+    (tagName === 'INPUT' && type !== 'checkbox') ||
+    tagName === 'TEXTAREA' ||
+    // Suppress shortcuts if the key is 'enter'
+    // and target is an anchor or button or paper-tab.
+    (e.keyCode === 13 &&
+      (tagName === 'A' || tagName === 'BUTTON' || tagName === 'PAPER-TAB'))
+  ) {
+    return true;
+  }
+  const path: EventTarget[] = e.composedPath() ?? [];
+  for (const el of path) {
+    if (!isElementTarget(el)) continue;
+    if (el.tagName === 'GR-OVERLAY') return true;
+  }
+  return false;
 }
diff --git a/polygerrit-ui/app/utils/dom-util_test.js b/polygerrit-ui/app/utils/dom-util_test.js
deleted file mode 100644
index bcb4505..0000000
--- a/polygerrit-ui/app/utils/dom-util_test.js
+++ /dev/null
@@ -1,155 +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 '../test/common-test-setup-karma.js';
-import {strToClassName, getComputedStyleValue, querySelector, querySelectorAll, descendedFromClass, getEventPath} from './dom-util.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-class TestEle extends PolymerElement {
-  static get is() {
-    return 'dom-util-test-element';
-  }
-
-  static get template() {
-    return html`
-    <div>
-      <div class="a">
-        <div class="b">
-          <div class="c"></div>
-        </div>
-        <span class="ss"></span>
-      </div>
-      <span class="ss"></span>
-    </div>
-    `;
-  }
-}
-
-customElements.define(TestEle.is, TestEle);
-
-const basicFixture = fixtureFromTemplate(html`
-  <div id="test" class="a b c">
-    <a class="testBtn" style="color:red;"></a>
-    <dom-util-test-element></dom-util-test-element>
-    <span class="ss"></span>
-  </div>
-`);
-
-suite('dom-util tests', () => {
-  suite('getEventPath', () => {
-    test('empty event', () => {
-      assert.equal(getEventPath(), '');
-      assert.equal(getEventPath(null), '');
-      assert.equal(getEventPath(undefined), '');
-      assert.equal(getEventPath({composedPath: () => []}), '');
-    });
-
-    test('event with fake path', () => {
-      assert.equal(getEventPath({composedPath: () => []}), '');
-      const dd = document.createElement('dd');
-      assert.equal(getEventPath({composedPath: () => [dd]}), 'dd');
-    });
-
-    test('event with fake complicated path', () => {
-      const dd = document.createElement('dd');
-      dd.setAttribute('id', 'test');
-      dd.className = 'a b';
-      const divNode = document.createElement('DIV');
-      divNode.id = 'test2';
-      divNode.className = 'a b c';
-      assert.equal(getEventPath(
-          {composedPath: () => [dd, divNode]}),
-      'div#test2.a.b.c>dd#test.a.b'
-      );
-    });
-
-    test('event with fake target', () => {
-      const fakeTargetParent1 = document.createElement('dd');
-      fakeTargetParent1.setAttribute('id', 'test');
-      fakeTargetParent1.className = 'a b';
-      const fakeTargetParent2 = document.createElement('DIV');
-      fakeTargetParent2.id = 'test2';
-      fakeTargetParent2.className = 'a b c';
-      fakeTargetParent2.appendChild(fakeTargetParent1);
-      const fakeTarget = document.createElement('SPAN');
-      fakeTargetParent1.appendChild(fakeTarget);
-      assert.equal(
-          getEventPath({composedPath: () => {}, target: fakeTarget}),
-          'div#test2.a.b.c>dd#test.a.b>span'
-      );
-    });
-
-    test('event with real click', () => {
-      const element = basicFixture.instantiate();
-      const aLink = element.querySelector('a');
-      let path;
-      aLink.onclick = e => path = getEventPath(e);
-      MockInteractions.click(aLink);
-      assert.equal(
-          path,
-          `html>body>test-fixture#${basicFixture.fixtureId}>` +
-          'div#test.a.b.c>a.testBtn'
-      );
-    });
-  });
-
-  suite('querySelector and querySelectorAll', () => {
-    test('query cross shadow dom', () => {
-      const element = basicFixture.instantiate();
-      const theFirstEl = querySelector(element, '.ss');
-      const allEls = querySelectorAll(element, '.ss');
-      assert.equal(allEls.length, 3);
-      assert.equal(theFirstEl, allEls[0]);
-    });
-  });
-
-  suite('getComputedStyleValue', () => {
-    test('color style', () => {
-      const element = basicFixture.instantiate();
-      const testBtn = querySelector(element, '.testBtn');
-      assert.equal(
-          getComputedStyleValue('color', testBtn), 'rgb(255, 0, 0)'
-      );
-    });
-  });
-
-  suite('descendedFromClass', () => {
-    test('basic tests', () => {
-      const element = basicFixture.instantiate();
-      const testEl = querySelector(element, 'dom-util-test-element');
-      // .c is a child of .a and not vice versa.
-      assert.isTrue(descendedFromClass(querySelector(testEl, '.c'), 'a'));
-      assert.isFalse(descendedFromClass(querySelector(testEl, '.a'), 'c'));
-
-      // Stops at stop element.
-      assert.isFalse(descendedFromClass(querySelector(testEl, '.c'), 'a',
-          querySelector(testEl, '.b')));
-    });
-  });
-
-  suite('strToClassName', () => {
-    test('basic tests', () => {
-      assert.equal(strToClassName(''), 'generated_');
-      assert.equal(strToClassName('11'), 'generated_11');
-      assert.equal(strToClassName('0.123'), 'generated_0_123');
-      assert.equal(strToClassName('0.123', 'prefix_'), 'prefix_0_123');
-      assert.equal(strToClassName('0>123', 'prefix_'), 'prefix_0_123');
-      assert.equal(strToClassName('0<123', 'prefix_'), 'prefix_0_123');
-      assert.equal(strToClassName('0+1+23', 'prefix_'), 'prefix_0_1_23');
-    });
-  });
-});
\ No newline at end of file
diff --git a/polygerrit-ui/app/utils/dom-util_test.ts b/polygerrit-ui/app/utils/dom-util_test.ts
new file mode 100644
index 0000000..9dd5be2
--- /dev/null
+++ b/polygerrit-ui/app/utils/dom-util_test.ts
@@ -0,0 +1,335 @@
+/**
+ * @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 '../test/common-test-setup-karma';
+import {
+  descendedFromClass,
+  eventMatchesShortcut,
+  getComputedStyleValue,
+  getEventPath,
+  Modifier,
+  querySelectorAll,
+  shouldSuppress,
+  strToClassName,
+} from './dom-util';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {queryAndAssert} from '../test/test-utils';
+
+async function keyEventOn(
+  el: HTMLElement,
+  callback: (e: KeyboardEvent) => void,
+  keyCode = 75,
+  key = 'k'
+): Promise<KeyboardEvent> {
+  let resolve: (e: KeyboardEvent) => void;
+  const promise = new Promise<KeyboardEvent>(r => (resolve = r));
+  el.addEventListener('keydown', (e: KeyboardEvent) => {
+    callback(e);
+    resolve(e);
+  });
+  MockInteractions.keyDownOn(el, keyCode, null, key);
+  return await promise;
+}
+
+class TestEle extends PolymerElement {
+  static get is() {
+    return 'dom-util-test-element';
+  }
+
+  static get template() {
+    return html`
+      <div>
+        <div class="a">
+          <div class="b">
+            <div class="c"></div>
+          </div>
+          <span class="ss"></span>
+        </div>
+        <span class="ss"></span>
+      </div>
+    `;
+  }
+}
+
+customElements.define(TestEle.is, TestEle);
+
+const basicFixture = fixtureFromTemplate(html`
+  <div id="test" class="a b c">
+    <a class="testBtn" style="color:red;"></a>
+    <dom-util-test-element></dom-util-test-element>
+    <span class="ss"></span>
+  </div>
+`);
+
+suite('dom-util tests', () => {
+  suite('getEventPath', () => {
+    test('empty event', () => {
+      assert.equal(getEventPath(), '');
+      assert.equal(getEventPath(undefined), '');
+      assert.equal(getEventPath(new MouseEvent('click')), '');
+    });
+
+    test('event with fake path', () => {
+      assert.equal(getEventPath(new MouseEvent('click')), '');
+      const dd = document.createElement('dd');
+      assert.equal(
+        getEventPath({...new MouseEvent('click'), composedPath: () => [dd]}),
+        'dd'
+      );
+    });
+
+    test('event with fake complicated path', () => {
+      const dd = document.createElement('dd');
+      dd.setAttribute('id', 'test');
+      dd.className = 'a b';
+      const divNode = document.createElement('DIV');
+      divNode.id = 'test2';
+      divNode.className = 'a b c';
+      assert.equal(
+        getEventPath({
+          ...new MouseEvent('click'),
+          composedPath: () => [dd, divNode],
+        }),
+        'div#test2.a.b.c>dd#test.a.b'
+      );
+    });
+
+    test('event with fake target', () => {
+      const fakeTargetParent1 = document.createElement('dd');
+      fakeTargetParent1.setAttribute('id', 'test');
+      fakeTargetParent1.className = 'a b';
+      const fakeTargetParent2 = document.createElement('DIV');
+      fakeTargetParent2.id = 'test2';
+      fakeTargetParent2.className = 'a b c';
+      fakeTargetParent2.appendChild(fakeTargetParent1);
+      const fakeTarget = document.createElement('SPAN');
+      fakeTargetParent1.appendChild(fakeTarget);
+      assert.equal(
+        getEventPath({
+          ...new MouseEvent('click'),
+          composedPath: () => [],
+          target: fakeTarget,
+        }),
+        'div#test2.a.b.c>dd#test.a.b>span'
+      );
+    });
+
+    test('event with real click', () => {
+      const element = basicFixture.instantiate() as HTMLElement;
+      const aLink = queryAndAssert(element, 'a');
+      let path;
+      aLink.addEventListener('click', (e: Event) => {
+        path = getEventPath(e as MouseEvent);
+      });
+      MockInteractions.click(aLink);
+      assert.equal(
+        path,
+        `html>body>test-fixture#${basicFixture.fixtureId}>` +
+          'div#test.a.b.c>a.testBtn'
+      );
+    });
+  });
+
+  suite('querySelector and querySelectorAll', () => {
+    test('query cross shadow dom', () => {
+      const element = basicFixture.instantiate() as HTMLElement;
+      const theFirstEl = queryAndAssert(element, '.ss');
+      const allEls = querySelectorAll(element, '.ss');
+      assert.equal(allEls.length, 3);
+      assert.equal(theFirstEl, allEls[0]);
+    });
+  });
+
+  suite('getComputedStyleValue', () => {
+    test('color style', () => {
+      const element = basicFixture.instantiate() as HTMLElement;
+      const testBtn = queryAndAssert(element, '.testBtn');
+      assert.equal(getComputedStyleValue('color', testBtn), 'rgb(255, 0, 0)');
+    });
+  });
+
+  suite('descendedFromClass', () => {
+    test('basic tests', () => {
+      const element = basicFixture.instantiate() as HTMLElement;
+      const testEl = queryAndAssert(element, 'dom-util-test-element');
+      // .c is a child of .a and not vice versa.
+      assert.isTrue(descendedFromClass(queryAndAssert(testEl, '.c'), 'a'));
+      assert.isFalse(descendedFromClass(queryAndAssert(testEl, '.a'), 'c'));
+
+      // Stops at stop element.
+      assert.isFalse(
+        descendedFromClass(
+          queryAndAssert(testEl, '.c'),
+          'a',
+          queryAndAssert(testEl, '.b')
+        )
+      );
+    });
+  });
+
+  suite('strToClassName', () => {
+    test('basic tests', () => {
+      assert.equal(strToClassName(''), 'generated_');
+      assert.equal(strToClassName('11'), 'generated_11');
+      assert.equal(strToClassName('0.123'), 'generated_0_123');
+      assert.equal(strToClassName('0.123', 'prefix_'), 'prefix_0_123');
+      assert.equal(strToClassName('0>123', 'prefix_'), 'prefix_0_123');
+      assert.equal(strToClassName('0<123', 'prefix_'), 'prefix_0_123');
+      assert.equal(strToClassName('0+1+23', 'prefix_'), 'prefix_0_1_23');
+    });
+  });
+
+  suite('eventMatchesShortcut', () => {
+    test('basic tests', () => {
+      const a = new KeyboardEvent('keydown', {key: 'a'});
+      const b = new KeyboardEvent('keydown', {key: 'B'});
+      assert.isTrue(eventMatchesShortcut(a, {key: 'a'}));
+      assert.isFalse(eventMatchesShortcut(a, {key: 'B'}));
+      assert.isFalse(eventMatchesShortcut(b, {key: 'a'}));
+      assert.isTrue(eventMatchesShortcut(b, {key: 'B'}));
+    });
+
+    test('check modifiers for a', () => {
+      const e = new KeyboardEvent('keydown', {key: 'a'});
+      const s = {key: 'a'};
+      assert.isTrue(eventMatchesShortcut(e, s));
+
+      const eAlt = new KeyboardEvent('keydown', {key: 'a', altKey: true});
+      const sAlt = {key: 'a', modifiers: [Modifier.ALT_KEY]};
+      assert.isFalse(eventMatchesShortcut(eAlt, s));
+      assert.isFalse(eventMatchesShortcut(e, sAlt));
+      const eCtrl = new KeyboardEvent('keydown', {key: 'a', ctrlKey: true});
+      const sCtrl = {key: 'a', modifiers: [Modifier.CTRL_KEY]};
+      assert.isFalse(eventMatchesShortcut(eCtrl, s));
+      assert.isFalse(eventMatchesShortcut(e, sCtrl));
+      const eMeta = new KeyboardEvent('keydown', {key: 'a', metaKey: true});
+      const sMeta = {key: 'a', modifiers: [Modifier.META_KEY]};
+      assert.isFalse(eventMatchesShortcut(eMeta, s));
+      assert.isFalse(eventMatchesShortcut(e, sMeta));
+
+      // Do NOT check SHIFT for alphanum keys.
+      const eShift = new KeyboardEvent('keydown', {key: 'a', shiftKey: true});
+      const sShift = {key: 'a', modifiers: [Modifier.SHIFT_KEY]};
+      assert.isTrue(eventMatchesShortcut(eShift, s));
+      assert.isTrue(eventMatchesShortcut(e, sShift));
+    });
+
+    test('check modifiers for Enter', () => {
+      const e = new KeyboardEvent('keydown', {key: 'Enter'});
+      const s = {key: 'Enter'};
+      assert.isTrue(eventMatchesShortcut(e, s));
+
+      const eAlt = new KeyboardEvent('keydown', {key: 'Enter', altKey: true});
+      const sAlt = {key: 'Enter', modifiers: [Modifier.ALT_KEY]};
+      assert.isFalse(eventMatchesShortcut(eAlt, s));
+      assert.isFalse(eventMatchesShortcut(e, sAlt));
+      const eCtrl = new KeyboardEvent('keydown', {key: 'Enter', ctrlKey: true});
+      const sCtrl = {key: 'Enter', modifiers: [Modifier.CTRL_KEY]};
+      assert.isFalse(eventMatchesShortcut(eCtrl, s));
+      assert.isFalse(eventMatchesShortcut(e, sCtrl));
+      const eMeta = new KeyboardEvent('keydown', {key: 'Enter', metaKey: true});
+      const sMeta = {key: 'Enter', modifiers: [Modifier.META_KEY]};
+      assert.isFalse(eventMatchesShortcut(eMeta, s));
+      assert.isFalse(eventMatchesShortcut(e, sMeta));
+      const eShift = new KeyboardEvent('keydown', {
+        key: 'Enter',
+        shiftKey: true,
+      });
+      const sShift = {key: 'Enter', modifiers: [Modifier.SHIFT_KEY]};
+      assert.isFalse(eventMatchesShortcut(eShift, s));
+      assert.isFalse(eventMatchesShortcut(e, sShift));
+    });
+
+    test('check modifiers for [', () => {
+      const e = new KeyboardEvent('keydown', {key: '['});
+      const s = {key: '['};
+      assert.isTrue(eventMatchesShortcut(e, s));
+
+      const eCtrl = new KeyboardEvent('keydown', {key: '[', ctrlKey: true});
+      const sCtrl = {key: '[', modifiers: [Modifier.CTRL_KEY]};
+      assert.isFalse(eventMatchesShortcut(eCtrl, s));
+      assert.isFalse(eventMatchesShortcut(e, sCtrl));
+      const eMeta = new KeyboardEvent('keydown', {key: '[', metaKey: true});
+      const sMeta = {key: '[', modifiers: [Modifier.META_KEY]};
+      assert.isFalse(eventMatchesShortcut(eMeta, s));
+      assert.isFalse(eventMatchesShortcut(e, sMeta));
+
+      // Do NOT check SHIFT and ALT for special chars like [.
+      const eAlt = new KeyboardEvent('keydown', {key: '[', altKey: true});
+      const sAlt = {key: '[', modifiers: [Modifier.ALT_KEY]};
+      assert.isTrue(eventMatchesShortcut(eAlt, s));
+      assert.isTrue(eventMatchesShortcut(e, sAlt));
+      const eShift = new KeyboardEvent('keydown', {
+        key: '[',
+        shiftKey: true,
+      });
+      const sShift = {key: '[', modifiers: [Modifier.SHIFT_KEY]};
+      assert.isTrue(eventMatchesShortcut(eShift, s));
+      assert.isTrue(eventMatchesShortcut(e, sShift));
+    });
+  });
+
+  suite('shouldSuppress', () => {
+    test('do not suppress shortcut event from <div>', async () => {
+      await keyEventOn(document.createElement('div'), e => {
+        assert.isFalse(shouldSuppress(e));
+      });
+    });
+
+    test('suppress shortcut event from <input>', async () => {
+      await keyEventOn(document.createElement('input'), e => {
+        assert.isTrue(shouldSuppress(e));
+      });
+    });
+
+    test('suppress shortcut event from <textarea>', async () => {
+      await keyEventOn(document.createElement('textarea'), e => {
+        assert.isTrue(shouldSuppress(e));
+      });
+    });
+
+    test('do not suppress shortcut event from checkbox <input>', async () => {
+      const inputEl = document.createElement('input');
+      inputEl.setAttribute('type', 'checkbox');
+      await keyEventOn(inputEl, e => {
+        assert.isFalse(shouldSuppress(e));
+      });
+    });
+
+    test('suppress shortcut event from children of <gr-overlay>', async () => {
+      const overlay = document.createElement('gr-overlay');
+      const div = document.createElement('div');
+      overlay.appendChild(div);
+      await keyEventOn(div, e => {
+        assert.isTrue(shouldSuppress(e));
+      });
+    });
+
+    test('suppress "enter" shortcut event from <a>', async () => {
+      await keyEventOn(document.createElement('a'), e => {
+        assert.isFalse(shouldSuppress(e));
+      });
+      await keyEventOn(
+        document.createElement('a'),
+        e => assert.isTrue(shouldSuppress(e)),
+        13,
+        'enter'
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index 918d2ab..b95fa4d 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -31,7 +31,11 @@
 import {assertNever, unique} from './common-util';
 
 // Name of the standard Code-Review label.
-export const CODE_REVIEW = 'Code-Review';
+export enum StandardLabels {
+  CODE_REVIEW = 'Code-Review',
+  CODE_OWNERS = 'Code Owners',
+  PRESUBMIT_VERIFIED = 'Presubmit-Verified',
+}
 
 export enum LabelStatus {
   APPROVED = 'APPROVED',
@@ -178,9 +182,13 @@
 }
 
 export function labelCompare(labelName1: string, labelName2: string) {
-  if (labelName1 === CODE_REVIEW && labelName2 === CODE_REVIEW) return 0;
-  if (labelName1 === CODE_REVIEW) return -1;
-  if (labelName2 === CODE_REVIEW) return 1;
+  if (
+    labelName1 === StandardLabels.CODE_REVIEW &&
+    labelName2 === StandardLabels.CODE_REVIEW
+  )
+    return 0;
+  if (labelName1 === StandardLabels.CODE_REVIEW) return -1;
+  if (labelName2 === StandardLabels.CODE_REVIEW) return 1;
 
   return labelName1.localeCompare(labelName2);
 }
@@ -189,26 +197,34 @@
   labels: LabelNameToInfoMap
 ): LabelInfo | undefined {
   for (const label of Object.keys(labels)) {
-    if (label === CODE_REVIEW) {
+    if (label === StandardLabels.CODE_REVIEW) {
       return labels[label];
     }
   }
   return;
 }
 
-export function extractAssociatedLabels(
-  requirement: SubmitRequirementResultInfo
-): string[] {
+function extractLabelsFrom(expression: string) {
   const pattern = new RegExp('label[0-9]*:([\\w-]+)', 'g');
   const labels = [];
   let match;
-  while (
-    (match = pattern.exec(
-      requirement.submittability_expression_result.expression
-    )) !== null
-  ) {
+  while ((match = pattern.exec(expression)) !== null) {
     labels.push(match[1]);
   }
+  return labels;
+}
+
+export function extractAssociatedLabels(
+  requirement: SubmitRequirementResultInfo
+): string[] {
+  let labels = extractLabelsFrom(
+    requirement.submittability_expression_result.expression
+  );
+  if (requirement.override_expression_result) {
+    labels = labels.concat(
+      extractLabelsFrom(requirement.override_expression_result.expression)
+    );
+  }
   return labels.filter(unique);
 }
 
@@ -219,10 +235,31 @@
     case SubmitRequirementStatus.UNSATISFIED:
       return 'close';
     case SubmitRequirementStatus.OVERRIDDEN:
-      return 'warning';
+      return 'overridden';
     case SubmitRequirementStatus.NOT_APPLICABLE:
       return 'info';
     default:
       assertNever(status, `Unsupported status: ${status}`);
   }
 }
+
+// TODO(milutin): This may be temporary for demo purposes
+const PRIORITY_REQUIREMENTS_ORDER: string[] = [
+  StandardLabels.CODE_REVIEW,
+  StandardLabels.CODE_OWNERS,
+  StandardLabels.PRESUBMIT_VERIFIED,
+];
+export function orderSubmitRequirements(
+  requirements: SubmitRequirementResultInfo[]
+) {
+  let priorityRequirementList: SubmitRequirementResultInfo[] = [];
+  for (const label of PRIORITY_REQUIREMENTS_ORDER) {
+    const priorityRequirement = requirements.filter(r => r.name === label);
+    priorityRequirementList =
+      priorityRequirementList.concat(priorityRequirement);
+  }
+  const nonPriorityRequirements = requirements.filter(
+    r => !PRIORITY_REQUIREMENTS_ORDER.includes(r.name)
+  );
+  return priorityRequirementList.concat(nonPriorityRequirements);
+}
diff --git a/polygerrit-ui/app/utils/label-util_test.ts b/polygerrit-ui/app/utils/label-util_test.ts
index 9360688..2d59294 100644
--- a/polygerrit-ui/app/utils/label-util_test.ts
+++ b/polygerrit-ui/app/utils/label-util_test.ts
@@ -256,5 +256,18 @@
       const labels = extractAssociatedLabels(submitRequirement);
       assert.deepEqual(labels, ['Verified', 'Code-Review']);
     });
+    test('overridden label', () => {
+      const submitRequirement = {
+        ...createSubmitRequirementExpressionInfoWith(
+          'label:Verified=MAX -label:Verified=MIN'
+        ),
+        override_expression_result: {
+          ...createSubmitRequirementExpressionInfo(),
+          expression: 'label:Build-cop-override',
+        },
+      };
+      const labels = extractAssociatedLabels(submitRequirement);
+      assert.deepEqual(labels, ['Verified', 'Build-cop-override']);
+    });
   });
 });
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index bfca566..d3b22eb 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -46,7 +46,7 @@
   dependencies:
     "@polymer/polymer" "^3.0.0"
 
-"@polymer/iron-a11y-keys-behavior@^3.0.0-pre.26", "@polymer/iron-a11y-keys-behavior@^3.0.1":
+"@polymer/iron-a11y-keys-behavior@^3.0.0-pre.26":
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/@polymer/iron-a11y-keys-behavior/-/iron-a11y-keys-behavior-3.0.1.tgz#2868ea72912d2007ffab4734684a91f5aac49b84"
   integrity sha512-lnrjKq3ysbBPT/74l0Fj0U9H9C35Tpw2C/tpJ8a+5g8Y3YJs1WSZYnEl1yOkw6sEyaxOq/1DkzH0+60gGu5/PQ==
diff --git a/tools/BUILD b/tools/BUILD
index 47a2a2e..5d8491a 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -244,7 +244,7 @@
         "-Xep:InvalidInlineTag:ERROR",
         "-Xep:InvalidJavaTimeConstant:ERROR",
         "-Xep:InvalidLink:ERROR",
-        # "-Xep:InvalidParam:WARN",
+        "-Xep:InvalidParam:ERROR",
         "-Xep:InvalidPatternSyntax:ERROR",
         "-Xep:InvalidThrows:ERROR",
         "-Xep:InvalidThrowsLink:ERROR",