Merge "Update all rows of a change on the dashboard when one is changed"
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index 48e729f..7ef9473 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -247,6 +247,38 @@
 ----
 
 
+[[DefinitelyTyped]]
+DefinitelyTyped
+
+* @types/resize-observer-browser
+
+[[DefinitelyTyped_license]]
+----
+    MIT License
+
+    Copyright (c) Microsoft Corporation.
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in all
+    copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+    SOFTWARE
+
+----
+
+
 [[Polymer-2014]]
 Polymer-2014
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index b7cdf8a..5f0fb65 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -3204,6 +3204,38 @@
 ----
 
 
+[[DefinitelyTyped]]
+DefinitelyTyped
+
+* @types/resize-observer-browser
+
+[[DefinitelyTyped_license]]
+----
+    MIT License
+
+    Copyright (c) Microsoft Corporation.
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in all
+    copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+    SOFTWARE
+
+----
+
+
 [[Polymer-2014]]
 Polymer-2014
 
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 9764c8a..a8d9b3d 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1839,6 +1839,8 @@
 Whether to enable the web UI for editing GPG keys.
 |`report_bug_url`    |optional|
 link:config-gerrit.html#gerrit.reportBugUrl[URL to report bugs].
+|`instance_id`       |optional|
+link:config-gerrit.html#gerrit.instanceId[Short identifier for this Gerrit installation].
 |=================================
 
 [[index-config-info]]
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index e02dc21..52c282e 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -190,6 +190,13 @@
 +
 Changes occurring in projects starting with 'PREFIX'.
 
+[[parentof]]
+parentof:'ID'::
+Changes which are parent to the change specified by 'ID'. Change 'ID' can be
+specified as a legacy numerical 'ID' such as 15183, or a Change-Id that can be
+picked from the commit message. This operator will return immediate parents
+and will not return grand parents or higher level ancestors of the given change.
+
 [[parentproject]]
 parentproject:'PROJECT'::
 +
diff --git a/java/com/google/gerrit/extensions/common/GerritInfo.java b/java/com/google/gerrit/extensions/common/GerritInfo.java
index 2ae6703..3265a00 100644
--- a/java/com/google/gerrit/extensions/common/GerritInfo.java
+++ b/java/com/google/gerrit/extensions/common/GerritInfo.java
@@ -23,4 +23,5 @@
   public Boolean editGpgKeys;
   public String reportBugUrl;
   public String primaryWeblinkName;
+  public String instanceId;
 }
diff --git a/java/com/google/gerrit/server/ApprovalInference.java b/java/com/google/gerrit/server/ApprovalInference.java
index 572ae7a..40bf249 100644
--- a/java/com/google/gerrit/server/ApprovalInference.java
+++ b/java/com/google/gerrit/server/ApprovalInference.java
@@ -109,10 +109,10 @@
       PatchSetApproval psa,
       PatchSet.Id psId,
       ChangeKind kind,
-      PatchList patchList) {
+      LabelType type,
+      @Nullable PatchList patchList) {
     int n = psa.key().patchSetId().get();
     checkArgument(n != psId.get());
-    LabelType type = project.getLabelTypes().byLabel(psa.labelId());
 
     if (type == null) {
       logger.atFine().log(
@@ -367,12 +367,17 @@
     logger.atFine().log(
         "change kind for patch set %d of change %d against prior patch set %s is %s",
         ps.id().get(), ps.id().changeId().get(), priorPatchSet.getValue().id().changeId(), kind);
-    PatchList patchList = getPatchList(project, ps, priorPatchSet);
+    PatchList patchList = null;
     for (PatchSetApproval psa : priorApprovals) {
       if (resultByUser.contains(psa.label(), psa.accountId())) {
         continue;
       }
-      if (!canCopy(project, psa, ps.id(), kind, patchList)) {
+      LabelType type = project.getLabelTypes().byLabel(psa.labelId());
+      // Only compute patchList if there is a relevant label, since this is expensive.
+      if (patchList == null && type != null && type.isCopyAllScoresIfListOfFilesDidNotChange()) {
+        patchList = getPatchList(project, ps, priorPatchSet);
+      }
+      if (!canCopy(project, psa, ps.id(), kind, type, patchList)) {
         continue;
       }
       resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(ps.id()));
diff --git a/java/com/google/gerrit/server/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
index a3136d4a..761b57d 100644
--- a/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -32,7 +32,6 @@
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
@@ -47,20 +46,17 @@
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
   private final AccountLoader.Factory accountLoaderFactory;
-  private final SubmitRuleEvaluator submitRuleEvaluator;
 
   @Inject
   ReviewerJson(
       PermissionBackend permissionBackend,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
-      AccountLoader.Factory accountLoaderFactory,
-      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
+      AccountLoader.Factory accountLoaderFactory) {
     this.permissionBackend = permissionBackend;
     this.changeDataFactory = changeDataFactory;
     this.approvalsUtil = approvalsUtil;
     this.accountLoaderFactory = accountLoaderFactory;
-    submitRuleEvaluator = submitRuleEvaluatorFactory.create(SubmitRuleOptions.defaults());
   }
 
   public List<ReviewerInfo> format(Collection<ReviewerResource> rsrcs)
@@ -123,7 +119,7 @@
     if (ps != null) {
       PermissionBackend.ForChange perm = permissionBackend.absentUser(reviewerAccountId).change(cd);
 
-      for (SubmitRecord rec : submitRuleEvaluator.evaluate(cd)) {
+      for (SubmitRecord rec : cd.submitRecords(SubmitRuleOptions.defaults())) {
         if (rec.labels == null) {
           continue;
         }
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index fe915c5..ac37411 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -109,6 +109,7 @@
               ActionType.GIT_UPDATE,
               "createAutoMerge",
               () -> createAutoMergeCommit(repo, rw, ins, merge, mergeStrategy))
+          .defaultTimeoutMultiplier(2)
           .call();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
index 9f58aaf..63e0b7a 100644
--- a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -88,8 +88,10 @@
       // If the latest approved patchset is the current patchset, no need to return anything.
       return "";
     }
-    String diff =
-        String.format("\n\n%d is the latest approved patch-set.\n", latestApprovedPatchsetId.get());
+    StringBuilder diff =
+        new StringBuilder(
+            String.format(
+                "\n\n%d is the latest approved patch-set.\n", latestApprovedPatchsetId.get()));
     PatchList patchList =
         getPatchList(
             notes.getProjectName(),
@@ -103,19 +105,19 @@
             .collect(Collectors.toList());
 
     if (patchListEntryList.isEmpty()) {
-      diff +=
-          "No files were changed between the latest approved patch-set and the submitted one.\n";
-      return diff;
+      diff.append(
+          "No files were changed between the latest approved patch-set and the submitted one.\n");
+      return diff.toString();
     }
 
-    diff += "The change was submitted with unreviewed changes in the following files:\n\n";
+    diff.append("The change was submitted with unreviewed changes in the following files:\n\n");
 
     for (PatchListEntry patchListEntry : patchListEntryList) {
-      diff +=
+      diff.append(
           getDiffForFile(
-              notes, currentPatchset.id(), latestApprovedPatchsetId, patchListEntry, currentUser);
+              notes, currentPatchset.id(), latestApprovedPatchsetId, patchListEntry, currentUser));
     }
-    return diff;
+    return diff.toString();
   }
 
   private String getDiffForFile(
@@ -126,12 +128,13 @@
       CurrentUser currentUser)
       throws AuthException, InvalidChangeOperationException, IOException,
           PermissionBackendException {
-    String diff =
-        String.format(
-            "The name of the file: %s\nInsertions: %d, Deletions: %d.\n\n",
-            patchListEntry.getNewName(),
-            patchListEntry.getInsertions(),
-            patchListEntry.getDeletions());
+    StringBuilder diff =
+        new StringBuilder(
+            String.format(
+                "The name of the file: %s\nInsertions: %d, Deletions: %d.\n\n",
+                patchListEntry.getNewName(),
+                patchListEntry.getInsertions(),
+                patchListEntry.getDeletions()));
     DiffPreferencesInfo diffPreferencesInfo = createDefaultDiffPreferencesInfo();
     PatchScriptFactory patchScriptFactory =
         patchScriptFactoryFactory.create(
@@ -145,66 +148,66 @@
     try {
       patchScript = patchScriptFactory.call();
     } catch (LargeObjectException exception) {
-      diff += "The file content is too large for showing the full diff. \n\n";
-      return diff;
+      diff.append("The file content is too large for showing the full diff. \n\n");
+      return diff.toString();
     }
     if (patchScript.getChangeType() == ChangeType.RENAMED) {
-      diff +=
+      diff.append(
           String.format(
               "The file %s was renamed to %s\n",
-              patchListEntry.getOldName(), patchListEntry.getNewName());
+              patchListEntry.getOldName(), patchListEntry.getNewName()));
     }
     SparseFileContent.Accessor fileA = patchScript.getA().createAccessor();
     SparseFileContent.Accessor fileB = patchScript.getB().createAccessor();
     boolean editsExist = false;
     if (patchScript.getEdits().stream().anyMatch(e -> e.getType() != Edit.Type.EMPTY)) {
-      diff += "```\n";
+      diff.append("```\n");
       editsExist = true;
     }
     for (Edit edit : patchScript.getEdits()) {
-      diff += getDiffForEdit(fileA, fileB, edit);
+      diff.append(getDiffForEdit(fileA, fileB, edit));
     }
     if (editsExist) {
-      diff += "```\n";
+      diff.append("```\n");
     }
-    return diff;
+    return diff.toString();
   }
 
   private String getDiffForEdit(Accessor fileA, Accessor fileB, Edit edit) {
-    String diff = "";
+    StringBuilder diff = new StringBuilder();
     Edit.Type type = edit.getType();
     switch (type) {
       case INSERT:
-        diff += String.format("@@ +%d:%d @@\n", edit.getBeginB(), edit.getEndB());
-        diff += getModifiedLines(fileB, edit.getBeginB(), edit.getEndB(), '+');
-        diff += "\n";
+        diff.append(String.format("@@ +%d:%d @@\n", edit.getBeginB(), edit.getEndB()));
+        diff.append(getModifiedLines(fileB, edit.getBeginB(), edit.getEndB(), '+'));
+        diff.append("\n");
         break;
       case DELETE:
-        diff += String.format("@@ -%d:%d @@\n", edit.getBeginA(), edit.getEndA());
-        diff += getModifiedLines(fileA, edit.getBeginA(), edit.getEndA(), '-');
-        diff += "\n";
+        diff.append(String.format("@@ -%d:%d @@\n", edit.getBeginA(), edit.getEndA()));
+        diff.append(getModifiedLines(fileA, edit.getBeginA(), edit.getEndA(), '-'));
+        diff.append("\n");
         break;
       case REPLACE:
-        diff +=
+        diff.append(
             String.format(
                 "@@ -%d:%d, +%d:%d @@\n",
-                edit.getBeginA(), edit.getEndA(), edit.getBeginB(), edit.getEndB());
-        diff += getModifiedLines(fileA, edit.getBeginA(), edit.getEndA(), '-');
-        diff += getModifiedLines(fileB, edit.getBeginB(), edit.getEndB(), '+');
-        diff += "\n";
+                edit.getBeginA(), edit.getEndA(), edit.getBeginB(), edit.getEndB()));
+        diff.append(getModifiedLines(fileA, edit.getBeginA(), edit.getEndA(), '-'));
+        diff.append(getModifiedLines(fileB, edit.getBeginB(), edit.getEndB(), '+'));
+        diff.append("\n");
         break;
       case EMPTY:
         // do nothing since there is no change here.
     }
-    return diff;
+    return diff.toString();
   }
 
   private String getModifiedLines(Accessor file, int begin, int end, char modificationType) {
-    String diff = "";
+    StringBuilder diff = new StringBuilder();
     for (int i = begin; i < end; i++) {
-      diff += String.format("%c  %s\n", modificationType, file.get(i));
+      diff.append(String.format("%c  %s\n", modificationType, file.get(i)));
     }
-    return diff;
+    return diff.toString();
   }
 
   private DiffPreferencesInfo createDefaultDiffPreferencesInfo() {
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index f047543..bf56000 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -872,36 +872,25 @@
   }
 
   public List<SubmitRecord> submitRecords(SubmitRuleOptions options) {
-    List<SubmitRecord> records = getCachedSubmitRecord(options);
+    // If the change is not submitted yet, 'strict' and 'lenient' both have the same result. If the
+    // change is submitted, SubmitRecord requested with 'strict' will contain just a single entry
+    // that with status=CLOSED. The latter is cheap to evaluate as we don't have to run any actual
+    // evaluation.
+    List<SubmitRecord> records = submitRecords.get(options);
     if (records == null) {
       if (!lazyLoad) {
         return Collections.emptyList();
       }
       records = submitRuleEvaluatorFactory.create(options).evaluate(this);
       submitRecords.put(options, records);
+      if (!change().isClosed() && submitRecords.size() == 1) {
+        // Cache the SubmitRecord with allowClosed = !allowClosed as the SubmitRecord are the same.
+        submitRecords.put(options.toBuilder().allowClosed(!options.allowClosed()).build(), records);
+      }
     }
     return records;
   }
 
-  @Nullable
-  public List<SubmitRecord> getSubmitRecords(SubmitRuleOptions options) {
-    return getCachedSubmitRecord(options);
-  }
-
-  private List<SubmitRecord> getCachedSubmitRecord(SubmitRuleOptions options) {
-    List<SubmitRecord> records = submitRecords.get(options);
-    if (records != null) {
-      return records;
-    }
-
-    if (options.allowClosed() && change != null && change.getStatus().isOpen()) {
-      SubmitRuleOptions openSubmitRuleOptions = options.toBuilder().allowClosed(false).build();
-      return submitRecords.get(openSubmitRuleOptions);
-    }
-
-    return null;
-  }
-
   public void setSubmitRecords(SubmitRuleOptions options, List<SubmitRecord> records) {
     submitRecords.put(options, records);
   }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 68a90d2..4e3edcd 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -173,6 +173,7 @@
   public static final String FIELD_MESSAGE = "message";
   public static final String FIELD_OWNER = "owner";
   public static final String FIELD_OWNERIN = "ownerin";
+  public static final String FIELD_PARENTOF = "parentof";
   public static final String FIELD_PARENTPROJECT = "parentproject";
   public static final String FIELD_PATH = "path";
   public static final String FIELD_PENDING_REVIEWER = "pendingreviewer";
@@ -735,6 +736,16 @@
   }
 
   @Operator
+  public Predicate<ChangeData> parentof(String value) throws QueryParseException {
+    List<ChangeData> changes = parseChangeData(value);
+    List<Predicate<ChangeData>> or = new ArrayList<>(changes.size());
+    for (ChangeData c : changes) {
+      or.add(new ParentOfPredicate(value, c, args.repoManager));
+    }
+    return Predicate.or(or);
+  }
+
+  @Operator
   public Predicate<ChangeData> parentproject(String name) {
     return new ParentProjectPredicate(args.projectCache, args.childProjects, name);
   }
@@ -1560,14 +1571,18 @@
   }
 
   private List<Change> parseChange(String value) throws QueryParseException {
+    return asChanges(parseChangeData(value));
+  }
+
+  private List<ChangeData> parseChangeData(String value) throws QueryParseException {
     if (PAT_LEGACY_ID.matcher(value).matches()) {
       Optional<Change.Id> id = Change.Id.tryParse(value);
       if (!id.isPresent()) {
         throw error("Invalid change id " + value);
       }
-      return asChanges(args.queryProvider.get().byLegacyChangeId(id.get()));
+      return args.queryProvider.get().byLegacyChangeId(id.get());
     } else if (PAT_CHANGE_ID.matcher(value).matches()) {
-      List<Change> changes = asChanges(args.queryProvider.get().byKeyPrefix(parseChangeId(value)));
+      List<ChangeData> changes = args.queryProvider.get().byKeyPrefix(parseChangeId(value));
       if (changes.isEmpty()) {
         throw error("Change " + value + " not found");
       }
diff --git a/java/com/google/gerrit/server/query/change/ParentOfPredicate.java b/java/com/google/gerrit/server/query/change/ParentOfPredicate.java
new file mode 100644
index 0000000..e48d586
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ParentOfPredicate.java
@@ -0,0 +1,62 @@
+// 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.common.collect.Sets;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.query.Matchable;
+import com.google.gerrit.index.query.OperatorPredicate;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import java.io.IOException;
+import java.util.Set;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class ParentOfPredicate extends OperatorPredicate<ChangeData>
+    implements Matchable<ChangeData> {
+  protected final Set<RevCommit> parents;
+
+  public ParentOfPredicate(String value, ChangeData change, GitRepositoryManager repoManager) {
+    super(ChangeQueryBuilder.FIELD_PARENTOF, value);
+    this.parents = getParents(change, repoManager);
+  }
+
+  @Override
+  public boolean match(ChangeData changeData) {
+    return changeData.patchSets().stream().anyMatch(ps -> parents.contains(ps.commitId()));
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+
+  protected Set<RevCommit> getParents(ChangeData change, GitRepositoryManager repoManager) {
+    PatchSet ps = change.currentPatchSet();
+    try (Repository repo = repoManager.openRepository(change.project());
+        RevWalk walk = new RevWalk(repo)) {
+      RevCommit c = walk.parseCommit(ps.commitId());
+      return Sets.newHashSet(c.getParents());
+    } catch (IOException e) {
+      throw new StorageException(
+          String.format(
+              "Loading commit %s for ps %d of change %d failed.",
+              ps.commitId(), ps.id().get(), ps.id().changeId().get()),
+          e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 562bdf8..73b38b2 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -1228,9 +1228,10 @@
     }
 
     private boolean isReviewer(ChangeContext ctx) {
-      ChangeData cd = changeDataFactory.create(ctx.getNotes());
-      ReviewerSet reviewers = cd.reviewers();
-      return reviewers.byState(REVIEWER).contains(ctx.getAccountId());
+      return approvalsUtil
+          .getReviewers(ctx.getNotes())
+          .byState(REVIEWER)
+          .contains(ctx.getAccountId());
     }
 
     private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 5459ede..0a5692e 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -304,6 +304,7 @@
     info.editGpgKeys =
         toBoolean(enableSignedPush && config.getBoolean("gerrit", null, "editGpgKeys", true));
     info.primaryWeblinkName = config.getString("gerrit", null, "primaryWeblinkName");
+    info.instanceId = config.getString("gerrit", null, "instanceId");
     return info;
   }
 
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 871d8d2..f486650 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -189,7 +189,7 @@
       // date by this point.
       ChangeData cd = requireNonNull(changes.get(id), () -> String.format("ChangeData for %s", id));
       return requireNonNull(
-          cd.getSubmitRecords(submitRuleOptions(allowClosed)),
+          cd.submitRecords(submitRuleOptions(allowClosed)),
           "getSubmitRecord only valid after submit rules are evalutated");
     }
 
@@ -549,8 +549,8 @@
             .listener(retryTracker)
             // Up to the entire submit operation is retried, including possibly many projects.
             // Multiply the timeout by the number of projects we're actually attempting to
-            // submit.
-            .defaultTimeoutMultiplier(filteredNoteDbChangeSet.projects().size())
+            // submit. Times 2 to retry more persistently, to increase success rate.
+            .defaultTimeoutMultiplier(filteredNoteDbChangeSet.projects().size() * 2)
             // By default, we only retry lock failures. Here it's better to also retry unexpected
             // runtime exceptions.
             .retryOn(t -> t instanceof RuntimeException)
diff --git a/javatests/com/google/gerrit/acceptance/pgm/BUILD b/javatests/com/google/gerrit/acceptance/pgm/BUILD
index 64bd25c..5b18d02 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/BUILD
+++ b/javatests/com/google/gerrit/acceptance/pgm/BUILD
@@ -27,7 +27,6 @@
     labels = [
         "docker",
         "elastic",
-        "exclusive",
         "pgm",
         "no_windows",
     ],
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index 0a84db4..4738f64 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -74,6 +74,7 @@
   @GerritConfig(name = "gerrit.allProjects", value = "Root")
   @GerritConfig(name = "gerrit.allUsers", value = "Users")
   @GerritConfig(name = "gerrit.reportBugUrl", value = "https://example.com/report")
+  @GerritConfig(name = "gerrit.instanceId", value = "devops-instance")
 
   // suggest
   @GerritConfig(name = "suggest.from", value = "3")
@@ -116,6 +117,7 @@
     assertThat(i.gerrit.allProjects).isEqualTo("Root");
     assertThat(i.gerrit.allUsers).isEqualTo("Users");
     assertThat(i.gerrit.reportBugUrl).isEqualTo("https://example.com/report");
+    assertThat(i.gerrit.instanceId).isEqualTo("devops-instance");
 
     // plugin
     assertThat(i.plugin.jsResourcePaths).isEmpty();
@@ -184,6 +186,7 @@
     assertThat(i.gerrit.allProjects).isEqualTo(AllProjectsNameProvider.DEFAULT);
     assertThat(i.gerrit.allUsers).isEqualTo(AllUsersNameProvider.DEFAULT);
     assertThat(i.gerrit.reportBugUrl).isNull();
+    assertThat(i.gerrit.instanceId).isNull();
 
     // plugin
     assertThat(i.plugin.jsResourcePaths).isEmpty();
diff --git a/javatests/com/google/gerrit/elasticsearch/BUILD b/javatests/com/google/gerrit/elasticsearch/BUILD
index be35d5a..3036811 100644
--- a/javatests/com/google/gerrit/elasticsearch/BUILD
+++ b/javatests/com/google/gerrit/elasticsearch/BUILD
@@ -55,7 +55,6 @@
 ELASTICSEARCH_TAGS = [
     "docker",
     "elastic",
-    "exclusive",
 ]
 
 [junit_tests(
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 7efcb4b..48bd321 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -705,6 +705,24 @@
   }
 
   @Test
+  public void byParentOf() throws Exception {
+    TestRepository<Repo> repo1 = createProject("repo1");
+    RevCommit commit1 = repo1.parseBody(repo1.commit().message("message").create());
+    Change change1 = insert(repo1, newChangeForCommit(repo1, commit1));
+    RevCommit commit2 = repo1.parseBody(repo1.commit(commit1));
+    Change change2 = insert(repo1, newChangeForCommit(repo1, commit2));
+    RevCommit commit3 = repo1.parseBody(repo1.commit(commit1, commit2));
+    Change change3 = insert(repo1, newChangeForCommit(repo1, commit3));
+
+    assertQuery("parentof:" + change1.getId().get());
+    assertQuery("parentof:" + change1.getKey().get());
+    assertQuery("parentof:" + change2.getId().get(), change1);
+    assertQuery("parentof:" + change2.getKey().get(), change1);
+    assertQuery("parentof:" + change3.getId().get(), change2, change1);
+    assertQuery("parentof:" + change3.getKey().get(), change2, change1);
+  }
+
+  @Test
   public void byParentProject() throws Exception {
     TestRepository<Repo> repo1 = createProject("repo1");
     TestRepository<Repo> repo2 = createProject("repo2", "repo1");
diff --git a/modules/jgit b/modules/jgit
index 4560bdf..9bfb0f3 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 4560bdf7e2e3c16a7c7bb3f2fcf067bb1eee26fb
+Subproject commit 9bfb0f3a4ec856dcbebb477a1ee8803a3c47c194
diff --git a/plugins/replication b/plugins/replication
index ab80790..14766e7 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit ab8079055a92fa4068a2982306c11425f347e12f
+Subproject commit 14766e75f91886ab48951035d59a78c8c3f07471
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index 5305cea..f2b4c89 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -400,7 +400,7 @@
       }
       return '';
     }
-    // TODO(TS): The following condtion seems always false, because params
+    // TODO(TS): The following condition seems always false, because params
     // never has detailType property. Remove it.
     if (
       ((params as unknown) as AdminSubsectionLink).detailType &&
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
index b0065d9..9cc6357 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
@@ -192,7 +192,7 @@
       };
       element.section = 'refs/*';
 
-      // Typically called on ready since elements will have properies defined
+      // Typically called on ready since elements will have properties defined
       // by the parent element.
       element._setupValues(element.rule);
       flush();
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts
index 9812933..add7ca5 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts
@@ -36,7 +36,7 @@
       width: 10em;
     }
     #graphic iron-icon {
-      color: #9e9e9e;
+      color: var(--gray-foreground);
       height: 5em;
       width: 5em;
     }
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
index 14dccea..a34bd63 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -58,7 +58,7 @@
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
 
-const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
+const PROJECT_PLACEHOLDER_PATTERN = /\${project}/g;
 const RELOAD_DASHBOARD_INTERVAL_MS = 10 * 1000;
 
 export interface GrDashboardView {
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 c3858d8..87b09c7 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
@@ -2068,10 +2068,10 @@
    *
    */
   _waitForChangeReachable(changeNum: NumericChangeId) {
-    let attempsRemaining = AWAIT_CHANGE_ATTEMPTS;
+    let attemptsRemaining = AWAIT_CHANGE_ATTEMPTS;
     return new Promise(resolve => {
       const check = () => {
-        attempsRemaining--;
+        attemptsRemaining--;
         // Pass a no-op error handler to avoid the "not found" error toast.
         this.restApiService
           .getChange(changeNum, () => {})
@@ -2082,7 +2082,7 @@
               return;
             }
 
-            if (attempsRemaining) {
+            if (attemptsRemaining) {
               this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
             } else {
               resolve(false);
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
index 6c9a27d..fbb70b7 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
@@ -2110,7 +2110,7 @@
 
       element = basicFixture.instantiate();
       // getChangeRevisionActions is not called without
-      // set the following properies
+      // set the following properties
       element.change = {};
       element.changeNum = '42';
       element.latestPatchNum = '2';
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 c5c73c5..59bf8ad 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
@@ -78,7 +78,7 @@
     }
     .icon.help,
     .icon.notTrusted {
-      color: #ffa62f;
+      color: var(--warning-foreground);
     }
     .icon.invalid {
       color: var(--negative-red-text-color);
@@ -87,7 +87,7 @@
       color: var(--positive-green-text-color);
     }
     .parentList.notCurrent.nonMerge #parentNotCurrentMessage {
-      --arrow-color: #ffa62f;
+      --arrow-color: var(--warning-foreground);
       display: inline-block;
     }
     .separatedSection {
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
index c0e87f3..adc7fd3 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
@@ -37,6 +37,7 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {appContext} from '../../../services/app-context';
 import {KnownExperimentId} from '../../../services/flags/flags';
+import {labelCompare} from '../../../utils/label-util';
 
 interface ChangeRequirement extends Requirement {
   satisfied: boolean;
@@ -136,7 +137,7 @@
     const labels = labelsRecord.base || {};
     const allLabels: Label[] = [];
 
-    for (const label of Object.keys(labels).sort()) {
+    for (const label of Object.keys(labels).sort(labelCompare)) {
       allLabels.push({
         labelName: label,
         icon: this._computeLabelIcon(labels[label]),
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
index e02b337..a502949 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
@@ -23,7 +23,7 @@
       width: 100%;
     }
     .status {
-      color: #ffa62f;
+      color: var(--warning-foreground);
       display: inline-block;
       text-align: center;
       vertical-align: top;
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index 4af51a9..8322f3c 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -22,7 +22,8 @@
 import {KnownExperimentId} from '../../../services/flags/flags';
 import {
   allRuns$,
-  aPluginHasRegistered,
+  aPluginHasRegistered$,
+  someProvidersAreLoading$,
 } from '../../../services/checks/checks-model';
 import {
   Category,
@@ -56,7 +57,7 @@
 import {notUndefined} from '../../../types/types';
 import {uniqueDefinedAvatar} from '../../../utils/account-util';
 import {PrimaryTab} from '../../../constants/constants';
-import {CommentTabState} from '../../../types/events';
+import {ChecksTabState, CommentTabState} from '../../../types/events';
 
 export enum SummaryChipStyles {
   INFO = 'info',
@@ -110,13 +111,6 @@
         .summaryChip.check iron-icon {
           color: var(--gray-foreground);
         }
-        .summaryChip.info {
-          border-color: var(--info-deemphasized-foreground;
-          background-color: var(--info-deemphasized-background);
-        }
-        .summaryChip.info iron-icon {
-          color: var(--info-deemphasized-foreground);
-        }
       `,
     ];
   }
@@ -229,19 +223,13 @@
     const chipClass = `checksChip font-small ${this.icon}`;
     const grIcon = `gr-icons:${this.icon}`;
     return html`
-      <div class="${chipClass}" role="button" @click="${this.handleClick}">
+      <div class="${chipClass}" role="button">
         <iron-icon icon="${grIcon}"></iron-icon>
         <div class="text">${this.text}</div>
         <slot></slot>
       </div>
     `;
   }
-
-  private handleClick(e: MouseEvent) {
-    e.stopPropagation();
-    e.preventDefault();
-    fireShowPrimaryTab(this, PrimaryTab.CHECKS);
-  }
 }
 
 /** What is the maximum number of expanded checks chips? */
@@ -268,13 +256,17 @@
   @property()
   showChecksSummary = false;
 
+  @property()
+  someProvidersAreLoading = false;
+
   /** Is reset when rendering beings and decreases while chips are rendered. */
   private detailsQuota = DETAILS_QUOTA;
 
   constructor() {
     super();
     this.subscribe('runs', allRuns$);
-    this.subscribe('showChecksSummary', aPluginHasRegistered);
+    this.subscribe('showChecksSummary', aPluginHasRegistered$);
+    this.subscribe('someProvidersAreLoading', someProvidersAreLoading$);
   }
 
   static get styles() {
@@ -318,14 +310,15 @@
 
   renderChecksZeroState() {
     if (this.runs.some(isRunningOrHasCompleted)) return;
-    return html`<span class="font-small zeroState">No results</span>`;
+    const msg = this.someProvidersAreLoading ? 'Loading...' : 'No results';
+    return html`<span class="font-small zeroState">${msg}</span>`;
   }
 
   renderChecksChipForCategory(category: Category) {
     const icon = iconForCategory(category);
     const runs = this.runs.filter(run => hasResultsOf(run, category));
     const count = (run: CheckRun) => getResultsOf(run, category);
-    return this.renderChecksChip(icon, runs, count);
+    return this.renderChecksChip(icon, runs, category, count);
   }
 
   renderChecksChipForStatus(
@@ -334,12 +327,13 @@
   ) {
     const icon = iconForStatus(status);
     const runs = this.runs.filter(filter);
-    return this.renderChecksChip(icon, runs, () => []);
+    return this.renderChecksChip(icon, runs, status, () => []);
   }
 
   renderChecksChip(
     icon: string,
     runs: CheckRun[],
+    statusOrCategory: RunStatus | Category,
     resultFilter: (run: CheckRun) => CheckResult[]
   ) {
     if (runs.length === 0) {
@@ -359,9 +353,10 @@
           class="${icon}"
           .icon="${icon}"
           .text="${text}"
+          @click="${() => this.onChipClick({checkName: run.checkName})}"
           >${links.map(
             link => html`
-              <a href="${link.url}" target="_blank" @click="${this.onClick}"
+              <a href="${link.url}" target="_blank" @click="${this.onLinkClick}"
                 ><iron-icon class="launch" icon="gr-icons:launch"></iron-icon
               ></a>
             `
@@ -380,11 +375,18 @@
       class="${icon}"
       .icon="${icon}"
       .text="${sum}"
+      @click="${() => this.onChipClick({statusOrCategory})}"
     ></gr-checks-chip>`;
   }
 
-  private onClick(e: MouseEvent) {
-    // Prevents handleClick() from reacting to <a> link clicks.
+  private onChipClick(state: ChecksTabState) {
+    fireShowPrimaryTab(this, PrimaryTab.CHECKS, true, {
+      checksTab: state,
+    });
+  }
+
+  private onLinkClick(e: MouseEvent) {
+    // Prevents onChipClick() from reacting to <a> link clicks.
     e.stopPropagation();
   }
 
@@ -436,7 +438,6 @@
               ><gr-summary-chip
                 styleType=${SummaryChipStyles.WARNING}
                 category=${CommentTabState.UNRESOLVED}
-                icon="message"
                 ?hidden=${!countUnresolvedComments}
               >
                 ${unresolvedAuthors.map(
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 3d7646f..a7f5ea7 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
@@ -171,7 +171,7 @@
 import {fireTitleChange} from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
 import {takeUntil} from 'rxjs/operators';
-import {aPluginHasRegistered} from '../../../services/checks/checks-model';
+import {aPluginHasRegistered$} from '../../../services/checks/checks-model';
 import {Subject} from 'rxjs';
 import {GrRelatedChangesListExperimental} from '../gr-related-changes-list-experimental/gr-related-changes-list-experimental';
 
@@ -274,11 +274,13 @@
    * @event show-auth-required
    */
 
-  reporting = appContext.reportingService;
+  private readonly reporting = appContext.reportingService;
 
-  flagsService = appContext.flagsService;
+  private readonly flagsService = appContext.flagsService;
 
-  readonly jsAPI = appContext.jsApiService;
+  private readonly jsAPI = appContext.jsApiService;
+
+  private readonly changeService = appContext.changeService;
 
   /**
    * URL params passed from the router.
@@ -594,7 +596,7 @@
   /** @override */
   ready() {
     super.ready();
-    aPluginHasRegistered.pipe(takeUntil(this.disconnected$)).subscribe(b => {
+    aPluginHasRegistered$.pipe(takeUntil(this.disconnected$)).subscribe(b => {
       this._showChecksTab = b;
     });
     this._isNewChangeSummaryUiEnabled = this.flagsService.isEnabled(
@@ -2022,6 +2024,7 @@
         this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
 
         this._change = change;
+        this.changeService.updateChange(change);
         if (
           !this._patchRange ||
           !this._patchRange.patchNum ||
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 f3fb860..08c04e8 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
@@ -690,7 +690,10 @@
         is="dom-if"
         if="[[_isTabActive(_constants.PrimaryTab.CHECKS, _activeTabs)]]"
       >
-        <gr-checks-tab id="checksTab"></gr-checks-tab>
+        <gr-checks-tab
+          id="checksTab"
+          tab-state="[[_tabState.checksTab]]"
+        ></gr-checks-tab>
       </template>
       <template
         is="dom-if"
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 99e5356..ae446cd 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
@@ -105,6 +105,7 @@
 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';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 const fixture = fixtureFromElement('gr-change-view');
@@ -2766,7 +2767,7 @@
     element._change = {...change};
     element._patchRange = {patchNum: 4 as PatchSetNum};
     element._mergeable = true;
-    const showStub = sinon.stub(element.jsAPI, 'handleEvent');
+    const showStub = sinon.stub(appContext.jsApiService, 'handleEvent');
     element._sendShowChangeEvent();
     assert.isTrue(showStub.calledOnce);
     assert.equal(showStub.lastCall.args[0], EventType.SHOW_CHANGE);
@@ -2968,11 +2969,11 @@
 
     test("don't report changeDisplayed on reply", done => {
       const changeDisplayStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeDisplayed'
       );
       const changeFullyLoadedStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeFullyLoaded'
       );
       element._handleReplySent();
@@ -2985,11 +2986,11 @@
 
     test('report changeDisplayed on _paramsChanged', done => {
       const changeDisplayStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeDisplayed'
       );
       const changeFullyLoadedStub = sinon.stub(
-        element.reporting,
+        appContext.reportingService,
         'changeFullyLoaded'
       );
       element._paramsChanged({
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
index 99094d2..29b3752 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
@@ -25,7 +25,7 @@
       margin-bottom: var(--spacing-l);
     }
     .warningBeforeSubmit {
-      color: var(--error-text-color);
+      color: var(--warning-foreground);
       vertical-align: top;
       margin-right: var(--spacing-s);
     }
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 a5c0624..c2947a6 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
@@ -803,7 +803,7 @@
   }
 
   /**
-   * Handle all events from the file list dom-repeat so event handleers don't
+   * Handle all events from the file list dom-repeat so event handlers don't
    * have to get registered for potentially very long lists.
    */
   _handleFileListClick(e: MouseEvent) {
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 23718fa..c57a2d5 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
@@ -154,7 +154,7 @@
       padding-left: var(--spacing-s);
     }
     .drafts {
-      color: #c62828;
+      color: var(--error-foreground);
       font-weight: var(--font-weight-bold);
     }
     .show-hide-icon:focus {
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 285b73f..80d8729 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
@@ -1244,7 +1244,7 @@
       // are no deletions.
       assert.equal(element._computeBarAdditionWidth(file, stats), 30);
 
-      // If there are no insetions, there is no width.
+      // If there are no insertions, there is no width.
       stats.maxInserted = 0;
       assert.equal(element._computeBarAdditionWidth(file, stats), 0);
 
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index a966186..661cd1a 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -37,6 +37,7 @@
 } from '../gr-label-score-row/gr-label-score-row';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {appContext} from '../../../services/app-context';
+import {labelCompare} from '../../../utils/label-util';
 
 @customElement('gr-label-scores')
 export class GrLabelScores extends GestureEventListeners(
@@ -147,7 +148,7 @@
     if (!labelRecord?.base) return [];
     const labelsObj = labelRecord.base;
     return Object.keys(labelsObj)
-      .sort()
+      .sort(labelCompare)
       .map(key => {
         return {
           name: key,
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index 2fe409a..f913459 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -54,7 +54,7 @@
 } from '../../../utils/patch-set-util';
 import {isServiceUser} from '../../../utils/account-util';
 
-const PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?(?:P|p)atch (?:S|s)et \d+:\s*(.*)/;
+const PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?[Pp]atch [Ss]et \d+:\s*(.*)/;
 const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?[.]?$/;
 const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
 const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
@@ -484,7 +484,7 @@
   _handleAnchorClick(e: Event) {
     e.preventDefault();
     // The element which triggers _handleAnchorClick is rendered only if
-    // message.id defined: the elemenet is wrapped in dom-if if="[[message.id]]"
+    // message.id defined: the element is wrapped in dom-if if="[[message.id]]"
     const detail: MessageAnchorTapDetail = {
       id: this.message!.id,
     };
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
index b93040b..8d03e32 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
@@ -305,7 +305,7 @@
           </gr-button>
         </template>
         <template is="dom-if" if="[[message._revision_number]]">
-          <span class="patchset">[[message._revision_number]]</span>
+          <span class="patchset">[[message._revision_number]] |</span>
         </template>
         <template is="dom-if" if="[[!message.id]]">
           <span class="date">
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 9877a95..bc3f167 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
@@ -118,7 +118,7 @@
 
 /**
  * If messages have the same tag, then that influences grouping and whether
- * a message is initally hidden or not, see isImportant(). So we are applying
+ * a message is initially hidden or not, see isImportant(). So we are applying
  * some "magic" rules here in order to hide exactly the right messages.
  *
  * 1. If a message does not have a tag, but is associated with robot comments,
@@ -263,7 +263,7 @@
   _combinedMessages: CombinedMessage[] = [];
 
   @property({type: Object, computed: '_computeLabelExtremes(labels.*)'})
-  _labelExtremes: {[lableName: string]: VotingRangeInfo} = {};
+  _labelExtremes: {[labelName: string]: VotingRangeInfo} = {};
 
   private readonly reporting = appContext.reportingService;
 
@@ -466,7 +466,7 @@
       LabelNameToInfoMap
     >
   ) {
-    const extremes: {[lableName: string]: VotingRangeInfo} = {};
+    const extremes: {[labelName: string]: VotingRangeInfo} = {};
     const labels = labelRecord.base;
     if (!labels) {
       return extremes;
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-change.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-change.ts
index 3ed545e..7b698f1 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-change.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list-experimental/gr-related-change.ts
@@ -77,10 +77,10 @@
           margin-left: var(--spacing-xs);
         }
         .notCurrent {
-          color: #e65100;
+          color: var(--warning-foreground);
         }
         .indirectAncestor {
-          color: #33691e;
+          color: var(--indirect-ancestor-text-color);
         }
         .submittableCheck {
           padding-left: var(--spacing-s);
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts
index 9941fa9..2f53319 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts
@@ -66,10 +66,10 @@
       margin-left: var(--spacing-xs);
     }
     .notCurrent {
-      color: #e65100;
+      color: var(--warning-foreground);
     }
     .indirectAncestor {
-      color: #33691e;
+      color: var(--indirect-ancestor-text-color);
     }
     .submittableCheck {
       padding-left: var(--spacing-s);
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 705a402..a8e89b6 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
@@ -784,7 +784,7 @@
   }
 
   _handle400Error(r?: Response | null) {
-    if (!r) throw new Error('Reponse is empty.');
+    if (!r) throw new Error('Response is empty.');
     let response: Response = r;
     // A call to _saveReview could fail with a server error if erroneous
     // reviewers were requested. This is signalled with a 400 Bad Request
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
index 6682bfb..0575239 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
@@ -1276,7 +1276,7 @@
         'Send and Start review');
   });
 
-  test('_handle400Error reviewrs and CCs', done => {
+  test('_handle400Error reviewers and CCs', done => {
     const error1 = 'error 1';
     const error2 = 'error 2';
     const error3 = 'error 3';
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index e448374..ef2430c 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -24,7 +24,14 @@
   query,
 } from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
-import {Category, CheckRun, Link, RunStatus, Tag} from '../../api/checks';
+import {
+  Category,
+  CheckRun,
+  Link,
+  LinkIcon,
+  RunStatus,
+  Tag,
+} from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
 import {RunResult} from '../../services/checks/checks-model';
 import {
@@ -35,6 +42,7 @@
 } from '../../services/checks/checks-util';
 import {assertIsDefined} from '../../utils/common-util';
 import {whenVisible} from '../../utils/dom-util';
+import {durationString} from '../../utils/date-util';
 
 @customElement('gr-result-row')
 class GrResultRow extends GrLitElement {
@@ -295,6 +303,8 @@
   @property()
   runs: CheckRun[] = [];
 
+  private isSectionExpanded = new Map<Category | 'SUCCESS', boolean>();
+
   static get styles() {
     return [
       sharedStyles,
@@ -312,23 +322,36 @@
           margin-top: var(--spacing-l);
           margin-left: var(--spacing-l);
           text-transform: capitalize;
+          cursor: default;
         }
-        .categoryHeader iron-icon {
+        .categoryHeader .expandIcon {
+          width: var(--line-height-h3);
+          height: var(--line-height-h3);
+          margin-right: var(--spacing-s);
+        }
+        .categoryHeader .statusIcon {
           position: relative;
-          top: 1px;
+          top: 2px;
         }
-        .categoryHeader iron-icon.error {
+        .categoryHeader .statusIcon.error {
           color: var(--error-foreground);
         }
-        .categoryHeader iron-icon.warning {
+        .categoryHeader .statusIcon.warning {
           color: var(--warning-foreground);
         }
-        .categoryHeader iron-icon.info {
+        .categoryHeader .statusIcon.info {
           color: var(--info-foreground);
         }
-        .categoryHeader iron-icon.success {
+        .categoryHeader .statusIcon.success {
           color: var(--success-foreground);
         }
+        .collapsed table {
+          display: none;
+        }
+        .collapsed {
+          border-bottom: 1px solid var(--border-color);
+          padding-bottom: var(--spacing-m);
+        }
         .noCompleted {
           margin-top: var(--spacing-l);
         }
@@ -354,7 +377,7 @@
       ${this.renderFilter()} ${this.renderNoCompleted()}
       ${this.renderSection(Category.ERROR)}
       ${this.renderSection(Category.WARNING)}
-      ${this.renderSection(Category.INFO)} ${this.renderSuccess()}
+      ${this.renderSection(Category.INFO)} ${this.renderSection('SUCCESS')}
     `;
   }
 
@@ -384,36 +407,62 @@
     return html`<div class="noCompleted">${text}</div>`;
   }
 
-  renderSection(category: Category) {
+  renderSection(category: Category | 'SUCCESS') {
     const catString = category.toString().toLowerCase();
-    const runs = this.runs.filter(r =>
-      (r.results ?? []).some(res => res.category === category)
-    );
+    let runs = this.runs;
+    if (category === 'SUCCESS') {
+      runs = runs
+        .filter(hasCompletedWithoutResults)
+        .filter(r => this.filterRegExp.test(r.checkName));
+    } else {
+      runs = runs.filter(r =>
+        (r.results ?? []).some(res => res.category === category)
+      );
+    }
     if (runs.length === 0) return;
+    const expanded = this.isSectionExpanded.get(category) ?? true;
+    const expandedClass = expanded ? 'expanded' : 'collapsed';
+    const icon = expanded ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
     return html`
-      <h3 class="categoryHeader heading-3">
-        <iron-icon
-          icon="gr-icons:${iconForCategory(category)}"
-          class="${catString}"
-        ></iron-icon>
-        ${catString}
-      </h3>
-      <table class="resultsTable">
-        <thead>
-          <tr class="headerRow">
-            <th class="iconCol"></th>
-            <th class="nameCol">Run</th>
-            <th class="summaryCol">Summary</th>
-            <th class="expanderCol"></th>
-          </tr>
-        </thead>
-        <tbody>
-          ${runs.map(run => this.renderRun(category, run))}
-        </tbody>
-      </table>
+      <div class="${expandedClass}">
+        <h3
+          class="categoryHeader heading-3"
+          @click="${() => this.toggleExpanded(category)}"
+        >
+          <iron-icon class="expandIcon" icon="${icon}"></iron-icon>
+          <iron-icon
+            icon="gr-icons:${iconForCategory(category)}"
+            class="statusIcon ${catString}"
+          ></iron-icon>
+          ${catString}
+        </h3>
+        <table class="resultsTable">
+          <thead>
+            <tr class="headerRow">
+              <th class="iconCol"></th>
+              <th class="nameCol">Run</th>
+              <th class="summaryCol">Summary</th>
+              <th class="expanderCol"></th>
+            </tr>
+          </thead>
+          <tbody>
+            ${runs.map(run =>
+              category === 'SUCCESS'
+                ? this.renderSuccessfulRun(run)
+                : this.renderRun(category, run)
+            )}
+          </tbody>
+        </table>
+      </div>
     `;
   }
 
+  toggleExpanded(category: Category | 'SUCCESS') {
+    const expanded = this.isSectionExpanded.get(category) ?? true;
+    this.isSectionExpanded.set(category, !expanded);
+    this.requestUpdate();
+  }
+
   renderRun(category: Category, run: CheckRun) {
     return html`${run.results
       ?.filter(result => result.category === category)
@@ -428,38 +477,30 @@
       )}`;
   }
 
-  renderSuccess() {
-    const runs = this.runs
-      .filter(hasCompletedWithoutResults)
-      .filter(r => this.filterRegExp.test(r.checkName));
-    if (runs.length === 0) return;
-    return html`
-      <h3 class="categoryHeader heading-3">
-        <iron-icon
-          icon="gr-icons:check-circle-outline"
-          class="success"
-        ></iron-icon>
-        Success
-      </h3>
-      <table class="resultsTable">
-        <tr class="headerRow">
-          <th class="iconCol"></th>
-          <th class="nameCol">Run</th>
-          <th class="summaryCol">Summary</th>
-          <th class="expanderCol"></th>
-        </tr>
-        ${runs.map(run => this.renderSuccessfulRun(run))}
-      </table>
-    `;
-  }
-
   renderSuccessfulRun(run: CheckRun) {
     const adaptedRun: RunResult = {
       category: Category.INFO, // will not be used, but is required
       summary: run.statusDescription ?? '',
-      message: 'Completed without results.',
       ...run,
     };
+    if (!run.statusDescription) {
+      const start = run.scheduledTimestamp ?? run.startedTimestamp;
+      const end = run.finishedTimestamp;
+      let duration = '';
+      if (start && end) {
+        duration = ` in ${durationString(start, end, true)}`;
+      }
+      adaptedRun.message = `Completed without results${duration}.`;
+    }
+    if (run.statusLink) {
+      adaptedRun.links = [
+        {
+          url: run.statusLink,
+          primary: true,
+          icon: LinkIcon.EXTERNAL,
+        },
+      ];
+    }
     return html`<gr-result-row .result="${adaptedRun}"></gr-result-row>`;
   }
 }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 71ad041..31f17724 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from 'lit-html';
+import {html, nothing} from 'lit-html';
 import {classMap} from 'lit-html/directives/class-map';
 import {
   css,
@@ -29,8 +29,10 @@
 import {
   compareByWorstCategory,
   fireActionTriggered,
+  iconForCategory,
   iconForRun,
   primaryRunAction,
+  worstCategory,
 } from '../../services/checks/checks-util';
 import {
   allRuns$,
@@ -41,7 +43,7 @@
   fakeRun4,
   updateStateSetResults,
 } from '../../services/checks/checks-model';
-import {assertIsDefined, toggleSetMembership} from '../../utils/common-util';
+import {assertIsDefined} from '../../utils/common-util';
 import {whenVisible} from '../../utils/dom-util';
 
 export interface RunSelectedEventDetail {
@@ -111,16 +113,19 @@
         .chip.placeholder {
           border-left: var(--thick-border) solid var(--border-color);
         }
-        .chip.error iron-icon {
+        .chip.placeholder iron-icon {
+          display: none;
+        }
+        iron-icon.error {
           color: var(--error-foreground);
         }
-        .chip.warning iron-icon {
+        iron-icon.warning {
           color: var(--warning-foreground);
         }
-        .chip.info-outline iron-icon {
+        iron-icon.info-outline {
           color: var(--info-foreground);
         }
-        .chip.check-circle-outline iron-icon {
+        iron-icon.check-circle-outline {
           color: var(--success-foreground);
         }
         /* Additional 'div' for increased specificity. */
@@ -195,7 +200,8 @@
     return html`
       <div @click="${this.handleChipClick}" class="${classMap(classes)}">
         <div class="left">
-          <iron-icon icon="gr-icons:${icon}"></iron-icon>
+          <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+          ${this.renderAdditionalIcon()}
           <span class="name">${this.run.checkName}</span>
         </div>
         <div class="right">
@@ -212,6 +218,20 @@
     `;
   }
 
+  /**
+   * For RUNNING we also want to render an icon representing the worst result
+   * that has been reported until now - if there are any results already.
+   */
+  renderAdditionalIcon() {
+    if (this.run.status !== RunStatus.RUNNING) return nothing;
+    const category = worstCategory(this.run);
+    if (!category) return nothing;
+    const icon = iconForCategory(category);
+    return html`
+      <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+    `;
+  }
+
   private handleChipClick(e: MouseEvent) {
     e.stopPropagation();
     e.preventDefault();
@@ -236,7 +256,10 @@
   @property()
   runs: CheckRun[] = [];
 
-  private selectedRuns = new Set<string>();
+  @property()
+  selectedRuns: string[] = [];
+
+  private isSectionExpanded = new Map<RunStatus, boolean>();
 
   constructor() {
     super();
@@ -251,9 +274,24 @@
           display: block;
           padding: var(--spacing-xl);
         }
-        .statusHeader {
+        .expandIcon {
+          width: var(--line-height-h3);
+          height: var(--line-height-h3);
+        }
+        .sectionHeader {
           padding-top: var(--spacing-l);
           text-transform: capitalize;
+          cursor: default;
+        }
+        .sectionHeader h3 {
+          display: inline-block;
+        }
+        .collapsed .sectionRuns {
+          display: none;
+        }
+        .collapsed {
+          border-bottom: 1px solid var(--border-color);
+          padding-bottom: var(--spacing-m);
         }
         input#filterInput {
           margin-top: var(--spacing-s);
@@ -344,29 +382,40 @@
       .filter(r => this.filterRegExp.test(r.checkName))
       .sort(compareByWorstCategory);
     if (runs.length === 0) return;
+    const expanded = this.isSectionExpanded.get(status) ?? true;
+    const expandedClass = expanded ? 'expanded' : 'collapsed';
+    const icon = expanded ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
     return html`
-      <div class="${status.toLowerCase()}">
-        <h3 class="statusHeader heading-3">${status.toLowerCase()}</h3>
-        ${runs.map(run => this.renderRun(run))}
+      <div class="${status.toLowerCase()} ${expandedClass}">
+        <div
+          class="sectionHeader"
+          @click="${() => this.toggleExpanded(status)}"
+        >
+          <iron-icon class="expandIcon" icon="${icon}"></iron-icon>
+          <h3 class="heading-3">${status.toLowerCase()}</h3>
+        </div>
+        <div class="sectionRuns">
+          ${runs.map(run => this.renderRun(run))}
+        </div>
       </div>
     `;
   }
 
+  toggleExpanded(status: RunStatus) {
+    const expanded = this.isSectionExpanded.get(status) ?? true;
+    this.isSectionExpanded.set(status, !expanded);
+    this.requestUpdate();
+  }
+
   renderRun(run: CheckRun) {
-    const selected = this.selectedRuns.has(run.checkName);
-    const deselected = !selected && this.selectedRuns.size > 0;
+    const selected = this.selectedRuns.includes(run.checkName);
+    const deselected = !selected && this.selectedRuns.length > 0;
     return html`<gr-checks-run
       .run="${run}"
       .selected="${selected}"
       .deselected="${deselected}"
-      @run-selected="${this.handleRunSelected}"
     ></gr-checks-run>`;
   }
-
-  handleRunSelected(e: RunSelectedEvent) {
-    toggleSetMembership(this.selectedRuns, e.detail.checkName);
-    this.requestUpdate();
-  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index 0ce81ed..ad4f2ae 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -15,7 +15,13 @@
  * limitations under the License.
  */
 import {html} from 'lit-html';
-import {css, customElement, property} from 'lit-element';
+import {
+  css,
+  customElement,
+  internalProperty,
+  property,
+  PropertyValues,
+} from 'lit-element';
 import {GrLitElement} from '../lit/gr-lit-element';
 import {Action, CheckResult, CheckRun} from '../../api/checks';
 import {
@@ -32,11 +38,9 @@
   ActionTriggeredEvent,
   fireActionTriggered,
 } from '../../services/checks/checks-util';
-import {
-  checkRequiredProperty,
-  toggleSetMembership,
-} from '../../utils/common-util';
+import {checkRequiredProperty} from '../../utils/common-util';
 import {RunSelectedEvent} from './gr-checks-runs';
+import {ChecksTabState} from '../../types/events';
 
 /**
  * The "Checks" tab on the Gerrit change page. Gets its data from plugins that
@@ -52,12 +56,16 @@
   actions: Action[] = [];
 
   @property()
+  tabState?: ChecksTabState;
+
+  @property()
   currentPatchNum: PatchSetNum | undefined = undefined;
 
   @property()
   changeNum: NumericChangeId | undefined = undefined;
 
-  private selectedRuns = new Set<string>();
+  @internalProperty()
+  selectedRuns: string[] = [];
 
   constructor() {
     super();
@@ -107,7 +115,9 @@
   render() {
     const ps = `Patchset ${this.currentPatchNum} (Latest)`;
     const filteredRuns = this.runs.filter(
-      r => this.selectedRuns.size === 0 || this.selectedRuns.has(r.checkName)
+      r =>
+        this.selectedRuns.length === 0 ||
+        this.selectedRuns.includes(r.checkName)
     );
     return html`
       <div class="header">
@@ -130,6 +140,7 @@
         <gr-checks-runs
           class="runs"
           .runs="${this.runs}"
+          .selectedRuns="${this.selectedRuns}"
           @run-selected="${this.handleRunSelected}"
         ></gr-checks-runs>
         <gr-checks-results
@@ -140,6 +151,16 @@
     `;
   }
 
+  protected updated(changedProperties: PropertyValues) {
+    super.updated(changedProperties);
+    if (changedProperties.has('tabState')) {
+      const check = this.tabState?.checkName;
+      if (check) {
+        this.selectedRuns = [check];
+      }
+    }
+  }
+
   renderAction(action: Action) {
     return html`<gr-checks-top-level-action
       .action="${action}"
@@ -163,8 +184,15 @@
   }
 
   handleRunSelected(e: RunSelectedEvent) {
-    toggleSetMembership(this.selectedRuns, e.detail.checkName);
-    this.requestUpdate();
+    this.toggleSelected(e.detail.checkName);
+  }
+
+  toggleSelected(checkName: string) {
+    if (this.selectedRuns.includes(checkName)) {
+      this.selectedRuns = this.selectedRuns.filter(r => r !== checkName);
+    } else {
+      this.selectedRuns = [...this.selectedRuns, checkName];
+    }
   }
 }
 
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
index b43b3b0..bed07a6 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -304,7 +304,7 @@
         if (!config) {
           throw new Error('getConfig returned undefined');
         }
-        this._retreiveFeedbackURL(config);
+        this._retrieveFeedbackURL(config);
         this._retrieveRegisterURL(config);
         return getDocsBaseUrl(config, this.restApiService);
       })
@@ -325,7 +325,7 @@
     });
   }
 
-  _retreiveFeedbackURL(config: ServerInfo) {
+  _retrieveFeedbackURL(config: ServerInfo) {
     if (config.gerrit?.report_bug_url) {
       this._feedbackURL = config.gerrit.report_bug_url;
     }
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
index 7430cc0..d9c43d6 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
@@ -488,7 +488,7 @@
         report_bug_url: url,
       },
     };
-    element._retreiveFeedbackURL(config);
+    element._retrieveFeedbackURL(config);
     await flush();
 
     assert.equal(element._feedbackURL, url);
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index f2ee838..632ce4c 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -249,7 +249,7 @@
 
 export interface GenerateUrlChangeViewParameters {
   view: GerritView.CHANGE;
-  // TODO(TS): NumericChangeId - not sure about it, may be it can be removeds
+  // TODO(TS): NumericChangeId - not sure about it, may be it can be removed
   changeNum: NumericChangeId;
   project: RepoName;
   patchNum?: PatchSetNum;
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index 3a76112..676ef7b 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -308,15 +308,6 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  private readonly changeService = appContext.changeService;
-
-  constructor() {
-    super();
-    // TODO: This is just an artificical dependdency such that the service is
-    // instantiated and its observables subscribed. Remove this later.
-    this.changeService.dontDoAnything();
-  }
-
   start() {
     if (!this._app) {
       return;
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 43f8a2b..97d5271 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
@@ -93,6 +93,7 @@
   'onlyextensions:',
   'owner:',
   'ownerin:',
+  'parentof:',
   'parentproject:',
   'project:',
   'projects:',
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
index 829038b..4943298 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
@@ -33,7 +33,7 @@
 
 /**
  * In JS, unicode code points above 0xFFFF occupy two elements of a string.
- * For example '𐀏'.length is 2. An occurence of such a code point is called a
+ * For example '𐀏'.length is 2. An occurrence of such a code point is called a
  * surrogate pair.
  *
  * This regex segments a string along tabs ('\t') and surrogate pairs, since
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
new file mode 100644
index 0000000..42268e9
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-overview-image.ts
@@ -0,0 +1,304 @@
+/**
+ * @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 {
+  css,
+  customElement,
+  html,
+  internalProperty,
+  LitElement,
+  property,
+  PropertyValues,
+  query,
+} from 'lit-element';
+import {StyleInfo, styleMap} from 'lit-html/directives/style-map';
+
+import {Dimensions, fitToFrame, Point, Rect} from './util';
+
+/**
+ * Displays a scaled-down version of an image with a draggable frame for
+ * choosing a portion of the image to be magnified by other components.
+ *
+ * Slotted content can be arbitrary elements, but should be limited to images or
+ * stacks of image-like elements (e.g. for overlays) with limited interactivity,
+ * to prevent confusion, as the component only captures a limited set of events.
+ * Slotted content is scaled to fit the bounds of the component, with
+ * letterboxing if aspect ratios differ. For slotted content smaller than the
+ * component, it will cap the scale at 1x and also apply letterboxing.
+ */
+@customElement('gr-overview-image')
+export class GrOverviewImage extends LitElement {
+  @property({type: Object})
+  frameRect: Rect = {origin: {x: 0, y: 0}, dimensions: {width: 0, height: 0}};
+
+  @internalProperty() protected contentStyle: StyleInfo = {};
+
+  @internalProperty() protected contentTransformStyle: StyleInfo = {};
+
+  @internalProperty() protected frameStyle: StyleInfo = {};
+
+  @internalProperty() protected overlayStyle: StyleInfo = {};
+
+  @internalProperty() protected dragging = false;
+
+  @query('.content-box') protected contentBox!: HTMLDivElement;
+
+  @query('.content') protected content!: HTMLDivElement;
+
+  @query('.content-transform') protected contentTransform!: HTMLDivElement;
+
+  @query('.frame') protected frame!: HTMLDivElement;
+
+  private contentBounds: Dimensions = {width: 0, height: 0};
+
+  private imageBounds: Dimensions = {width: 0, height: 0};
+
+  private scale = 1;
+
+  // When grabbing the frame to drag it around, this stores the offset of the
+  // cursor from the center of the frame at the start of the drag.
+  private grabOffset: Point = {x: 0, y: 0};
+
+  private readonly resizeObserver = new ResizeObserver(
+    (entries: ResizeObserverEntry[]) => {
+      for (const entry of entries) {
+        if (entry.target === this.contentBox) {
+          this.contentBounds = {
+            width: entry.contentRect.width,
+            height: entry.contentRect.height,
+          };
+        }
+        if (entry.target === this.contentTransform) {
+          this.imageBounds = {
+            width: entry.contentRect.width,
+            height: entry.contentRect.height,
+          };
+        }
+        this.updateScale();
+      }
+    }
+  );
+
+  static styles = css`
+    :host {
+      --overview-image-background-color: #000;
+      --overview-image-frame-color: #f00;
+      display: flex;
+    }
+    * {
+      box-sizing: border-box;
+    }
+    ::slotted(*) {
+      display: block;
+    }
+    .content-box {
+      border: 1px solid var(--overview-image-background-color);
+      background-color: var(--overview-iamge-background-color);
+      width: 100%;
+      position: relative;
+    }
+    .content {
+      position: absolute;
+      cursor: pointer;
+    }
+    .content-transform {
+      position: absolute;
+      transform-origin: top left;
+      will-change: transform;
+    }
+    .frame {
+      border: 1px solid var(--overview-image-frame-color);
+      position: absolute;
+      will-change: transform;
+    }
+    .overlay {
+      position: absolute;
+      z-index: 10000;
+      cursor: grabbing;
+    }
+  `;
+
+  render() {
+    return html`
+      <div class="content-box">
+        <div
+          class="content"
+          style="${styleMap({
+            ...this.contentStyle,
+          })}"
+          @mousemove="${this.maybeDragFrame}"
+          @mousedown=${this.clickOverview}
+          @mouseup="${this.releaseFrame}"
+        >
+          <div
+            class="content-transform"
+            style="${styleMap(this.contentTransformStyle)}"
+          >
+            <slot></slot>
+          </div>
+          <div
+            class="frame"
+            style="${styleMap({
+              ...this.frameStyle,
+              cursor: this.dragging ? 'grabbing' : 'grab',
+            })}"
+            @mousedown="${this.grabFrame}"
+          ></div>
+        </div>
+        <div
+          class="overlay"
+          style="${styleMap({
+            ...this.overlayStyle,
+            display: this.dragging ? 'block' : 'none',
+          })}"
+          @mousemove="${this.overlayMouseMove}"
+          @mouseleave="${this.releaseFrame}"
+          @mouseup="${this.releaseFrame}"
+        ></div>
+      </div>
+    `;
+  }
+
+  firstUpdated() {
+    this.resizeObserver.observe(this.contentBox);
+    this.resizeObserver.observe(this.contentTransform);
+  }
+
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('frameRect')) {
+      this.updateFrameStyle();
+    }
+  }
+
+  clickOverview(event: MouseEvent) {
+    event.preventDefault();
+
+    this.updateOverlaySize();
+
+    this.dragging = true;
+    const rect = this.content.getBoundingClientRect();
+    this.notifyNewCenter({
+      x: (event.clientX - rect.left) / this.scale,
+      y: (event.clientY - rect.top) / this.scale,
+    });
+  }
+
+  grabFrame(event: MouseEvent) {
+    event.preventDefault();
+    // Do not bubble up into clickOverview().
+    event.stopPropagation();
+
+    this.updateOverlaySize();
+
+    this.dragging = true;
+    const rect = this.frame.getBoundingClientRect();
+    const frameCenterX = rect.x + rect.width / 2;
+    const frameCenterY = rect.y + rect.height / 2;
+    this.grabOffset = {
+      x: event.clientX - frameCenterX,
+      y: event.clientY - frameCenterY,
+    };
+  }
+
+  maybeDragFrame(event: MouseEvent) {
+    event.preventDefault();
+    if (!this.dragging) return;
+    const rect = this.content.getBoundingClientRect();
+    const center = {
+      x: (event.clientX - rect.left - this.grabOffset.x) / this.scale,
+      y: (event.clientY - rect.top - this.grabOffset.y) / this.scale,
+    };
+    this.notifyNewCenter(center);
+  }
+
+  releaseFrame(event: MouseEvent) {
+    event.preventDefault();
+    this.dragging = false;
+    this.grabOffset = {x: 0, y: 0};
+  }
+
+  overlayMouseMove(event: MouseEvent) {
+    event.preventDefault();
+    this.maybeDragFrame(event);
+  }
+
+  private updateScale() {
+    const fitted = fitToFrame(this.imageBounds, this.contentBounds);
+    this.scale = fitted.scale;
+
+    this.contentStyle = {
+      ...this.contentStyle,
+      top: `${fitted.top}px`,
+      left: `${fitted.left}px`,
+      width: `${fitted.width}px`,
+      height: `${fitted.height}px`,
+    };
+
+    this.contentTransformStyle = {
+      transform: `scale(${this.scale})`,
+    };
+
+    this.updateFrameStyle();
+  }
+
+  private updateFrameStyle() {
+    const x = this.frameRect.origin.x * this.scale;
+    const y = this.frameRect.origin.y * this.scale;
+    const width = this.frameRect.dimensions.width * this.scale;
+    const height = this.frameRect.dimensions.height * this.scale;
+    this.frameStyle = {
+      ...this.frameStyle,
+      transform: `translate(${x}px, ${y}px)`,
+      width: `${width}px`,
+      height: `${height}px`,
+    };
+  }
+
+  private updateOverlaySize() {
+    const rect = this.contentBox.getBoundingClientRect();
+    // Create a whole-page overlay to capture mouse events, so that the drag
+    // interaction continues until the user releases the mouse button. Since
+    // innerWidth and innerHeight include scrollbars, we subtract 20 pixels each
+    // to prevent the overlay from extending offscreen under any existing
+    // scrollbar and causing the scrollbar for the other dimension to show up
+    // unnecessarily.
+    const width = window.innerWidth - 20;
+    const height = window.innerHeight - 20;
+    this.overlayStyle = {
+      ...this.overlayStyle,
+      top: `-${rect.top + 1}px`,
+      left: `-${rect.left + 1}px`,
+      width: `${width}px`,
+      height: `${height}px`,
+    };
+  }
+
+  private notifyNewCenter(center: Point) {
+    this.dispatchEvent(
+      new CustomEvent('center-updated', {
+        detail: {...center},
+        bubbles: true,
+        composed: true,
+      })
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-overview-image': GrOverviewImage;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts
new file mode 100644
index 0000000..a14a9cc
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-zoomed-image.ts
@@ -0,0 +1,95 @@
+/**
+ * @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 {
+  css,
+  customElement,
+  html,
+  internalProperty,
+  LitElement,
+  property,
+  PropertyValues,
+} from 'lit-element';
+import {StyleInfo, styleMap} from 'lit-html/directives/style-map';
+import {Rect} from './util';
+
+/**
+ * Displays its slotted content at a given scale, centered over a given point,
+ * while ensuring the content always fills the container. The content does not
+ * have to be a single image, it can be arbitrary HTML. To prevent user
+ * confusion, it should ideally be image-like, i.e. have limited or no
+ * interactivity, as the component does not prevent events or focus from
+ * reaching the slotted content.
+ */
+@customElement('gr-zoomed-image')
+export class GrZoomedImage extends LitElement {
+  @property({type: Number}) scale = 1;
+
+  @property({type: Object})
+  frameRect: Rect = {origin: {x: 0, y: 0}, dimensions: {width: 0, height: 0}};
+
+  @internalProperty() protected imageStyles: StyleInfo = {};
+
+  static styles = css`
+    :host {
+      display: block;
+    }
+    ::slotted(*) {
+      display: block;
+    }
+    #clip {
+      position: relative;
+      width: 100%;
+      height: 100%;
+      overflow: hidden;
+    }
+    #transform {
+      position: absolute;
+      transform-origin: top left;
+      will-change: transform;
+    }
+  `;
+
+  render() {
+    return html`
+      <div id="clip">
+        <div id="transform" style="${styleMap(this.imageStyles)}">
+          <slot></slot>
+        </div>
+      </div>
+    `;
+  }
+
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('scale') || changedProperties.has('frameRect')) {
+      this.updateImageStyles();
+    }
+  }
+
+  private updateImageStyles() {
+    const {x, y} = this.frameRect.origin;
+    this.imageStyles = {
+      'image-rendering': this.scale >= 1 ? 'pixelated' : 'auto',
+      transform: `translate(${-x}px, ${-y}px) scale(${this.scale})`,
+    };
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-zoomed-image': GrZoomedImage;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
new file mode 100644
index 0000000..b42eea9
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
@@ -0,0 +1,236 @@
+/**
+ * @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 interface Point {
+  x: number;
+  y: number;
+}
+
+export interface Dimensions {
+  width: number;
+  height: number;
+}
+
+export interface Rect {
+  origin: Point;
+  dimensions: Dimensions;
+}
+
+export interface FittedContent {
+  top: number;
+  left: number;
+  width: number;
+  height: number;
+  scale: number;
+}
+
+function clamp(value: number, min: number, max: number) {
+  return Math.max(min, Math.min(value, max));
+}
+
+/**
+ * Fits content of the given dimensions into the given frame, maintaining the
+ * aspect ratio of the content and applying letterboxing / pillarboxing as
+ * needed.
+ */
+export function fitToFrame(
+  content: Dimensions,
+  frame: Dimensions
+): FittedContent {
+  const contentAspectRatio = content.width / content.height;
+  const frameAspectRatio = frame.width / frame.height;
+  // If the content is wider than the frame, it will be letterboxed, otherwise
+  // it will be pillarboxed. When letterboxed, content and frame width will
+  // match exactly, when pillarboxed, content and frame height will match
+  // exactly.
+  const isLetterboxed = contentAspectRatio > frameAspectRatio;
+  let width: number;
+  let height: number;
+  if (isLetterboxed) {
+    width = Math.min(frame.width, content.width);
+    height = content.height * (width / content.width);
+  } else {
+    height = Math.min(frame.height, content.height);
+    width = content.width * (height / content.height);
+  }
+  const top = (frame.height - height) / 2;
+  const left = (frame.width - width) / 2;
+  const scale = width / content.width;
+  return {top, left, width, height, scale};
+}
+
+function ensureInBounds(part: Rect, bounds: Dimensions): Rect {
+  const x =
+    part.dimensions.width <= bounds.width
+      ? clamp(part.origin.x, 0, bounds.width - part.dimensions.width)
+      : (bounds.width - part.dimensions.width) / 2;
+  const y =
+    part.dimensions.height <= bounds.height
+      ? clamp(part.origin.y, 0, bounds.height - part.dimensions.height)
+      : (bounds.height - part.dimensions.height) / 2;
+  return {origin: {x, y}, dimensions: part.dimensions};
+}
+
+/**
+ * Maintains a given frame inside given bounds, adjusting requested positions
+ * for the frame as needed. This supports the non-destructive application of a
+ * scaling factor, so that e.g. the magnification of an image can be changed
+ * easily while keeping the frame centered over the same spot. Changing bounds
+ * or frame size also keeps the frame position when possible.
+ */
+export class FrameConstrainer {
+  private center: Point = {x: 0, y: 0};
+
+  private frameSize: Dimensions = {width: 0, height: 0};
+
+  private bounds: Dimensions = {width: 0, height: 0};
+
+  private scale = 1;
+
+  private unscaledFrame: Rect = {
+    origin: {x: 0, y: 0},
+    dimensions: {width: 0, height: 0},
+  };
+
+  private scaledFrame: Rect = {
+    origin: {x: 0, y: 0},
+    dimensions: {width: 0, height: 0},
+  };
+
+  getCenter(): Point {
+    return {...this.center};
+  }
+
+  /**
+   * Returns the frame at its original size, positioned within the given bounds
+   * at the given scale; its origin will be in scaled bounds coordinates.
+   *
+   * Ex: for given bounds 100x50 and frame size 30x20, centered over (50, 25),
+   * all at 1x scale, when setting scale to 2, this will return a frame of size
+   * 30x20, centered over (100, 50), within bounds 200x100.
+   *
+   * Useful for positioning a viewport of fixed size over a magnified image.
+   */
+  getUnscaledFrame(): Rect {
+    return {
+      origin: {...this.unscaledFrame.origin},
+      dimensions: {...this.unscaledFrame.dimensions},
+    };
+  }
+
+  /**
+   * Returns the scaled down frame–a scale of 2 will result in frame dimensions
+   * being halved—position within the given bounds at 1x scale; its origin will
+   * be in unscaled bounds coordinates.
+   *
+   * Ex: for given bounds 100x50 and frame size 30x20, centered over (50, 25),
+   * all at 1x scale, when setting scale to 2, this will return a frame of size
+   * 15x10, centered over (50, 25), within bounds 100x50.
+   *
+   * Useful for highlighting the magnified portion of an image as determined by
+   * getUnscaledFrame() in an overview image of fixed size.
+   */
+  getScaledFrame(): Rect {
+    return {
+      origin: {...this.scaledFrame.origin},
+      dimensions: {...this.scaledFrame.dimensions},
+    };
+  }
+
+  /**
+   * Requests the frame to be centered over the given point, in unscaled bounds
+   * coordinates. This will keep the frame within the given bounds, also when
+   * requesting a center point fully outside the given bounds.
+   */
+  requestCenter(center: Point) {
+    this.center = {...center};
+
+    this.ensureFrameInBounds();
+  }
+
+  /**
+   * Sets the frame size, while keeping the frame within the given bounds, and
+   * maintaining the current center if possible.
+   */
+  setFrameSize(frameSize: Dimensions) {
+    if (frameSize.width <= 0 || frameSize.height <= 0) return;
+    this.frameSize = {...frameSize};
+
+    this.ensureFrameInBounds();
+  }
+
+  /**
+   * Sets the bounds, while keeping the frame within them, and maintaining the
+   * current center if possible.
+   */
+  setBounds(bounds: Dimensions) {
+    if (bounds.width <= 0 || bounds.height <= 0) return;
+    this.bounds = {...bounds};
+
+    this.ensureFrameInBounds();
+  }
+
+  /**
+   * Sets the applied scale, while keeping the frame within the given bounds,
+   * and maintaining the current center if possible (both relevant moving from
+   * a larger scale to a smaller scale).
+   */
+  setScale(scale: number) {
+    if (!scale || scale <= 0) return;
+    this.scale = scale;
+
+    this.ensureFrameInBounds();
+  }
+
+  private ensureFrameInBounds() {
+    const scaledCenter = {
+      x: this.center.x * this.scale,
+      y: this.center.y * this.scale,
+    };
+    const scaledBounds = {
+      width: this.bounds.width * this.scale,
+      height: this.bounds.height * this.scale,
+    };
+    const scaledFrameSize = {
+      width: this.frameSize.width / this.scale,
+      height: this.frameSize.height / this.scale,
+    };
+
+    const requestedUnscaledFrame = {
+      origin: {
+        x: scaledCenter.x - this.frameSize.width / 2,
+        y: scaledCenter.y - this.frameSize.height / 2,
+      },
+      dimensions: this.frameSize,
+    };
+    const requestedScaledFrame = {
+      origin: {
+        x: this.center.x - scaledFrameSize.width / 2,
+        y: this.center.y - scaledFrameSize.height / 2,
+      },
+      dimensions: scaledFrameSize,
+    };
+
+    this.unscaledFrame = ensureInBounds(requestedUnscaledFrame, scaledBounds);
+    this.scaledFrame = ensureInBounds(requestedScaledFrame, this.bounds);
+
+    this.center = {
+      x: this.scaledFrame.origin.x + this.scaledFrame.dimensions.width / 2,
+      y: this.scaledFrame.origin.y + this.scaledFrame.dimensions.height / 2,
+    };
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js
new file mode 100644
index 0000000..80cfa36
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util_test.js
@@ -0,0 +1,171 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import {FrameConstrainer} from './util.js';
+
+suite('FrameConstrainer tests', () => {
+  let constrainer;
+
+  setup(() => {
+    constrainer = new FrameConstrainer();
+    constrainer.setBounds({width: 100, height: 100});
+    constrainer.setFrameSize({width: 50, height: 50});
+    constrainer.requestCenter({x: 50, y: 50});
+  });
+
+  suite('changing center', () => {
+    test('moves frame to requested position', () => {
+      constrainer.requestCenter({x: 30, y: 30});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 5, y: 5}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('keeps frame in bounds for top left corner', () => {
+      constrainer.requestCenter({x: 5, y: 5});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 0, y: 0}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('keeps frame in bounds for bottom right corner', () => {
+      constrainer.requestCenter({x: 95, y: 95});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center left', () => {
+      constrainer.requestCenter({x: -5, y: 50});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 0, y: 25}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center right', () => {
+      constrainer.requestCenter({x: 105, y: 50});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 50, y: 25}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center top', () => {
+      constrainer.requestCenter({x: 50, y: -5});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 25, y: 0}, dimensions: {width: 50, height: 50}});
+    });
+
+    test('handles out-of-bounds center bottom', () => {
+      constrainer.requestCenter({x: 50, y: 105});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 25, y: 50}, dimensions: {width: 50, height: 50}});
+    });
+  });
+
+  suite('changing frame size', () => {
+    test('maintains center when decreased', () => {
+      constrainer.setFrameSize({width: 10, height: 10});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 45, y: 45}, dimensions: {width: 10, height: 10}});
+    });
+
+    test('maintains center when increased', () => {
+      constrainer.setFrameSize({width: 80, height: 80});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 10, y: 10}, dimensions: {width: 80, height: 80}});
+    });
+
+    test('updates center to remain in bounds when increased', () => {
+      constrainer.setFrameSize({width: 10, height: 10});
+      constrainer.requestCenter({x: 95, y: 95});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 90, y: 90}, dimensions: {width: 10, height: 10}});
+
+      constrainer.setFrameSize({width: 20, height: 20});
+      assert.deepEqual(
+          constrainer.getUnscaledFrame(),
+          {origin: {x: 80, y: 80}, dimensions: {width: 20, height: 20}});
+    });
+  });
+
+  suite('changing scale', () => {
+    suite('for unscaled frame', () => {
+      test('adjusts origin to maintain center when zooming in', () => {
+        constrainer.setScale(2);
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 75, y: 75}, dimensions: {width: 50, height: 50}});
+      });
+
+      test('adjusts origin to maintain center when zooming out', () => {
+        constrainer.setFrameSize({width: 20, height: 20});
+        constrainer.setScale(0.5);
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 15, y: 15}, dimensions: {width: 20, height: 20}});
+      });
+
+      test('keeps frame in bounds when zooming out', () => {
+        constrainer.setScale(5);
+        constrainer.requestCenter({x: 100, y: 100});
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 450, y: 450}, dimensions: {width: 50, height: 50}});
+
+        constrainer.setScale(1);
+        assert.deepEqual(
+            constrainer.getUnscaledFrame(),
+            {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
+      });
+    });
+
+    suite('for scaled frame', () => {
+      test('decreases frame size and maintains center when zooming in', () => {
+        constrainer.setScale(2);
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 37.5, y: 37.5}, dimensions: {width: 25, height: 25}});
+      });
+
+      test('increases frame size and maintains center when zooming out', () => {
+        constrainer.setFrameSize({width: 20, height: 20});
+        constrainer.setScale(0.5);
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 30, y: 30}, dimensions: {width: 40, height: 40}});
+      });
+
+      test('keeps frame in bounds when zooming out', () => {
+        constrainer.setScale(5);
+        constrainer.requestCenter({x: 100, y: 100});
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 90, y: 90}, dimensions: {width: 10, height: 10}});
+
+        constrainer.setScale(1);
+        assert.deepEqual(
+            constrainer.getScaledFrame(),
+            {origin: {x: 50, y: 50}, dimensions: {width: 50, height: 50}});
+      });
+    });
+  });
+});
\ No newline at end of file
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 cfe2cfe..60f2853 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
@@ -60,17 +60,17 @@
       this.restApiService.savePreferences({diff_view: newMode});
     }
     this.mode = newMode;
-    let annoucement;
+    let announcement;
     if (this.isUnifiedSelected(newMode)) {
-      annoucement = 'Changed diff view to unified';
+      announcement = 'Changed diff view to unified';
     } else if (this.isSideBySideSelected(newMode)) {
-      annoucement = 'Changed diff view to side by side';
+      announcement = 'Changed diff view to side by side';
     }
-    if (annoucement) {
+    if (announcement) {
       this.fire(
         'iron-announce',
         {
-          text: annoucement,
+          text: announcement,
         },
         {bubbles: true}
       );
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 088d9cf..2c4b8f6 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
@@ -1465,9 +1465,9 @@
   ) {
     let patchNum = patchRange.patchNum;
 
-    const comparedAgainsParent = patchRange.basePatchNum === 'PARENT';
+    const comparedAgainstParent = patchRange.basePatchNum === 'PARENT';
 
-    if (isBase && !comparedAgainsParent) {
+    if (isBase && !comparedAgainstParent) {
       patchNum = patchRange.basePatchNum;
     }
 
@@ -1475,7 +1475,7 @@
       changeBaseURL(project, changeNum, patchNum) +
       `/files/${encodeURIComponent(path)}/download`;
 
-    if (isBase && comparedAgainsParent) {
+    if (isBase && comparedAgainstParent) {
       url += '?parent=1';
     }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
index 66ac065..0ca929a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
@@ -88,7 +88,7 @@
 // TODO: This type should be exposed to gr-diff clients in a separate type file.
 // For Gerrit these are instances of GrCommentThread, but other gr-diff users
 // have different HTML elements in use for comment threads.
-// TODO: Also document the required HTML attritbutes that thread elements must
+// TODO: Also document the required HTML attributes that thread elements must
 // have, e.g. 'diff-side', 'range', 'line-num', 'data-value'.
 export interface GrDiffThreadElement extends HTMLElement {
   rootId: string;
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
index 52465b3..5ab8449 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
@@ -35,7 +35,6 @@
         text-transform: none;
         font-family: var(--font-family);
       }
-      --trigger-hover-color: rgba(0, 0, 0, 0.6);
     }
     @media screen and (max-width: 50em) {
       .filesWeblinks {
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
index da29b85..1150674 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
@@ -492,7 +492,7 @@
    * with code it shouldn't AND to avoid executing regexes as much as
    * possible.
    * * These tests should document the issue clearly enough that the test can
-   * be condidently removed when the issue is solved in HLJS.
+   * be confidently removed when the issue is solved in HLJS.
    * * These tests should rewrite the line of code to have the same number of
    * characters. This method rewrites the string that gets parsed, but NOT
    * the string that gets displayed and highlighted. Thus, the positions
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
index b36edd4..bc153ee 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -277,7 +277,8 @@
     return this.restApiService
       .queryChangeFiles(this.change._number, this.patchNum, input)
       .then(res => {
-        if (!res) throw new Error('Failed to retrieve files. Reponse not set.');
+        if (!res)
+          throw new Error('Failed to retrieve files. Response not set.');
         return res.map(file => {
           return {name: file};
         });
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
index be6ffc4..bbf4790 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
@@ -50,14 +50,14 @@
 
   suite('edit button CUJ', () => {
     let navStubs;
-    let openAutoCcmplete;
+    let openAutoComplete;
 
     setup(() => {
       navStubs = [
         sinon.stub(GerritNav, 'getEditUrlForDiff'),
         sinon.stub(GerritNav, 'navigateToRelativeUrl'),
       ];
-      openAutoCcmplete = element.$.openDialog.querySelector('gr-autocomplete');
+      openAutoComplete = element.$.openDialog.querySelector('gr-autocomplete');
     });
 
     test('_isValidPath', () => {
@@ -77,9 +77,9 @@
         assert.isFalse(queryStub.called);
         // Setup _focused manually - in headless mode Chrome sometimes don't
         // setup focus. flush and/or flushAsynchronousOperations don't help
-        openAutoCcmplete._focused = true;
-        openAutoCcmplete.noDebounce = true;
-        openAutoCcmplete.text = 'src/test.cpp';
+        openAutoComplete._focused = true;
+        openAutoComplete.noDebounce = true;
+        openAutoComplete.text = 'src/test.cpp';
         assert.isTrue(queryStub.called);
         assert.isFalse(element.$.openDialog.disabled);
         MockInteractions.tap(element.$.openDialog.shadowRoot
@@ -95,8 +95,8 @@
       MockInteractions.tap(element.shadowRoot.querySelector('#open'));
       return showDialogSpy.lastCall.returnValue.then(() => {
         assert.isTrue(element.$.openDialog.disabled);
-        openAutoCcmplete.noDebounce = true;
-        openAutoCcmplete.text = 'src/test.cpp';
+        openAutoComplete.noDebounce = true;
+        openAutoComplete.text = 'src/test.cpp';
         assert.isFalse(element.$.openDialog.disabled);
         MockInteractions.tap(element.$.openDialog.shadowRoot
             .querySelector('gr-button'));
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 1864598..1e08a5c 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
@@ -200,7 +200,7 @@
   }
 
   _handlePathChanged(e: CustomEvent<string>) {
-    // TODO(TS) could be cleand up, it was added for type requirements
+    // TODO(TS) could be cleaned up, it was added for type requirements
     if (this._changeNum === undefined || !this._path) {
       return Promise.reject(new Error('changeNum or path undefined'));
     }
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
index ea7bdc3..7ea3be3 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.js
@@ -20,7 +20,7 @@
 import {GrAttributeHelper} from './gr-attribute-helper.js';
 
 Polymer({
-  is: 'gr-attrubute-helper-some-element',
+  is: 'gr-attribute-helper-some-element',
   properties: {
     fooBar: {
       type: Object,
@@ -29,7 +29,7 @@
   },
 });
 
-const basicFixture = fixtureFromElement('gr-attrubute-helper-some-element');
+const basicFixture = fixtureFromElement('gr-attribute-helper-some-element');
 
 suite('gr-attribute-helper tests', () => {
   let element;
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
index 91ca402..59203d3 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_html.ts
@@ -25,7 +25,7 @@
       margin-bottom: var(--spacing-m);
     }
     .agreementsUrl {
-      border: 1px solid #b0bdcc;
+      border: 1px solid var(--border-color);
       margin-bottom: var(--spacing-xl);
       margin-left: var(--spacing-xl);
       margin-right: var(--spacing-xl);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
index 3bb1458..4e6dd1a 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
@@ -58,7 +58,6 @@
     gr-button.remove:focus {
       --gr-button: {
         @apply --gr-remove-button-style;
-        color: #333;
       }
     }
     gr-button.remove {
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
index d105c5d..e55c8f1 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
@@ -22,7 +22,7 @@
       display: inline-block;
       border-radius: 50%;
       background-size: cover;
-      background-color: var(--avatar-background-color, #f1f2f3);
+      background-color: var(--avatar-background-color, var(--gray-background));
     }
   </style>
 `;
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 7a6ce2c..60b891e 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -61,7 +61,7 @@
   tooltip = '';
 
   // Note: don't assign a value to this, since constructor is called
-  // after created, the initial value maybe overriden by this
+  // after created, the initial value maybe overridden by this
   @property({type: String})
   _initialTabindex?: string;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
index 76bbd67..55408c0 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_html.ts
@@ -23,12 +23,12 @@
       font-size: var(--font-size-normal);
       font-weight: var(--font-weight-normal);
       line-height: var(--line-height-normal);
-      /* Explicitly set the background color of the diff to be white. We
+      /* Explicitly set the background color of the diff. We
        * cannot use the diff content type ab because of the skip chunk preceding
        * it, diff processor assumes the chunk of type skip/ab can be collapsed
        * and hides our diff behind context control buttons.
        *  */
-      --dark-add-highlight-color: white;
+      --dark-add-highlight-color: var(--background-color-primary);
     }
     gr-button {
       margin-left: var(--spacing-m);
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
index 2fbbd7c..119ed20 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
@@ -54,8 +54,8 @@
 export type Stop = HTMLElement | AbortStop;
 
 /**
- * Type guard and checker to check if a stop can be targetted.
- * Abort stops cannot be targetted.
+ * Type guard and checker to check if a stop can be targeted.
+ * Abort stops cannot be targeted.
  */
 export function isTargetable(stop: Stop): stop is HTMLElement {
   return !(stop instanceof AbortStop);
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index d2f003b..4c2a417 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -52,7 +52,7 @@
 
   // TODO(TS): maybe default to [] as only used in dom-repeat
   @property({type: Array})
-  comamnds?: Command[];
+  commands?: Command[];
 
   @property({type: Boolean})
   _loggedIn = false;
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
index 3fce16e..888f34f 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
@@ -32,7 +32,7 @@
 import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
 
 /**
- * Requred values are text and value. mobileText and triggerText will
+ * Required values are text and value. mobileText and triggerText will
  * fall back to text if not provided.
  *
  * If bottomText is not provided, nothing will display on the second
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
index 374cc62..0f530bf 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_html.ts
@@ -68,6 +68,11 @@
       border-radius: 0 0 4px 4px;
       border-color: var(--border-color);
       box-shadow: var(--elevation-level-1);
+      /* slightly up to cover rounded corner of the commit msg */
+      margin-top: calc(-1 * var(--spacing-xs));
+      /* To make this bar pop over editor, since editor has relative position. 
+      */
+      position: relative;
     }
     .show-all-container .show-all-button {
       margin-right: auto;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
index 27bc591..7f9218a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
@@ -250,7 +250,7 @@
    *   });
    * });
    *
-   * // Listen on your-special-event from pluignB
+   * // Listen on your-special-event from pluginB
    * Gerrit.install(pluginB => {
    *   Gerrit.on("your-special-event", ({plugin}) => {
    *     // do something, plugin is pluginA
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index 8c0fce26..db34e5a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
@@ -74,7 +74,7 @@
 const UNKNOWN_PLUGIN_PREFIX = '__$$__';
 
 // Current API version for Plugin,
-// plugins with incompatible version will not be laoded.
+// plugins with incompatible version will not be loaded.
 const API_VERSION = '0.1';
 
 /**
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
index 6b62291..f5b1fca 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
@@ -225,7 +225,7 @@
     assert.isTrue(alertStub.calledTwice);
   });
 
-  test('plugins installed failed becasue of wrong version', async () => {
+  test('plugins installed failed because of wrong version', async () => {
     const plugins = [
       'http://test.com/plugins/foo/static/test.js',
       'http://test.com/plugins/bar/static/test.js',
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts
index a335db7..8581a0c 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts
@@ -46,7 +46,6 @@
     gr-button.remove:focus {
       --gr-button: {
         @apply --gr-remove-button-style;
-        color: #333;
       }
     }
     gr-button.remove {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index 68f15dc..89abd57 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -457,7 +457,7 @@
    * Send an XHR.
    *
    * @return Promise resolves to Response/ParsedJSON only if the request is successful
-   *     (i.e. no exception and response.ok is trsue). If response fails then
+   *     (i.e. no exception and response.ok is true). If response fails then
    *     promise resolves either to void if errFn is set or rejects if errFn
    *     is not set   */
   send(req: SendRequest): Promise<Response | ParsedJSON | undefined> {
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 b0b40dd..885db2a 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -112,7 +112,7 @@
   @property({type: Boolean})
   hideBorder = false;
 
-  /** Text input should be rendered in monspace font.  */
+  /** Text input should be rendered in monospace font.  */
   @property({type: Boolean})
   monospace = false;
 
diff --git a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
index 77d2d00..75ad608 100644
--- a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
+++ b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
@@ -69,13 +69,13 @@
       // Handler for mouseenter event
       private mouseenterHandler?: (e: MouseEvent) => void;
 
-      // Hanlder for scrolling on window
+      // Handler for scrolling on window
       private readonly windowScrollHandler: () => void;
 
-      // Hanlder for showing the tooltip, will be attached to certain events
+      // Handler for showing the tooltip, will be attached to certain events
       private readonly showHandler: () => void;
 
-      // Hanlder for hiding the tooltip, will be attached to certain events
+      // Handler for hiding the tooltip, will be attached to certain events
       private readonly hideHandler: () => void;
 
       // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
diff --git a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
index 662d6bf..57e034f 100644
--- a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
+++ b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
@@ -29,7 +29,7 @@
 // is used. To ensure that this import can't be avoided, the second parameter
 // is added. Usage example:
 // class Element extends IronFitMixin(PolymerElement, IronFitBehavior as IronFitBehavior)
-// The code 'IronFitBehavior as IronFitBehavior' required, becuase IronFitBehavior
+// The code 'IronFitBehavior as IronFitBehavior' required, because IronFitBehavior
 // defined as an object, not as IronFitBehavior instance.
 
 export const IronFitMixin = <T extends Constructor<PolymerElement>>(
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 63a05aa..ab85b87 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
@@ -544,7 +544,7 @@
 }
 
 /**
- * Shortcut manager, holds all hosts, bindings and listners.
+ * Shortcut manager, holds all hosts, bindings and listeners.
  */
 export class ShortcutManager {
   private readonly activeHosts = new Map<PolymerElement, Map<string, string>>();
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index 7a6253b..f03c7e6 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -254,6 +254,14 @@
     license: SharedLicenses.Polymer2017
   },
   {
+    name: "@types/resize-observer-browser",
+    license: {
+      name: 'DefinitelyTyped',
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE"
+    }
+  },
+  {
     name: "@webcomponents/shadycss",
     license: SharedLicenses.Polymer2017
   },
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 3351386..5e15990 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -25,6 +25,7 @@
     "@polymer/paper-tabs": "^3.1.0",
     "@polymer/paper-toggle-button": "^3.0.1",
     "@polymer/polymer": "^3.4.1",
+    "@types/resize-observer-browser": "^0.1.5",
     "@webcomponents/shadycss": "^1.9.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
     "ba-linkify": "file:../../lib/ba-linkify/src/",
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 09e0724..0369ccf 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -70,7 +70,7 @@
     eventEmitter: () => new EventEmitter(),
     authService: () => new Auth(appContext.eventEmitter),
     restApiService: () => new GrRestApiInterface(appContext.authService),
-    changeService: () => new ChangeService(appContext.restApiService),
+    changeService: () => new ChangeService(),
     checksService: () => new ChecksService(),
     jsApiService: () => new GrJsApiInterface(),
   });
diff --git a/polygerrit-ui/app/services/change/change-service.ts b/polygerrit-ui/app/services/change/change-service.ts
index 6a9a5e9..4524813 100644
--- a/polygerrit-ui/app/services/change/change-service.ts
+++ b/polygerrit-ui/app/services/change/change-service.ts
@@ -14,28 +14,33 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import {routerChangeNum$} from '../router/router-model';
 import {updateState} from './change-model';
-import {RestApiService} from '../gr-rest-api/gr-rest-api';
-import {switchMap, tap} from 'rxjs/operators';
-import {of, from} from 'rxjs';
+import {tap} from 'rxjs/operators';
+import {ParsedChangeInfo} from '../../types/types';
 
 export class ChangeService {
+  // TODO: In the future we will want to make restApiService.getChangeDetail()
+  // calls from a switchMap() here. For now just make sure to invalidate the
+  // change when no changeNum is set.
   private routerChangeNumEffect = routerChangeNum$.pipe(
-    switchMap(changeNum => {
-      if (!changeNum) return of(undefined);
-      return from(this.restApiService.getChangeDetail(changeNum));
-    }),
-    tap(change => {
-      updateState(change ?? undefined);
+    tap(changeNum => {
+      if (!changeNum) updateState(undefined);
     })
   );
 
-  constructor(private readonly restApiService: RestApiService) {
+  constructor() {
     this.routerChangeNumEffect.subscribe();
   }
 
-  // TODO: Remove.
-  dontDoAnything() {}
+  /**
+   * This is a temporary indirection between change-view, which currently
+   * manages what the current change is, and the change-model, which will
+   * become the source of truth in the future. We will extract a substantial
+   * amount of code from change-view and move it into this change-service. This
+   * will take some time ...
+   */
+  updateChange(change: ParsedChangeInfo) {
+    updateState(change);
+  }
 }
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index 6705a85..28aca73 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -34,6 +34,7 @@
 
 interface ChecksProviderState {
   pluginName: string;
+  loading: boolean;
   config?: ChecksApiConfig;
   runs: CheckRun[];
   actions: Action[];
@@ -50,11 +51,18 @@
 // Re-exporting as Observable so that you can only subscribe, but not emit.
 export const checksState$: Observable<ChecksState> = privateState$;
 
-export const aPluginHasRegistered = checksState$.pipe(
+export const aPluginHasRegistered$ = checksState$.pipe(
   map(state => Object.keys(state).length > 0),
   distinctUntilChanged()
 );
 
+export const someProvidersAreLoading$ = checksState$.pipe(
+  map(state => {
+    return Object.values(state).some(providerState => providerState.loading);
+  }),
+  distinctUntilChanged()
+);
+
 export const allActions$ = checksState$.pipe(
   map(state => {
     return Object.values(state).reduce(
@@ -106,6 +114,7 @@
   const nextState = {...privateState$.getValue()};
   nextState[pluginName] = {
     pluginName,
+    loading: false,
     config,
     runs: [],
     actions: [],
@@ -177,6 +186,15 @@
   status: RunStatus.COMPLETED,
 };
 
+export function updateStateSetLoading(pluginName: string) {
+  const nextState = {...privateState$.getValue()};
+  nextState[pluginName] = {
+    ...nextState[pluginName],
+    loading: true,
+  };
+  privateState$.next(nextState);
+}
+
 export function updateStateSetResults(
   pluginName: string,
   runs: CheckRun[],
@@ -185,6 +203,7 @@
   const nextState = {...privateState$.getValue()};
   nextState[pluginName] = {
     ...nextState[pluginName],
+    loading: false,
     runs: [...runs],
     actions: [...actions],
   };
diff --git a/polygerrit-ui/app/services/checks/checks-service.ts b/polygerrit-ui/app/services/checks/checks-service.ts
index 2e63f98..b11eada 100644
--- a/polygerrit-ui/app/services/checks/checks-service.ts
+++ b/polygerrit-ui/app/services/checks/checks-service.ts
@@ -30,7 +30,11 @@
   ResponseCode,
 } from '../../api/checks';
 import {change$, currentPatchNum$} from '../change/change-model';
-import {updateStateSetProvider, updateStateSetResults} from './checks-model';
+import {
+  updateStateSetLoading,
+  updateStateSetProvider,
+  updateStateSetResults,
+} from './checks-model';
 import {
   BehaviorSubject,
   combineLatest,
@@ -83,6 +87,7 @@
               patchsetNumber: patchNum,
               repo: change.project,
             };
+            updateStateSetLoading(pluginName);
             return from(this.providers[pluginName].fetch(data));
           }
         ),
diff --git a/polygerrit-ui/app/services/checks/checks-util.ts b/polygerrit-ui/app/services/checks/checks-util.ts
index 176464f..ea532ea 100644
--- a/polygerrit-ui/app/services/checks/checks-util.ts
+++ b/polygerrit-ui/app/services/checks/checks-util.ts
@@ -24,7 +24,7 @@
   return undefined;
 }
 
-export function iconForCategory(category: Category) {
+export function iconForCategory(category: Category | 'SUCCESS') {
   switch (category) {
     case Category.ERROR:
       return 'error';
@@ -32,6 +32,8 @@
       return 'info-outline';
     case Category.WARNING:
       return 'warning';
+    case 'SUCCESS':
+      return 'check-circle-outline';
     default:
       assertNever(category, `Unsupported category: ${category}`);
   }
@@ -74,8 +76,12 @@
 }
 
 export function iconForRun(run: CheckRun) {
-  const category = worstCategory(run);
-  return category ? iconForCategory(category) : iconForStatus(run.status);
+  if (run.status !== RunStatus.COMPLETED) {
+    return iconForStatus(run.status);
+  } else {
+    const category = worstCategory(run);
+    return category ? iconForCategory(category) : iconForStatus(run.status);
+  }
 }
 
 export function iconForStatus(status: RunStatus) {
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 4ca983a..4196513 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -61,7 +61,7 @@
   timeEnd(name: string, eventDetails?: EventDetails): void;
   /**
    * Reports just line timeEnd, but additionally reports an average given a
-   * denominator and a separate reporiting name for the average.
+   * denominator and a separate reporting name for the average.
    *
    * @param name Timing name.
    * @param averageName Average timing name.
@@ -74,7 +74,7 @@
     denominator: number
   ): void;
   /**
-   * Get a timer object to for reporing a user timing. The start time will be
+   * Get a timer object for reporting a user timing. The start time will be
    * the time that the object has been created, and the end time will be the
    * time that the "end" method is called on the object.
    */
@@ -99,7 +99,7 @@
   reportExecution(id: string, details: EventDetails): void;
   reportInteraction(eventName: string, details?: EventDetails): void;
   /**
-   * A draft interaction was started. Update the time-betweeen-draft-actions
+   * A draft interaction was started. Update the time-between-draft-actions
    * timer.
    */
   recordDraftInteraction(): void;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index af06450..631a4e0 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -651,7 +651,7 @@
     if (baseTime !== 0) {
       window.performance.measure(name, `${name}-start`);
     } else {
-      // Microsft Edge does not handle the 2nd param correctly
+      // Microsoft Edge does not handle the 2nd param correctly
       // (if undefined).
       window.performance.measure(name);
     }
@@ -659,7 +659,7 @@
 
   /**
    * Reports just line timeEnd, but additionally reports an average given a
-   * denominator and a separate reporiting name for the average.
+   * denominator and a separate reporting name for the average.
    *
    * @param name Timing name.
    * @param averageName Average timing name.
@@ -703,7 +703,7 @@
   }
 
   /**
-   * Get a timer object to for reporing a user timing. The start time will be
+   * Get a timer object to for reporting a user timing. The start time will be
    * the time that the object has been created, and the end time will be the
    * time that the "end" method is called on the object.
    */
@@ -805,7 +805,7 @@
   }
 
   /**
-   * A draft interaction was started. Update the time-betweeen-draft-actions
+   * A draft interaction was started. Update the time-between-draft-actions
    * timer.
    */
   recordDraftInteraction() {
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.ts b/polygerrit-ui/app/styles/gr-voting-styles.ts
index d4e6d52..c1989de 100644
--- a/polygerrit-ui/app/styles/gr-voting-styles.ts
+++ b/polygerrit-ui/app/styles/gr-voting-styles.ts
@@ -27,7 +27,7 @@
     <style>
       :host {
         --vote-chip-styles: {
-          border: 1px solid rgba(0,0,0,.12);
+          border: 1px solid var(--border-color);
           border-radius: 1em;
           box-shadow: none;
           box-sizing: border-box;
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index c3b0681..18c12b0 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -76,8 +76,6 @@
     --info-background: var(--blue-50);
     --selected-foreground: var(--blue-700);
     --selected-background: var(--blue-50);
-    --info-deemphasized-foreground: var(--gray-300);
-    --info-deemphasized-background: var(--gray-50);
     --success-foreground: var(--green-700);
     --success-background: var(--green-50);
     --gray-foreground: var(--gray-700);
@@ -101,6 +99,7 @@
     --tooltip-text-color: white;
     --negative-red-text-color: #d93025;
     --positive-green-text-color: #188038;
+    --indirect-ancestor-text-color: var(--green-700);
 
     /* background colors */
     /* primary background colors */
@@ -171,7 +170,7 @@
     --line-height-mono: 1.286rem;   /* 18px */
     --line-height-small: 1.143rem;  /* 16px */
     --line-height-normal: 1.429rem; /* 20px */
-    --line-height-h3: 1.714rem;     /* 24px */
+    --line-height-h3: 1.715rem;     /* 24px */
     --line-height-h2: 2rem;         /* 28px */
     --line-height-h1: 2.286rem;     /* 32px */
     --font-weight-normal: 400; /* 400 is the same as 'normal' */
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 4057f7f..5455a24 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -44,9 +44,10 @@
       --warning-background: var(--orange-900);
       --info-foreground: var(--blue-200);
       --info-background: var(--blue-900);
-      --info-deemphasized-foreground: var(--gray-700);
-      --info-deemphasized-background: var(--primary-text-color);
+      --selected-foreground: var(--blue-200);
+      --selected-background: var(--blue-900);
       --success-foreground: var(--green-200);
+      --success-background: var(--green-900);
       --gray-foreground: var(--gray-100);
       --gray-background: var(--gray-900);
       --tag-background: var(--cyan-900);
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index 0d751ab..46d0173d 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -48,7 +48,7 @@
   "include": [
     // Items below must be in sync with the src_dirs list in the BUILD file
     // Also items must be in sync with tsconfig_bazel.json, tsconfig_bazel_test.json
-    // (include and exclude arrays are overriden when extends)
+    // (include and exclude arrays are overridden when extends)
     "api/**/*",
     "constants/**/*",
     "elements/**/*",
diff --git a/polygerrit-ui/app/tsconfig_bazel.json b/polygerrit-ui/app/tsconfig_bazel.json
index 46bd926..dfd2078 100644
--- a/polygerrit-ui/app/tsconfig_bazel.json
+++ b/polygerrit-ui/app/tsconfig_bazel.json
@@ -9,7 +9,7 @@
   "include": [
     // Items below must be in sync with the src_dirs list in the BUILD file
     // Also items must be in sync with tsconfig.json, tsconfig_bazel_test.json
-    // (include and exclude arrays are overriden when extends)
+    // (include and exclude arrays are overridden when extends)
     "api/**/*",
     "constants/**/*",
     "elements/**/*",
diff --git a/polygerrit-ui/app/tsconfig_bazel_test.json b/polygerrit-ui/app/tsconfig_bazel_test.json
index 1b6c226..9c2ff93 100644
--- a/polygerrit-ui/app/tsconfig_bazel_test.json
+++ b/polygerrit-ui/app/tsconfig_bazel_test.json
@@ -14,7 +14,7 @@
   "include": [
     // Items below must be in sync with the src_dirs list in the BUILD file
     // Also items must be in sync with tsconfig.json, tsconfig_test.json
-    // (include and exclude arrays are overriden when extends)
+    // (include and exclude arrays are overridden when extends)
     "api/**/*",
     "constants/**/*",
     "elements/**/*",
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 95801e7..711b11b 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -1021,7 +1021,7 @@
  */
 export interface PluginConfigInfo {
   has_avatars: boolean;
-  // The following 2 properies exists in Java class, but don't mention in docs
+  // The following 2 properties exists in Java class, but don't mention in docs
   js_resource_paths: string[];
   html_resource_paths: string[];
 }
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index 6b05fad..5965453 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -19,6 +19,7 @@
 import {UIComment} from '../utils/comment-util';
 import {FetchRequest} from './types';
 import {MovedLinkClickedEventDetail} from '../api/diff';
+import {Category, RunStatus} from '../api/checks';
 
 export interface TitleChangeEventDetail {
   title: string;
@@ -152,6 +153,7 @@
 
 export interface TabState {
   commentTab?: CommentTabState;
+  checksTab?: ChecksTabState;
 }
 
 export enum CommentTabState {
@@ -160,6 +162,11 @@
   SHOW_ALL = 'show all',
 }
 
+export interface ChecksTabState {
+  statusOrCategory?: RunStatus | Category;
+  checkName?: string;
+}
+
 export type SwitchTabEvent = CustomEvent<SwitchTabEventDetail>;
 
 declare global {
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 1228863..a7f8b49 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -173,28 +173,44 @@
   return states;
 }
 
-export function isOwner(change?: ChangeInfo, account?: AccountInfo) {
+export function isOwner(change?: ChangeInfo, account?: AccountInfo): boolean {
   if (!change || !account) return false;
   return change.owner?._account_id === account._account_id;
 }
 
-export function isReviewer(change?: ChangeInfo, account?: AccountInfo) {
+export function isReviewer(
+  change?: ChangeInfo,
+  account?: AccountInfo
+): boolean {
   if (!change || !account) return false;
   const reviewers = change.reviewers.REVIEWER ?? [];
   return reviewers.some(r => r._account_id === account._account_id);
 }
 
-export function isUploader(change?: ChangeInfo, account?: AccountInfo) {
+export function isCc(change?: ChangeInfo, account?: AccountInfo): boolean {
+  if (!change || !account) return false;
+  const ccs = change.reviewers.CC ?? [];
+  return ccs.some(r => r._account_id === account._account_id);
+}
+
+export function isUploader(
+  change?: ChangeInfo,
+  account?: AccountInfo
+): boolean {
   if (!change || !account) return false;
   const rev = getCurrentRevision(change);
   return rev?.uploader?._account_id === account._account_id;
 }
 
-export function isInvolved(change?: ChangeInfo, account?: AccountInfo) {
+export function isInvolved(
+  change?: ChangeInfo,
+  account?: AccountInfo
+): boolean {
   const owner = isOwner(change, account);
   const uploader = isUploader(change, account);
   const reviewer = isReviewer(change, account);
-  return owner || uploader || reviewer;
+  const cc = isCc(change, account);
+  return owner || uploader || reviewer || cc;
 }
 
 export function getCurrentRevision(change?: ChangeInfo) {
diff --git a/polygerrit-ui/app/utils/common-util_test.js b/polygerrit-ui/app/utils/common-util_test.js
index 917d652b..d6d66d7 100644
--- a/polygerrit-ui/app/utils/common-util_test.js
+++ b/polygerrit-ui/app/utils/common-util_test.js
@@ -29,7 +29,7 @@
       assert.isTrue(hasOwnProperty(obj, 'name with spaces'));
       assert.isFalse(hasOwnProperty(obj, 'def'));
     });
-    test('object prototype has overriden hasOwnProperty', () => {
+    test('object prototype has overridden hasOwnProperty', () => {
       const F = function() {
         this.abc = 23;
       };
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index fad5041..3af8c59 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -37,9 +37,13 @@
 
 // similar to fromNow from moment.js
 export function fromNow(date: Date, noAgo = false) {
-  const now = new Date();
+  return durationString(date, new Date(), noAgo);
+}
+
+// similar to fromNow from moment.js
+export function durationString(from: Date, to: Date, noAgo = false) {
   const ago = noAgo ? '' : ' ago';
-  const secondsAgo = Math.floor((now.valueOf() - date.valueOf()) / 1000);
+  const secondsAgo = Math.floor((to.valueOf() - from.valueOf()) / 1000);
   if (secondsAgo <= 59) return 'just now';
   if (secondsAgo <= 119) return `1 minute${ago}`;
   const minutesAgo = Math.floor(secondsAgo / 60);
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 32014d7..7f9ef72 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -156,7 +156,7 @@
 
 export function windowLocationReload() {
   const e = new Error();
-  console.info(`Calling window.location.realod(): ${e.stack}`);
+  console.info(`Calling window.location.reload(): ${e.stack}`);
   window.location.reload();
 }
 
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index 60ac4d8..4eed0a0 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -51,3 +51,11 @@
 ): ApprovalInfo | undefined {
   return label.all?.filter(x => x._account_id === account._account_id)[0];
 }
+
+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;
+
+  return labelName1.localeCompare(labelName2);
+}
diff --git a/polygerrit-ui/app/utils/label-util_test.js b/polygerrit-ui/app/utils/label-util_test.js
index 6a2f768..f9a30df 100644
--- a/polygerrit-ui/app/utils/label-util_test.js
+++ b/polygerrit-ui/app/utils/label-util_test.js
@@ -21,6 +21,7 @@
   getVotingRangeOrDefault,
   getMaxAccounts,
   getApprovalInfo,
+  labelCompare,
 } from './label-util.js';
 
 const VALUES_1 = {
@@ -113,4 +114,11 @@
     };
     assert.isUndefined(getApprovalInfo(label, myAccountInfo));
   });
+
+  test('labelCompare', () => {
+    let sorted = ['c', 'b', 'a'].sort(labelCompare);
+    assert.sameOrderedMembers(sorted, ['a', 'b', 'c']);
+    sorted = ['b', 'a', 'Code-Review'].sort(labelCompare);
+    assert.sameOrderedMembers(sorted, ['Code-Review', 'a', 'b']);
+  });
 });
diff --git a/polygerrit-ui/app/utils/url-util_test.js b/polygerrit-ui/app/utils/url-util_test.js
index 0658be3..b1b17f4 100644
--- a/polygerrit-ui/app/utils/url-util_test.js
+++ b/polygerrit-ui/app/utils/url-util_test.js
@@ -25,15 +25,15 @@
 
 suite('url-util tests', () => {
   suite('getBaseUrl tests', () => {
-    let originialCanonicalPath;
+    let originalCanonicalPath;
 
     suiteSetup(() => {
-      originialCanonicalPath = window.CANONICAL_PATH;
+      originalCanonicalPath = window.CANONICAL_PATH;
       window.CANONICAL_PATH = '/r';
     });
 
     suiteTeardown(() => {
-      window.CANONICAL_PATH = originialCanonicalPath;
+      window.CANONICAL_PATH = originalCanonicalPath;
     });
 
     test('getBaseUrl', () => {
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index e544dbc..ec3b7a0 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -324,6 +324,11 @@
   dependencies:
     "@webcomponents/shadycss" "^1.9.1"
 
+"@types/resize-observer-browser@^0.1.5":
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.5.tgz#36d897708172ac2380cd486da7a3daf1161c1e23"
+  integrity sha512-8k/67Z95Goa6Lznuykxkfhq9YU3l1Qe6LNZmwde1u7802a3x8v44oq0j91DICclxatTr0rNnhXx7+VTIetSrSQ==
+
 "@webcomponents/shadycss@^1.9.1", "@webcomponents/shadycss@^1.9.2":
   version "1.9.4"
   resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.9.4.tgz#4f9d8ea1526bab084c60b53d4854dc39fdb2bb48"
diff --git a/tools/remote-bazelrc b/tools/remote-bazelrc
index de7d0df..22ee330 100644
--- a/tools/remote-bazelrc
+++ b/tools/remote-bazelrc
@@ -42,9 +42,6 @@
 # Set various strategies so that all actions execute remotely. Mixing remote
 # and local execution will lead to errors unless the toolchain and remote
 # machine exactly match the host machine.
-build:remote --spawn_strategy=remote,sandboxed
-build:remote --strategy=Javac=remote
-build:remote --strategy=Genrule=remote
 build:remote --define=EXECUTOR=remote
 
 # Enable the remote cache so action results can be shared across machines,
@@ -68,6 +65,3 @@
 build:remote-cache --tls_enabled=true
 build:remote-cache --remote_timeout=3600
 build:remote-cache --auth_enabled=true
-build:remote-cache --spawn_strategy=standalone
-build:remote-cache --strategy=Javac=standalone
-build:remote-cache --strategy=Genrule=standalone