Merge "Fix template types of gr-access-section"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index a8d8769..b609643 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -600,8 +600,9 @@
 ----
 
 As a response, two link:#change-info[ChangeInfo] entities are returned
-that describe information added and removed from the `old` change state.
-Only fields that differ between the change's two states are returned.
+that describe information added and removed from the `old` change state, and
+the two link:#change-info[ChangeInfo] entities that generated the diff are
+returned. Only fields that differ between the change's two states are returned.
 
 .Response
 ----
@@ -625,9 +626,55 @@
       "topic": "new-topic"
     },
     "removed": {
-      "updated": "2013-02-20 12:05:34.111000000",
+      "updated": "2013-02-01 09:59:32.126000000",
       "topic": "old-topic"
-    }
+    },
+    "old_change_info": {
+      "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
+      "project": "myProject",
+      "branch": "master",
+      "attention_set": [],
+      "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
+      "subject": "Implementing Feature X",
+      "status": "NEW",
+      "topic": "old-topic",
+      "created": "2013-02-01 09:59:32.126000000",
+      "updated": "2013-02-01 09:59:32.126000000",
+      "mergeable": true,
+      "insertions": 34,
+      "deletions": 101,
+      "_number": 3965,
+      "owner": {
+        "name": "John Doe"
+      }
+    },
+    "new_change_info": {
+      "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
+      "project": "myProject",
+      "branch": "master",
+      "attention_set": [
+        {
+          "account": {
+            "name": "John Doe"
+          },
+         "last_update": "2013-02-21 11:16:36.775000000",
+         "reason": "reviewer or cc replied"
+        }
+      ],
+      "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
+      "subject": "Implementing Feature X",
+      "status": "NEW",
+      "topic": "new-topic",
+      "created": "2013-02-01 09:59:32.126000000",
+      "updated": "2013-02-21 11:16:36.775000000",
+      "mergeable": true,
+      "insertions": 34,
+      "deletions": 101,
+      "_number": 3965,
+      "owner": {
+        "name": "John Doe"
+      }
+    },
   }
 ----
 
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
index ba5e323..ad112d3 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
@@ -63,9 +63,12 @@
    */
   public static ChangeInfoDifference getDifference(
       ChangeInfo oldChangeInfo, ChangeInfo newChangeInfo) {
-    return ChangeInfoDifference.create(
-        /* added= */ getAdded(oldChangeInfo, newChangeInfo),
-        /* removed= */ getAdded(newChangeInfo, oldChangeInfo));
+    return ChangeInfoDifference.builder()
+        .setOldChangeInfo(oldChangeInfo)
+        .setNewChangeInfo(newChangeInfo)
+        .setAdded(getAdded(oldChangeInfo, newChangeInfo))
+        .setRemoved(getAdded(newChangeInfo, oldChangeInfo))
+        .build();
   }
 
   @SuppressWarnings("unchecked") // reflection is used to construct instances of T
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDifference.java b/java/com/google/gerrit/extensions/common/ChangeInfoDifference.java
index 269c673..997c3ee 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfoDifference.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDifference.java
@@ -20,11 +20,29 @@
 @AutoValue
 public abstract class ChangeInfoDifference {
 
+  public abstract ChangeInfo oldChangeInfo();
+
+  public abstract ChangeInfo newChangeInfo();
+
   public abstract ChangeInfo added();
 
   public abstract ChangeInfo removed();
 
-  public static ChangeInfoDifference create(ChangeInfo added, ChangeInfo removed) {
-    return new AutoValue_ChangeInfoDifference(added, removed);
+  public static Builder builder() {
+    return new AutoValue_ChangeInfoDifference.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder setOldChangeInfo(ChangeInfo oldChangeInfo);
+
+    public abstract Builder setNewChangeInfo(ChangeInfo newChangeInfo);
+
+    public abstract Builder setAdded(ChangeInfo added);
+
+    public abstract Builder setRemoved(ChangeInfo removed);
+
+    public abstract ChangeInfoDifference build();
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index c4ae33a..3ef9c8f 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -490,7 +490,7 @@
                 continue;
               }
 
-              List<Ref> refs = retrieveBranchRefs(e);
+              List<Ref> refs = retrieveBranchRefs(e, git);
               if (!hasValidRef(refs)) {
                 continue;
               }
@@ -578,7 +578,8 @@
     }
   }
 
-  private List<Ref> retrieveBranchRefs(ProjectState e) throws PermissionBackendException {
+  private List<Ref> retrieveBranchRefs(ProjectState e, Repository git)
+      throws PermissionBackendException {
     boolean canReadAllRefs = e.statePermitsRead();
     if (canReadAllRefs) {
       try {
@@ -588,7 +589,7 @@
       }
     }
 
-    return getBranchRefs(e.getNameKey(), canReadAllRefs);
+    return getBranchRefs(e.getNameKey(), canReadAllRefs, git);
   }
 
   private void addParentProjectInfo(
@@ -708,9 +709,10 @@
     stdout.flush();
   }
 
-  private List<Ref> getBranchRefs(Project.NameKey projectName, boolean canReadAllRefs) {
+  private List<Ref> getBranchRefs(
+      Project.NameKey projectName, boolean canReadAllRefs, Repository git) {
     Ref[] result = new Ref[showBranch.size()];
-    try (Repository git = repoManager.openRepository(projectName)) {
+    try {
       PermissionBackend.ForProject perm = permissionBackend.user(currentUser).project(projectName);
       for (int i = 0; i < showBranch.size(); i++) {
         Ref ref = git.findRef(showBranch.get(i));
diff --git a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
index 4352fe8..024e35e 100644
--- a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
+++ b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
@@ -58,6 +58,17 @@
   }
 
   @Test
+  public void getDiff_returnsOldAndNewChangeInfos() {
+    ChangeInfo oldChangeInfo = createChangeInfoWithTopic("topic");
+    ChangeInfo newChangeInfo = createChangeInfoWithTopic(oldChangeInfo.topic);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.oldChangeInfo()).isEqualTo(oldChangeInfo);
+    assertThat(diff.newChangeInfo()).isEqualTo(newChangeInfo);
+  }
+
+  @Test
   public void getDiff_givenUnchangedTopic_returnsNullTopics() {
     ChangeInfo oldChangeInfo = createChangeInfoWithTopic("topic");
     ChangeInfo newChangeInfo = createChangeInfoWithTopic(oldChangeInfo.topic);
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index c8949e6..9ebee9c 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -3698,18 +3698,16 @@
 
     TestRepository<Repo> repo = createProject("repo");
     Change change = insert(repo, newChange(repo));
-    AssigneeInput ain = new AssigneeInput();
-    ain.assignee = user2.toString();
-    gApi.changes().id(change.getId().get()).setAssignee(ain);
+    gApi.changes().id(change.getId().get()).addReviewer(user2.toString());
 
     RequestContext adminContext = requestContext.setContext(newRequestContext(user2));
-    assertQuery("assignee:self", change);
+    assertQuery("reviewer:self", change);
 
     requestContext.setContext(adminContext);
     gApi.accounts().id(user2.get()).setActive(false);
 
     requestContext.setContext(newRequestContext(user2));
-    assertQuery("assignee:self", change);
+    assertQuery("reviewer:self", change);
   }
 
   @Test
diff --git a/package.json b/package.json
index e1bbf7c..a47ba9f 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
     "eslint-plugin-html": "^6.1.2",
     "eslint-plugin-import": "^2.22.1",
     "eslint-plugin-jsdoc": "^32.3.0",
+    "eslint-plugin-lit": "^1.5.1",
     "eslint-plugin-node": "^11.1.0",
     "eslint-plugin-prettier": "^3.4.0",
     "eslint-plugin-regex": "^1.8.0",
diff --git a/plugins/delete-project b/plugins/delete-project
index 3481845..8fe544a 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 3481845462f86959ff4ae4efb726e2bc3e494c3d
+Subproject commit 8fe544ac569efa357ee054257143d8e1d4aa6afd
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index 247227b..14f9e8c 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -412,11 +412,30 @@
         }],
       },
     },
+    {
+      files: ['*.ts'],
+      excludedFiles: '*_html.ts',
+      rules: {
+        'lit/attribute-value-entities': 'error',
+        'lit/binding-positions': 'error',
+        'lit/no-duplicate-template-bindings': 'error',
+        'lit/no-invalid-html': 'error',
+        'lit/no-legacy-template-syntax': 'error',
+        'lit/no-property-change-update': 'error',
+        'lit/no-invalid-escape-sequences': 'error',
+        'lit/no-legacy-imports': 'error',
+        'lit/no-private-properties': 'error',
+        'lit/no-useless-template-literals': 'error',
+        'lit/no-value-attribute': 'error',
+        'lit/prefer-static-styles': 'error',
+      },
+    },
   ],
   plugins: [
     'html',
     'jsdoc',
     'import',
+    'lit',
     'prettier',
     'regex',
   ],
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 07e9e6f..0552d45 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -105,7 +105,6 @@
     "elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.ts",
     "elements/admin/gr-repo/gr-repo_html.ts",
     "elements/admin/gr-rule-editor/gr-rule-editor_html.ts",
-    "elements/change-list/gr-change-list-item/gr-change-list-item_html.ts",
     "elements/change-list/gr-change-list-view/gr-change-list-view_html.ts",
     "elements/change-list/gr-change-list/gr-change-list_html.ts",
     "elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts",
@@ -113,9 +112,6 @@
     "elements/change/gr-change-metadata/gr-change-metadata_html.ts",
     "elements/change/gr-change-requirements/gr-change-requirements_html.ts",
     "elements/change/gr-change-view/gr-change-view_html.ts",
-    "elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_html.ts",
-    "elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_html.ts",
-    "elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_html.ts",
     "elements/change/gr-file-list-header/gr-file-list-header_html.ts",
     "elements/change/gr-file-list/gr-file-list_html.ts",
     "elements/change/gr-label-score-row/gr-label-score-row_html.ts",
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 1453fd0..ee579ff 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -53,30 +53,30 @@
 }
 
 /**
+ * Represents a "generic" text range in the code (e.g. text selection)
+ */
+interface TextRange {
+  /** first line of the range (1-based inclusive). */
+  start_line: number;
+  /** first column of the range (in the first line) (1-based inclusive). */
+  start_column: number;
+  /** last line of the range (1-based inclusive). */
+  end_line: number;
+  /** last column of the range (in the end line) (1-based inclusive). */
+  end_column: number;
+}
+
+/**
  * Represents a syntax block in a code (e.g. method, function, class, if-else).
  */
 export declare interface SyntaxBlock {
   /** Name of the block (e.g. name of the method/class)*/
   name: string;
-  /** Where does this block syntatically starts and ends (line number and column).*/
-  range: {
-    /** first line of the block (1-based inclusive). */
-    start_line: number;
-    /**
-     * column of the range start inside the first line (e.g. "{" character ending a function/method)
-     * (1-based inclusive).
-     */
-    start_column: number;
-    /**
-     * last line of the block (1-based inclusive).
-     */
-    end_line: number;
-    /**
-     * column of the block end inside the end line (e.g. "}" character ending a function/method)
-     * (1-based inclusive).
-     */
-    end_column: number;
-  };
+  /**
+   * Where does this block syntatically starts and ends (line number and
+   * column).
+   */
+  range: TextRange;
   /** Sub-blocks of the current syntax block (e.g. methods of a class) */
   children: SyntaxBlock[];
 }
@@ -210,15 +210,22 @@
 }
 
 /**
- * Listens to changes in token highlighting - when a new token starts or stopped being highlighted.
- * Examples:
- * - Token highlighted: ('myFunctionName', 12, [Element]).
- * - Token unhighlighted: (undefined, 0, undefined).
+ * Event details when a token is highlighted.
  */
-export type TokenHighlightedListener = (
-  newHighlight: string | undefined,
-  newLineNumber: number,
-  hoveredElement?: Element
+export declare interface TokenHighlightEventDetails {
+  token: string;
+  element: Element;
+  side: Side;
+  range: TextRange;
+}
+
+/**
+ * Listens to changes in token highlighting - when a new token starts or stopped
+ * being highlighted. undefined is sent if the event is about a clear in
+ * highlighting.
+ */
+export type TokenHighlightListener = (
+  tokenHighlightEvent?: TokenHighlightEventDetails
 ) => void;
 
 export declare interface ImageDiffPreferences {
diff --git a/polygerrit-ui/app/api/embed.ts b/polygerrit-ui/app/api/embed.ts
index fed724e..520aeec 100644
--- a/polygerrit-ui/app/api/embed.ts
+++ b/polygerrit-ui/app/api/embed.ts
@@ -24,7 +24,7 @@
   DiffLayer,
   GrAnnotation,
   GrDiffCursor,
-  TokenHighlightedListener,
+  TokenHighlightListener,
 } from './diff';
 
 declare global {
@@ -35,7 +35,7 @@
       TokenHighlightLayer: {
         new (
           container?: HTMLElement,
-          listener?: TokenHighlightedListener
+          listener?: TokenHighlightListener
         ): DiffLayer;
       };
     };
diff --git a/polygerrit-ui/app/api/popup.ts b/polygerrit-ui/app/api/popup.ts
index 8d81831..d265ee6 100644
--- a/polygerrit-ui/app/api/popup.ts
+++ b/polygerrit-ui/app/api/popup.ts
@@ -17,9 +17,10 @@
 
 export declare interface PopupPluginApi {
   /**
-   * Opens the popup, inserts it into DOM over current UI.
-   * Creates the popup if not previously created. Creates popup content element,
-   * if it was provided with constructor.
+   * Opens the popup, inserts it into the DOM over current UI.
+   * Creates the popup if not previously created. Creates and inserts the popup
+   * content element, if a `moduleName` was provided in the constructor.
+   * Otherwise you have to call `appendContent()` when the promise resolves.
    */
   open(): Promise<PopupPluginApi>;
 
@@ -27,4 +28,10 @@
    * Hides the popup.
    */
   close(): void;
+
+  /**
+   * Appends the given element as a child to the popup. Only call this method
+   * when you have called `popup()` without a `moduleName`.
+   */
+  appendContent(el: HTMLElement): void;
 }
diff --git a/polygerrit-ui/app/api/styles.ts b/polygerrit-ui/app/api/styles.ts
index 43149d3..55ac2cc 100644
--- a/polygerrit-ui/app/api/styles.ts
+++ b/polygerrit-ui/app/api/styles.ts
@@ -37,6 +37,7 @@
   font: Style;
   form: Style;
   menuPage: Style;
+  spinner: Style;
   subPage: Style;
   table: Style;
 }
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 8f94b38..645e770 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -306,3 +306,5 @@
 }
 
 export const RELOAD_DASHBOARD_INTERVAL_MS = 10 * 1000;
+
+export const SHOWN_ITEMS_COUNT = 25;
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
index cdd2913..b438420 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -23,7 +23,6 @@
 import '../gr-create-group-dialog/gr-create-group-dialog';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-admin-group-list_html';
-import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property, observe, computed} from '@polymer/decorators';
 import {AppElementAdminParams} from '../../gr-app-types';
@@ -32,6 +31,7 @@
 import {GrCreateGroupDialog} from '../gr-create-group-dialog/gr-create-group-dialog';
 import {fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
+import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -46,11 +46,8 @@
   };
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = ListViewMixin(PolymerElement);
-
 @customElement('gr-admin-group-list')
-export class GrAdminGroupList extends base {
+export class GrAdminGroupList extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -82,7 +79,7 @@
    * */
   @computed('_groups')
   get _shownGroups() {
-    return this.computeShownItems(this._groups);
+    return this._groups.slice(0, SHOWN_ITEMS_COUNT);
   }
 
   @property({type: Number})
@@ -106,8 +103,8 @@
   @observe('params')
   _paramsChanged(params: AppElementAdminParams) {
     this._loading = true;
-    this._filter = this.getFilterValue(params);
-    this._offset = this.getOffsetValue(params);
+    this._filter = params?.filter ?? '';
+    this._offset = Number(params?.offset ?? 0);
 
     return this._getGroups(this._filter, this._groupsPerPage, this._offset);
   }
@@ -184,4 +181,8 @@
   _visibleToAll(item: GroupInfo) {
     return item.options?.visible_to_all === true ? 'Y' : 'N';
   }
+
+  computeLoadingClass(loading: boolean) {
+    return loading ? 'loading' : '';
+  }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
index d40b810..6605350 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
@@ -17,11 +17,9 @@
 
 import '../../../styles/gr-table-styles';
 import '../../../styles/shared-styles';
-import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-account-link/gr-account-link';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-group-audit-log_html';
-import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property} from '@polymer/decorators';
 import {
@@ -36,11 +34,8 @@
 import {appContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = ListViewMixin(PolymerElement);
-
 @customElement('gr-group-audit-log')
-export class GrGroupAuditLog extends base {
+export class GrGroupAuditLog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -130,6 +125,10 @@
 
     return '';
   }
+
+  computeLoadingClass(loading: boolean) {
+    return loading ? 'loading' : '';
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
index 40c2f30..828aa55 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_html.ts
@@ -43,7 +43,7 @@
       <template is="dom-repeat" items="[[_auditLog]]">
         <tr class="table">
           <td class="date">
-            <gr-date-formatter has-tooltip="" date-str="[[item.date]]">
+            <gr-date-formatter withTooltip date-str="[[item.date]]">
             </gr-date-formatter>
           </td>
           <td class="type">[[itemType(item.type)]]</td>
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
index d26d14c..b91b04b 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.js
@@ -140,7 +140,7 @@
     'https://test/site/group/url');
   });
 
-  test('save members correctly', () => {
+  test('save members correctly', async () => {
     element._groupOwner = true;
 
     const memberName = 'test-admin';
@@ -155,6 +155,7 @@
     element.$.groupMemberSearchInput.text = memberName;
     element.$.groupMemberSearchInput.value = 1234;
 
+    await flush();
     assert.isFalse(button.hasAttribute('disabled'));
 
     return element._handleSavingGroupMember().then(() => {
@@ -165,7 +166,7 @@
     });
   });
 
-  test('save included groups correctly', () => {
+  test('save included groups correctly', async () => {
     element._groupOwner = true;
 
     const includedGroupName = 'testName';
@@ -179,7 +180,7 @@
 
     element.$.includedGroupSearchInput.text = includedGroupName;
     element.$.includedGroupSearchInput.value = 'testId';
-
+    await flush();
     assert.isFalse(button.hasAttribute('disabled'));
 
     return element._handleSavingIncludedGroups().then(() => {
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
index e492a15..e390ac5 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
@@ -94,6 +94,7 @@
 
     element.$.groupNameInput.text = groupName2;
 
+    await flush();
     assert.isFalse(button.hasAttribute('disabled'));
     assert.isTrue(element.$.groupName.classList.contains('edited'));
 
@@ -122,6 +123,7 @@
 
     element.$.groupOwnerInput.text = 'testId2';
 
+    await flush();
     assert.isFalse(button.hasAttribute('disabled'));
     assert.isTrue(element.$.groupOwner.classList.contains('edited'));
 
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
index 13bd79d..9f51688 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
@@ -19,25 +19,21 @@
 import '../../shared/gr-list-view/gr-list-view';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-plugin-list_html';
-import {
-  ListViewMixin,
-  ListViewParams,
-} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {customElement, property} from '@polymer/decorators';
 import {PluginInfo} from '../../../types/common';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util';
+import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {ListViewParams} from '../../gr-app-types';
 
 interface PluginInfoWithName extends PluginInfo {
   name: string;
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = ListViewMixin(PolymerElement);
-
 @customElement('gr-plugin-list')
-export class GrPluginList extends base {
+export class GrPluginList extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -85,8 +81,8 @@
 
   _paramsChanged(params: ListViewParams) {
     this._loading = true;
-    this._filter = this.getFilterValue(params);
-    this._offset = this.getOffsetValue(params);
+    this._filter = params?.filter ?? '';
+    this._offset = Number(params?.offset ?? 0);
 
     return this._getPlugins(this._filter, this._pluginsPerPage, this._offset);
   }
@@ -114,7 +110,15 @@
   }
 
   _computePluginUrl(id: string) {
-    return this.getUrl('/', id);
+    return getBaseUrl() + '/' + encodeURL(id, true);
+  }
+
+  computeLoadingClass(loading: boolean) {
+    return loading ? 'loading' : '';
+  }
+
+  computeShownItems(plugins: PluginInfoWithName[]) {
+    return plugins.slice(0, SHOWN_ITEMS_COUNT);
   }
 }
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
index 3693845..00c5999 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -30,7 +30,6 @@
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-detail-list_html';
-import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {encodeURL} from '../../../utils/url-util';
 import {customElement, property} from '@polymer/decorators';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
@@ -49,6 +48,7 @@
 import {firePageError} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
+import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 
 const PGP_START = '-----BEGIN PGP SIGNATURE-----';
 
@@ -60,11 +60,8 @@
   };
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = ListViewMixin(PolymerElement);
-
 @customElement('gr-repo-detail-list')
-export class GrRepoDetailList extends base {
+export class GrRepoDetailList extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -155,8 +152,8 @@
 
     this.detailType = params.detail;
 
-    this._filter = this.getFilterValue(params);
-    this._offset = this.getOffsetValue(params);
+    this._filter = params?.filter ?? '';
+    this._offset = Number(params?.offset ?? 0);
     if (!this.detailType)
       return Promise.reject(new Error('undefined detailType'));
 
@@ -395,6 +392,14 @@
   _computeHideTagger(tagger?: GitPersonInfo) {
     return tagger ? '' : 'hide';
   }
+
+  computeLoadingClass(loading: boolean) {
+    return loading ? 'loading' : '';
+  }
+
+  computeShownItems(items: BranchInfo[] | TagInfo[]) {
+    return items.slice(0, SHOWN_ITEMS_COUNT);
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
index 4f66f0d..429a6d6 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
@@ -145,10 +145,7 @@
             <td class$="tagger [[_hideIfBranch(detailType)]]">
               <div class$="tagger [[_computeHideTagger(item.tagger)]]">
                 <gr-account-link account="[[item.tagger]]"> </gr-account-link>
-                (<gr-date-formatter
-                  has-tooltip=""
-                  date-str="[[item.tagger.date]]"
-                >
+                (<gr-date-formatter withTooltip date-str="[[item.tagger.date]]">
                 </gr-date-formatter
                 >)
               </div>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index e87bb0d..beef556 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -22,16 +22,16 @@
 import '../gr-create-repo-dialog/gr-create-repo-dialog';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-list_html';
-import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property, observe, computed} from '@polymer/decorators';
 import {AppElementAdminParams} from '../../gr-app-types';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {RepoName, ProjectInfoWithName} from '../../../types/common';
 import {GrCreateRepoDialog} from '../gr-create-repo-dialog/gr-create-repo-dialog';
-import {ProjectState} from '../../../constants/constants';
+import {ProjectState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -46,11 +46,8 @@
   };
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = ListViewMixin(PolymerElement);
-
 @customElement('gr-repo-list')
-export class GrRepoList extends base {
+export class GrRepoList extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -84,7 +81,7 @@
 
   @computed('_repos')
   get _shownRepos() {
-    return this.computeShownItems(this._repos);
+    return this._repos.slice(0, SHOWN_ITEMS_COUNT);
   }
 
   private readonly restApiService = appContext.restApiService;
@@ -99,8 +96,8 @@
   @observe('params')
   _paramsChanged(params: AppElementAdminParams) {
     this._loading = true;
-    this._filter = this.getFilterValue(params);
-    this._offset = this.getOffsetValue(params);
+    this._filter = params?.filter ?? '';
+    this._offset = Number(params?.offset ?? 0);
 
     return this._getRepos(this._filter, this._reposPerPage, this._offset);
   }
@@ -115,7 +112,7 @@
   }
 
   _computeRepoUrl(name: string) {
-    return this.getUrl(this._path + '/', name);
+    return getBaseUrl() + this._path + '/' + encodeURL(name, true);
   }
 
   _computeChangesLink(name: string) {
@@ -185,4 +182,8 @@
     const webLinks = repo.web_links;
     return webLinks.length ? webLinks : null;
   }
+
+  computeLoadingClass(loading: boolean) {
+    return loading ? 'loading' : '';
+  }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
index 02339cf..6e68ae7 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
@@ -119,15 +119,7 @@
   private renderOption(option: PluginOption) {
     return html`
       <section class="section ${option.info.type}">
-        <span class="title">
-          <gr-tooltip-content
-            has-tooltip="${option.info.description}"
-            show-icon="${option.info.description}"
-            title="${option.info.description}"
-          >
-            <span>${option.info.display_name}</span>
-          </gr-tooltip-content>
-        </span>
+        <span class="title"> ${this.renderOptionTitle(option)} </span>
         <span class="value">
           ${this.renderOptionDetail(option)} ${this.renderInherited(option)}
         </span>
@@ -135,6 +127,18 @@
     `;
   }
 
+  private renderOptionTitle(option: PluginOption) {
+    const titleName = html`<span>${option.info.display_name}</span>`;
+    if (!option.info.description) return titleName;
+    return html` <gr-tooltip-content
+      has-tooltip
+      show-icon
+      title="${option.info.description}"
+    >
+      ${titleName}
+    </gr-tooltip-content>`;
+  }
+
   private renderOptionDetail(option: PluginOption) {
     if (option.info.type === ConfigParameterInfoType.ARRAY) {
       return html`
@@ -180,7 +184,7 @@
         >
           <input
             is="iron-input"
-            value="${option.info.value}"
+            .value="${option.info.value}"
             @input=${this._handleStringChange}
             data-option-key="${option._key}"
             ?disabled=${!option.info.editable}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
index 313b91f..d160a28 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -28,7 +28,6 @@
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-list-item_html';
-import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
@@ -76,11 +75,8 @@
 // How many reviewers should be shown with an account-label?
 const PRIMARY_REVIEWERS_COUNT = 2;
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = ChangeTableMixin(PolymerElement);
-
 @customElement('gr-change-list-item')
-export class GrChangeListItem extends base {
+export class GrChangeListItem extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -354,10 +350,7 @@
     return this._computeAdditionalReviewers(change).length;
   }
 
-  _computeAdditionalReviewersTitle(
-    change: ChangeInfo | undefined,
-    config: ServerInfo
-  ) {
+  _computeAdditionalReviewersTitle(change?: ChangeInfo, config?: ServerInfo) {
     if (!change || !config) return '';
     return this._computeAdditionalReviewers(change)
       .map(user => getDisplayName(config, user, true))
@@ -393,13 +386,20 @@
   }
 
   _computeWaiting(
-    account?: AccountInfo,
-    change?: ChangeInfo
+    account?: AccountInfo | null,
+    change?: ChangeInfo | null
   ): Timestamp | undefined {
     if (!account?._account_id || !change?.attention_set) return undefined;
     return change?.attention_set[account._account_id]?.last_update;
   }
 
+  _computeIsColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]) {
+    if (!columnsToDisplay || !columnToCheck) {
+      return false;
+    }
+    return !columnsToDisplay.includes(columnToCheck);
+  }
+
   toggleReviewed() {
     if (!this.change) return;
     const newVal = !this.change?.reviewed;
@@ -417,6 +417,11 @@
     );
   }
 
+  _formatDate(date: Timestamp | undefined): string | undefined {
+    if (!date) return undefined;
+    return date.toString();
+  }
+
   _handleChangeClick() {
     // Don't prevent the default and neither stop bubbling. We just want to
     // report the click, but then let the browser handle the click on the link.
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
index 8ee6a3f..f10ffd0 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
@@ -127,7 +127,7 @@
   </td>
   <td
     class="cell subject"
-    hidden$="[[isColumnHidden('Subject', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Subject', visibleChangeTableColumns)]]"
   >
     <a
       title$="[[change.subject]]"
@@ -143,7 +143,7 @@
   </td>
   <td
     class="cell status"
-    hidden$="[[isColumnHidden('Status', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Status', visibleChangeTableColumns)]]"
   >
     <template is="dom-repeat" items="[[statuses]]" as="status">
       <div class="comma">,</div>
@@ -155,7 +155,7 @@
   </td>
   <td
     class="cell owner"
-    hidden$="[[isColumnHidden('Owner', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Owner', visibleChangeTableColumns)]]"
   >
     <gr-account-link
       highlightAttention
@@ -165,7 +165,7 @@
   </td>
   <td
     class="cell assignee"
-    hidden$="[[isColumnHidden('Assignee', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Assignee', visibleChangeTableColumns)]]"
   >
     <template is="dom-if" if="[[change.assignee]]">
       <gr-account-link
@@ -179,7 +179,7 @@
   </td>
   <td
     class="cell reviewers"
-    hidden$="[[isColumnHidden('Reviewers', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Reviewers', visibleChangeTableColumns)]]"
   >
     <div>
       <template
@@ -204,14 +204,14 @@
       </template>
       <template is="dom-if" if="[[_computeAdditionalReviewersCount(change)]]">
         <span title="[[_computeAdditionalReviewersTitle(change, config)]]">
-          +[[_computeAdditionalReviewersCount(change, config)]]
+          +[[_computeAdditionalReviewersCount(change)]]
         </span>
       </template>
     </div>
   </td>
   <td
     class="cell comments"
-    hidden$="[[isColumnHidden('Comments', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Comments', visibleChangeTableColumns)]]"
   >
     <iron-icon
       hidden$="[[!change.unresolved_comment_count]]"
@@ -221,22 +221,22 @@
   </td>
   <td
     class="cell repo"
-    hidden$="[[isColumnHidden('Repo', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Repo', visibleChangeTableColumns)]]"
   >
     <a class="fullRepo" href$="[[_computeRepoUrl(change)]]">
-      [[_computeRepoDisplay(change)]]
+      [[_computeRepoDisplay(change, false)]]
     </a>
     <a
       class="truncatedRepo"
       href$="[[_computeRepoUrl(change)]]"
-      title$="[[_computeRepoDisplay(change)]]"
+      title$="[[_computeRepoDisplay(change, false)]]"
     >
-      [[_computeRepoDisplay(change, 'true')]]
+      [[_computeRepoDisplay(change, true)]]
     </a>
   </td>
   <td
     class="cell branch"
-    hidden$="[[isColumnHidden('Branch', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Branch', visibleChangeTableColumns)]]"
   >
     <a href$="[[_computeRepoBranchURL(change)]]"> [[change.branch]] </a>
     <template is="dom-if" if="[[change.topic]]">
@@ -250,38 +250,38 @@
   </td>
   <td
     class="cell updated"
-    hidden$="[[isColumnHidden('Updated', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Updated', visibleChangeTableColumns)]]"
   >
     <gr-date-formatter
-      has-tooltip=""
-      date-str="[[change.updated]]"
+      withTooltip
+      date-str="[[_formatDate(change.updated)]]"
     ></gr-date-formatter>
   </td>
   <td
     class="cell submitted"
-    hidden$="[[isColumnHidden('Submitted', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Submitted', visibleChangeTableColumns)]]"
   >
     <gr-date-formatter
-      has-tooltip=""
-      date-str="[[change.submitted]]"
+      withTooltip
+      date-str="[[_formatDate(change.submitted)]]"
     ></gr-date-formatter>
   </td>
   <td
     class="cell waiting"
-    hidden$="[[isColumnHidden('Waiting', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Waiting', visibleChangeTableColumns)]]"
   >
     <gr-date-formatter
-      has-tooltip=""
-      force-relative=""
-      relative-option-no-ago=""
+      withTooltip
+      forceRelative
+      relativeOptionNoAge
       date-str="[[_computeWaiting(account, change)]]"
     ></gr-date-formatter>
   </td>
   <td
     class="cell size"
-    hidden$="[[isColumnHidden('Size', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Size', visibleChangeTableColumns)]]"
   >
-    <gr-tooltip-content has-tooltip="" title="[[_computeSizeTooltip(change)]]">
+    <gr-tooltip-content has-tooltip title="[[_computeSizeTooltip(change)]]">
       <template is="dom-if" if="[[_changeSize]]">
         <span>[[_changeSize]]</span>
       </template>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
index ac0b929..aa04784 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -29,6 +29,7 @@
   TopicName,
 } from '../../../types/common';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {columnNames} from '../gr-change-list/gr-change-list';
 import './gr-change-list-item';
 import {GrChangeListItem, LabelCategory} from './gr-change-list-item';
 
@@ -372,7 +373,7 @@
 
     await flush();
 
-    for (const column of element.columnNames) {
+    for (const column of columnNames) {
       const elementClass = '.' + column.toLowerCase();
       assert.isFalse(
         queryAndAssert(element, elementClass).hasAttribute('hidden')
@@ -395,7 +396,7 @@
 
     await flush();
 
-    for (const column of element.columnNames) {
+    for (const column of columnNames) {
       const elementClass = '.' + column.toLowerCase();
       if (column === 'Repo') {
         assert.isTrue(
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 40674d4..a2a46e0 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -24,7 +24,6 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-list_html';
 import {appContext} from '../../../services/app-context';
-import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
 import {
   KeyboardShortcutMixin,
   Shortcut,
@@ -57,6 +56,19 @@
 const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
 const MAX_SHORTCUT_CHARS = 5;
 
+export const columnNames = [
+  'Subject',
+  'Status',
+  'Owner',
+  'Assignee',
+  'Reviewers',
+  'Comments',
+  'Repo',
+  'Branch',
+  'Updated',
+  'Size',
+];
+
 export interface ChangeListSection {
   name?: string;
   query?: string;
@@ -68,7 +80,7 @@
 }
 
 // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = ChangeTableMixin(KeyboardShortcutMixin(PolymerElement));
+const base = KeyboardShortcutMixin(PolymerElement);
 
 @customElement('gr-change-list')
 export class GrChangeList extends base {
@@ -219,32 +231,44 @@
       return;
     }
 
-    this.changeTableColumns = this.columnNames;
+    this.changeTableColumns = columnNames;
     this.showNumber = false;
-    this.visibleChangeTableColumns = this.getEnabledColumns(
-      this.columnNames,
-      config,
-      this.flagsService.enabledExperiments
+    this.visibleChangeTableColumns = this.changeTableColumns.filter(col =>
+      this._isColumnEnabled(col, config, this.flagsService.enabledExperiments)
     );
-
     if (account && preferences) {
       this.showNumber = !!(
         preferences && preferences.legacycid_in_change_table
       );
       if (preferences.change_table && preferences.change_table.length > 0) {
-        const prefColumns = this.renameProjectToRepoColumn(
-          preferences.change_table
+        const prefColumns = preferences.change_table.map(column =>
+          column === 'Project' ? 'Repo' : column
         );
-        this.visibleChangeTableColumns = this.getEnabledColumns(
-          prefColumns,
-          config,
-          this.flagsService.enabledExperiments
+        this.visibleChangeTableColumns = prefColumns.filter(col =>
+          this._isColumnEnabled(
+            col,
+            config,
+            this.flagsService.enabledExperiments
+          )
         );
       }
     }
   }
 
   /**
+   * Is the column disabled by a server config or experiment? For example the
+   * assignee feature might be disabled and thus the corresponding column is
+   * also disabled.
+   *
+   */
+  _isColumnEnabled(column: string, config: ServerInfo, experiments: string[]) {
+    if (!config || !config.change) return true;
+    if (column === 'Assignee') return !!config.change.enable_assignee;
+    if (column === 'Comments') return experiments.includes('comments-column');
+    return true;
+  }
+
+  /**
    * This methods allows us to customize the columns per section.
    *
    * @param visibleColumns are the columns according to configs and user prefs
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
index 0d2056a..7b226e7 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
@@ -269,7 +269,7 @@
     });
 
     test('all columns visible', () => {
-      for (const column of element.columnNames) {
+      for (const column of element.changeTableColumns) {
         const elementClass = '.' + element._lowerCase(column);
         assert.isFalse(element.shadowRoot
             .querySelector(elementClass).hidden);
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 456b7cf..9eca3bc 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
@@ -14,6 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
 import '../gr-change-list/gr-change-list';
 import '../../shared/gr-button/gr-button';
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
index 484a952..4778890 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-a11y-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       display: block;
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
index beadea3..9242a58 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 
-import '../../shared/gr-date-formatter/gr-date-formatter';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {RepoName} from '../../../types/common';
 import {WebLinkInfo} from '../../../types/diff';
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
index 614339a..b5f55ca 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_html.ts
@@ -84,24 +84,27 @@
       hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]"
     >
       <template is="dom-repeat" items="[[_topLevelPrimaryActions]]" as="action">
-        <gr-button
-          link=""
+        <gr-tooltip-content
           title$="[[action.title]]"
           has-tooltip="[[_computeHasTooltip(action.title)]]"
           position-below="true"
-          data-action-key$="[[action.__key]]"
-          class$="[[action.__key]]"
-          data-action-type$="[[action.__type]]"
-          data-label$="[[action.label]]"
-          disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-          on-click="_handleActionTap"
         >
-          <iron-icon
-            class$="[[_computeHasIcon(action)]]"
-            icon$="gr-icons:[[action.icon]]"
-          ></iron-icon>
-          [[action.label]]
-        </gr-button>
+          <gr-button
+            link=""
+            data-action-key$="[[action.__key]]"
+            class$="[[action.__key]]"
+            data-action-type$="[[action.__type]]"
+            data-label$="[[action.label]]"
+            disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
+            on-click="_handleActionTap"
+          >
+            <iron-icon
+              class$="[[_computeHasIcon(action)]]"
+              icon$="gr-icons:[[action.icon]]"
+            ></iron-icon>
+            [[action.label]]
+          </gr-button>
+        </gr-tooltip-content>
       </template>
     </section>
     <section
@@ -113,24 +116,27 @@
         items="[[_topLevelSecondaryActions]]"
         as="action"
       >
-        <gr-button
-          link=""
+        <gr-tooltip-content
           title$="[[action.title]]"
           has-tooltip="[[_computeHasTooltip(action.title)]]"
           position-below="true"
-          data-action-key$="[[action.__key]]"
-          class$="[[action.__key]]"
-          data-action-type$="[[action.__type]]"
-          data-label$="[[action.label]]"
-          disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-          on-click="_handleActionTap"
         >
-          <iron-icon
-            class$="[[_computeHasIcon(action)]]"
-            icon$="gr-icons:[[action.icon]]"
-          ></iron-icon>
-          [[action.label]]
-        </gr-button>
+          <gr-button
+            link=""
+            data-action-key$="[[action.__key]]"
+            class$="[[action.__key]]"
+            data-action-type$="[[action.__type]]"
+            data-label$="[[action.label]]"
+            disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
+            on-click="_handleActionTap"
+          >
+            <iron-icon
+              class$="[[_computeHasIcon(action)]]"
+              icon$="gr-icons:[[action.icon]]"
+            ></iron-icon>
+            [[action.label]]
+          </gr-button>
+        </gr-tooltip-content>
       </template>
     </section>
     <gr-button hidden$="[[!_loading]]" disabled=""
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 04326a6..26e2fb4 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -1094,7 +1094,7 @@
         );
         tap(abandonButton);
 
-        assert.isUndefined(element.$.confirmAbandonDialog.message);
+        assert.equal(element.$.confirmAbandonDialog.message, '');
       });
 
       test('works', () => {
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 c080345..a37daaa 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
@@ -134,9 +134,9 @@
         <span class="title">Submitted</span>
         <span class="value">
           <gr-date-formatter
-            has-tooltip=""
+            withTooltip
             date-str="[[change.submitted]]"
-            show-yesterday=""
+            showYesterday=""
           ></gr-date-formatter>
         </span>
       </section>
@@ -154,9 +154,9 @@
       </span>
       <span class="value">
         <gr-date-formatter
-          has-tooltip=""
+          withTooltip
           date-str="[[change.updated]]"
-          show-yesterday=""
+          showYesterday
         ></gr-date-formatter>
       </span>
     </section>
@@ -354,8 +354,8 @@
               ></gr-commit-info>
               <gr-tooltip-content
                 id="parentNotCurrentMessage"
-                has-tooltip=""
-                show-icon=""
+                has-tooltip
+                show-icon
                 title$="[[_notCurrentMessage]]"
               ></gr-tooltip-content>
             </li>
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 6a31c0a..414784c 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
@@ -17,7 +17,6 @@
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-label/gr-label';
 import '../../shared/gr-label-info/gr-label-info';
 import '../../shared/gr-limited-text/gr-limited-text';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
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 58a00f4..3396e13 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
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import '@polymer/paper-tabs/paper-tabs';
+import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
 import '../../diff/gr-comment-api/gr-comment-api';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
@@ -23,7 +24,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-change-star/gr-change-star';
 import '../../shared/gr-change-status/gr-change-status';
-import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-editable-content/gr-editable-content';
 import '../../shared/gr-linked-text/gr-linked-text';
 import '../../shared/gr-overlay/gr-overlay';
@@ -51,7 +51,7 @@
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {pluralize} from '../../../utils/string-util';
-import {windowLocationReload} from '../../../utils/dom-util';
+import {windowLocationReload, querySelectorAll} from '../../../utils/dom-util';
 import {
   GeneratedWebLink,
   GerritNav,
@@ -84,6 +84,7 @@
   isCc,
   isOwner,
   isReviewer,
+  isInvolved,
 } from '../../../utils/change-util';
 import {EventType as PluginEventType} from '../../../api/plugin';
 import {customElement, observe, property} from '@polymer/decorators';
@@ -188,6 +189,11 @@
   changeComments$,
   drafts$,
 } from '../../../services/comments/comments-model';
+import {
+  hasAttention,
+  getAddedByReason,
+  getRemovedByReason,
+} from '../../../utils/attention-set-util';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
@@ -579,6 +585,7 @@
       [Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
       [Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
       [Shortcut.OPEN_SUBMIT_DIALOG]: '_handleOpenSubmitDialog',
+      [Shortcut.TOGGLE_ATTENTION_SET]: '_handleToggleAttentionSet',
     };
   }
 
@@ -1172,6 +1179,9 @@
   _paramsChanged(value: AppElementChangeViewParams) {
     if (value.view !== GerritView.CHANGE) {
       this._initialLoadComplete = false;
+      querySelectorAll(this, 'gr-overlay').forEach(overlay =>
+        (overlay as GrOverlay).close()
+      );
       return;
     }
 
@@ -1523,6 +1533,48 @@
     this.$.actions.showSubmitDialog();
   }
 
+  _handleToggleAttentionSet(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    if (!this._change || !this._account?._account_id) return;
+    if (!this._loggedIn || !isInvolved(this._change, this._account)) return;
+    if (!this._change.attention_set) this._change.attention_set = {};
+    if (hasAttention(this._account, this._change)) {
+      const reason = getRemovedByReason(this._account, this._serverConfig);
+      if (this._change.attention_set)
+        delete this._change.attention_set[this._account._account_id];
+      fireAlert(this, 'Removing you from the attention set ...');
+      this.restApiService
+        .removeFromAttentionSet(
+          this._change._number,
+          this._account._account_id,
+          reason
+        )
+        .then(() => {
+          fireEvent(this, 'hide-alert');
+        });
+    } else {
+      const reason = getAddedByReason(this._account, this._serverConfig);
+      fireAlert(this, 'Adding you to the attention set ...');
+      this._change.attention_set[this._account._account_id!] = {
+        account: this._account,
+        reason,
+        reason_account: this._account,
+      };
+      this.restApiService
+        .addToAttentionSet(
+          this._change._number,
+          this._account._account_id,
+          reason
+        )
+        .then(() => {
+          fireEvent(this, 'hide-alert');
+        });
+    }
+    this._change = {...this._change};
+  }
+
   _handleDiffAgainstBase(e: CustomKeyboardEvent) {
     if (this.shouldSuppressKeyboardShortcut(e)) {
       return;
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 fb80822..d57aca8 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-a11y-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     .container:not(.loading) {
       background-color: var(--background-color-tertiary);
@@ -476,7 +479,7 @@
         class="commentThreads"
       >
         <gr-tooltip-content
-          has-tooltip=""
+          has-tooltip
           title$="[[_computeTotalCommentCounts(_change.unresolved_comment_count, _changeComments)]]"
         >
           <span>Comments</span></gr-tooltip-content
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 676e066..6668e15 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
@@ -59,6 +59,7 @@
   createAccountWithIdNameAndEmail,
   createChangeViewChange,
   createRelatedChangeAndCommitInfo,
+  createAccountDetailWithId,
 } from '../../../test/test-data-generators';
 import {ChangeViewPatchRange, GrChangeView} from './gr-change-view';
 import {
@@ -366,7 +367,9 @@
         },
       })
     );
-    stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    stubRestApi('getAccount').returns(
+      Promise.resolve(createAccountDetailWithId(5))
+    );
     stubRestApi('getDiffComments').returns(Promise.resolve({}));
     stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
     stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
@@ -505,6 +508,39 @@
     assert.isNotOk(args[2]);
   });
 
+  test('toggle attention set status', async () => {
+    element._change = {
+      ...createChangeViewChange(),
+      revisions: createRevisions(10),
+    };
+    const addToAttentionSetStub = stubRestApi('addToAttentionSet').returns(
+      Promise.resolve(new Response())
+    );
+
+    const removeFromAttentionSetStub = stubRestApi(
+      'removeFromAttentionSet'
+    ).returns(Promise.resolve(new Response()));
+    element._patchRange = {
+      basePatchNum: 1 as BasePatchSetNum,
+      patchNum: 3 as RevisionPatchSetNum,
+    };
+    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+
+    assert.isNotOk(element._change.attention_set);
+    await element._getLoggedIn();
+    await element.restApiService.getAccount();
+    element._handleToggleAttentionSet(
+      new CustomEvent('') as CustomKeyboardEvent
+    );
+    assert.isTrue(addToAttentionSetStub.called);
+    assert.isFalse(removeFromAttentionSetStub.called);
+
+    element._handleToggleAttentionSet(
+      new CustomEvent('') as CustomKeyboardEvent
+    );
+    assert.isTrue(removeFromAttentionSetStub.called);
+  });
+
   suite('plugins adding to file tab', () => {
     setup(async () => {
       element._changeNum = TEST_NUMERIC_CHANGE_ID;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
index 4fd42b0..07da53f 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
@@ -57,7 +57,7 @@
    */
 
   @property({type: String})
-  message?: string;
+  message = '';
 
   get keyBindings() {
     return {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.js
deleted file mode 100644
index 14d16f5..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.js
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-confirm-abandon-dialog.js';
-
-const basicFixture = fixtureFromElement('gr-confirm-abandon-dialog');
-
-suite('gr-confirm-abandon-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('_handleConfirmTap', () => {
-    const confirmHandler = sinon.stub();
-    element.addEventListener('confirm', confirmHandler);
-    sinon.spy(element, '_handleConfirmTap');
-    sinon.spy(element, '_confirm');
-    element.shadowRoot
-        .querySelector('gr-dialog').dispatchEvent(
-            new CustomEvent('confirm', {
-              composed: true, bubbles: true,
-            }));
-    assert.isTrue(confirmHandler.called);
-    assert.isTrue(confirmHandler.calledOnce);
-    assert.isTrue(element._handleConfirmTap.called);
-    assert.isTrue(element._confirm.called);
-    assert.isTrue(element._confirm.calledOnce);
-  });
-
-  test('_handleCancelTap', () => {
-    const cancelHandler = sinon.stub();
-    element.addEventListener('cancel', cancelHandler);
-    sinon.spy(element, '_handleCancelTap');
-    element.shadowRoot
-        .querySelector('gr-dialog').dispatchEvent(
-            new CustomEvent('cancel', {
-              composed: true, bubbles: true,
-            }));
-    assert.isTrue(cancelHandler.called);
-    assert.isTrue(cancelHandler.calledOnce);
-    assert.isTrue(element._handleCancelTap.called);
-    assert.isTrue(element._handleCancelTap.calledOnce);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.ts
new file mode 100644
index 0000000..08dda14
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.ts
@@ -0,0 +1,66 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-confirm-abandon-dialog';
+import {GrConfirmAbandonDialog} from './gr-confirm-abandon-dialog';
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+
+const basicFixture = fixtureFromElement('gr-confirm-abandon-dialog');
+
+suite('gr-confirm-abandon-dialog tests', () => {
+  let element: GrConfirmAbandonDialog;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_handleConfirmTap', () => {
+    const confirmHandler = sinon.stub();
+    element.addEventListener('confirm', confirmHandler);
+    const confirmTapSpy = sinon.spy(element, '_handleConfirmTap');
+    const confirmSpy = sinon.spy(element, '_confirm');
+    queryAndAssert<GrDialog>(element, 'gr-dialog').dispatchEvent(
+      new CustomEvent('confirm', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+    assert.isTrue(confirmHandler.called);
+    assert.isTrue(confirmHandler.calledOnce);
+    assert.isTrue(confirmTapSpy.called);
+    assert.isTrue(confirmSpy.called);
+    assert.isTrue(confirmSpy.calledOnce);
+  });
+
+  test('_handleCancelTap', () => {
+    const cancelHandler = sinon.stub();
+    element.addEventListener('cancel', cancelHandler);
+    const cancelTapSpy = sinon.spy(element, '_handleCancelTap');
+    queryAndAssert<GrDialog>(element, 'gr-dialog').dispatchEvent(
+      new CustomEvent('cancel', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+    assert.isTrue(cancelHandler.called);
+    assert.isTrue(cancelHandler.calledOnce);
+    assert.isTrue(cancelTapSpy.called);
+    assert.isTrue(cancelTapSpy.calledOnce);
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index fbf57f3..b061043 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -25,15 +25,14 @@
 import {appContext} from '../../../services/app-context';
 import {
   ChangeInfo,
-  BranchInfo,
-  RepoName,
   BranchName,
+  RepoName,
   CommitId,
   ChangeInfoId,
 } from '../../../types/common';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {customElement, property, observe} from '@polymer/decorators';
-import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrTypedAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {HttpMethod, ChangeStatus} from '../../../constants/constants';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {fireEvent} from '../../../utils/event-util';
@@ -68,7 +67,7 @@
 
 export interface GrConfirmCherrypickDialog {
   $: {
-    branchInput: HTMLElement;
+    branchInput: GrTypedAutocomplete<BranchName>;
   };
 }
 
@@ -91,7 +90,7 @@
    */
 
   @property({type: String})
-  branch?: BranchName;
+  branch = '' as BranchName;
 
   @property({type: String})
   baseCommit?: string;
@@ -106,7 +105,7 @@
   commitNum?: CommitId;
 
   @property({type: String})
-  message?: string;
+  message = '';
 
   @property({type: String})
   project?: RepoName;
@@ -115,7 +114,7 @@
   changes: ChangeInfo[] = [];
 
   @property({type: Object})
-  _query: (input: string) => Promise<AutocompleteSuggestion[]>;
+  _query?: (input: string) => Promise<{name: BranchName}[]>;
 
   @property({type: Boolean})
   _showCherryPickTopic = false;
@@ -249,7 +248,7 @@
     cherryPickType: CherryPickType,
     duplicateProjectChanges: boolean,
     statuses: Statuses,
-    branch?: BranchName
+    branch: BranchName
   ) {
     if (!branch) return true;
     const duplicateProject =
@@ -398,29 +397,22 @@
     this.$.branchInput.focus();
   }
 
-  _getProjectBranchesSuggestions(
-    input: string
-  ): Promise<AutocompleteSuggestion[]> {
-    if (!this.project) {
-      this.reporting.error(new Error('no project specified'));
-      return Promise.resolve([]);
-    }
+  _getProjectBranchesSuggestions(input: string) {
+    if (!this.project) return Promise.reject(new Error('Missing project'));
     if (input.startsWith('refs/heads/')) {
       input = input.substring('refs/heads/'.length);
     }
     return this.restApiService
       .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
-      .then((response: BranchInfo[] | undefined) => {
+      .then(response => {
         if (!response) return [];
-        const branches = [];
+        const branches: Array<{name: BranchName}> = [];
         for (const branchInfo of response) {
-          let branch;
-          if (branchInfo.ref.startsWith('refs/heads/')) {
-            branch = branchInfo.ref.substring('refs/heads/'.length);
-          } else {
-            branch = branchInfo.ref;
+          let name: string = branchInfo.ref;
+          if (name.startsWith('refs/heads/')) {
+            name = name.substring('refs/heads/'.length);
           }
-          branches.push({name: branch});
+          branches.push({name: name as BranchName});
         }
         return branches;
       });
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
index ff078f0..1256cc1 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
@@ -115,10 +115,10 @@
         current_revision: 'a',
       },
     ];
-    setup(() => {
+    setup(async () => {
       element.updateChanges(changes);
       element._cherryPickType = CHERRY_PICK_TYPES.TOPIC;
-      flush();
+      await flush();
     });
 
     test('cherry pick topic submit', async () => {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
index c196706..8e6521d 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
@@ -21,15 +21,23 @@
 import {htmlTemplate} from './gr-confirm-move-dialog_html';
 import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {customElement, property} from '@polymer/decorators';
-import {RepoName, BranchName} from '../../../types/common';
-import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {BranchName, RepoName} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
+import {GrTypedAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 
 const SUGGESTIONS_LIMIT = 15;
 
 // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
 const base = KeyboardShortcutMixin(PolymerElement);
 
+// This is used to make sure 'branch'
+// can be typed as BranchName.
+export interface GrConfirmMoveDialog {
+  $: {
+    branchInput: GrTypedAutocomplete<BranchName>;
+  };
+}
+
 @customElement('gr-confirm-move-dialog')
 export class GrConfirmMoveDialog extends base {
   static get template() {
@@ -49,16 +57,16 @@
    */
 
   @property({type: String})
-  branch?: BranchName;
+  branch = '' as BranchName;
 
   @property({type: String})
-  message?: string;
+  message = '';
 
   @property({type: String})
   project?: RepoName;
 
   @property({type: Object})
-  _query: (input: string) => Promise<AutocompleteSuggestion[]>;
+  _query?: (input: string) => Promise<{name: BranchName}[]>;
 
   get keyBindings() {
     return {
@@ -95,9 +103,7 @@
     );
   }
 
-  _getProjectBranchesSuggestions(
-    input: string
-  ): Promise<AutocompleteSuggestion[]> {
+  _getProjectBranchesSuggestions(input: string) {
     if (!this.project) return Promise.reject(new Error('Missing project'));
     if (input.startsWith('refs/heads/')) {
       input = input.substring('refs/heads/'.length);
@@ -105,21 +111,15 @@
     return this.restApiService
       .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
       .then(response => {
-        const branches: AutocompleteSuggestion[] = [];
-        let branch;
-        if (response) {
-          response.forEach(value => {
-            if (value.ref.startsWith('refs/heads/')) {
-              branch = value.ref.substring('refs/heads/'.length);
-            } else {
-              branch = value.ref;
-            }
-            branches.push({
-              name: branch,
-            });
-          });
+        if (!response) return [];
+        const branches: Array<{name: BranchName}> = [];
+        for (const branchInfo of response) {
+          let name: string = branchInfo.ref;
+          if (name.startsWith('refs/heads/')) {
+            name = name.substring('refs/heads/'.length);
+          }
+          branches.push({name: name as BranchName});
         }
-
         return branches;
       });
   }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.ts
similarity index 75%
rename from polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js
rename to polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.ts
index 10844f5..ea5d320 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.ts
@@ -15,35 +15,37 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-confirm-move-dialog.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import '../../../test/common-test-setup-karma';
+import './gr-confirm-move-dialog';
+import {GrConfirmMoveDialog} from './gr-confirm-move-dialog';
+import {stubRestApi} from '../../../test/test-utils';
+import {BranchName, GitRef, RepoName} from '../../../types/common';
 
 const basicFixture = fixtureFromElement('gr-confirm-move-dialog');
 
 suite('gr-confirm-move-dialog tests', () => {
-  let element;
+  let element: GrConfirmMoveDialog;
 
   setup(() => {
-    stubRestApi('getRepoBranches').callsFake(input => {
+    stubRestApi('getRepoBranches').callsFake((input: string) => {
       if (input.startsWith('test')) {
         return Promise.resolve([
           {
-            ref: 'refs/heads/test-branch',
+            ref: 'refs/heads/test-branch' as GitRef,
             revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
             can_delete: true,
           },
         ]);
       } else {
-        return Promise.resolve(undefined);
+        return Promise.resolve([]);
       }
     });
     element = basicFixture.instantiate();
-    element.project = 'test-project';
+    element.project = 'test-repo' as RepoName;
   });
 
   test('with updated commit message', () => {
-    element.branch = 'master';
+    element.branch = 'master' as BranchName;
     const myNewMessage = 'updated commit message';
     element.message = myNewMessage;
     flush();
@@ -52,13 +54,15 @@
 
   test('_getProjectBranchesSuggestions empty', async () => {
     const branches = await element._getProjectBranchesSuggestions(
-        'nonexistent');
+      'nonexistent'
+    );
     assert.equal(branches.length, 0);
   });
 
   test('_getProjectBranchesSuggestions non-empty', async () => {
     const branches = await element._getProjectBranchesSuggestions(
-        'test-branch');
+      'test-branch'
+    );
     assert.equal(branches.length, 1);
     assert.equal(branches[0].name, 'test-branch');
   });
@@ -68,4 +72,3 @@
     assert.equal(branches.length, 0);
   });
 });
-
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
index bceee27..85f0330 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
@@ -180,50 +180,61 @@
           hidden$="[[_computePrefsButtonHidden(diffPrefs, diffPrefsDisabled)]]"
           hidden=""
         >
-          <gr-button
-            link=""
-            has-tooltip=""
+          <gr-tooltip-content
+            has-tooltip
             title="Diff preferences"
-            class="prefsButton desktop"
-            on-click="_handlePrefsTap"
-            ><iron-icon icon="gr-icons:settings"></iron-icon
-          ></gr-button>
+          >
+            <gr-button
+              link=""
+
+              class="prefsButton desktop"
+              on-click="_handlePrefsTap"
+              ><iron-icon icon="gr-icons:settings"></iron-icon
+            ></gr-button>
+          </gr-tooltip-content>
         </span>
         <span class="separator"></span>
       </div>
       <span class="downloadContainer desktop">
-        <gr-button
-          link=""
-          class="download"
+        <gr-tooltip-content
+          has-tooltip
           title="[[createTitle(Shortcut.OPEN_DOWNLOAD_DIALOG,
-                ShortcutSection.ACTIONS)]]"
-          has-tooltip=""
-          on-click="_handleDownloadTap"
-          >Download</gr-button
+                   ShortcutSection.ACTIONS)]]"
         >
+          <gr-button
+            link=""
+            class="download"
+            on-click="_handleDownloadTap"
+            >Download</gr-button
+          >
+        </gr-tooltip-content>
       </span>
       <template
         is="dom-if"
         if="[[_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]"
       >
-        <gr-button
-          id="expandBtn"
-          link=""
-          title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
-                ShortcutSection.FILE_LIST)]]"
-          has-tooltip=""
-          on-click="_expandAllDiffs"
-          >Expand All</gr-button
-        >
-        <gr-button
-          id="collapseBtn"
-          link=""
-          on-click="_collapseAllDiffs"
-          title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
-          ShortcutSection.FILE_LIST)]]"
-          has-tooltip=""
-          >Collapse All</gr-button
-        >
+        <gr-tooltip-content
+            has-tooltip
+            title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
+                  ShortcutSection.FILE_LIST)]]">
+          <gr-button
+            id="expandBtn"
+            link=""
+
+            on-click="_expandAllDiffs"
+            >Expand All</gr-button
+          >
+        <gr-tooltip-content
+            has-tooltip
+            title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
+                  ShortcutSection.FILE_LIST)]]">
+          <gr-button
+            id="collapseBtn"
+            link=""
+            on-click="_collapseAllDiffs"
+            >Collapse All</gr-button
+          >
+        </gr-tooltip-content>
       </template>
       <template
         is="dom-if"
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
index 08e5e3b..c90cfcc 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
@@ -90,26 +90,26 @@
     assert.isFalse(computeSpy.lastCall.returnValue);
   });
 
-  test('fileViewActions are properly hidden', () => {
+  test('fileViewActions are properly hidden', async () => {
     const actions = element.shadowRoot
         .querySelector('.fileViewActions');
     assert.equal(getComputedStyle(actions).display, 'none');
     element.filesExpanded = FilesExpandedState.SOME;
-    flush();
+    await flush();
     assert.notEqual(getComputedStyle(actions).display, 'none');
     element.filesExpanded = FilesExpandedState.ALL;
-    flush();
+    await flush();
     assert.notEqual(getComputedStyle(actions).display, 'none');
     element.filesExpanded = FilesExpandedState.NONE;
-    flush();
+    await flush();
     assert.equal(getComputedStyle(actions).display, 'none');
   });
 
-  test('expand/collapse buttons are toggled correctly', () => {
+  test('expand/collapse buttons are toggled correctly', async () => {
     // Only the expand button should be visible in the initial state when
     // NO files are expanded.
     element.shownFileCount = 10;
-    flush();
+    await flush();
     const expandBtn = element.shadowRoot.querySelector('#expandBtn');
     const collapseBtn = element.shadowRoot.querySelector('#collapseBtn');
     assert.notEqual(getComputedStyle(expandBtn).display, 'none');
@@ -118,19 +118,19 @@
     // Both expand and collapse buttons should be visible when SOME files are
     // expanded.
     element.filesExpanded = FilesExpandedState.SOME;
-    flush();
+    await flush();
     assert.notEqual(getComputedStyle(expandBtn).display, 'none');
     assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
 
     // Only the collapse button should be visible when ALL files are expanded.
     element.filesExpanded = FilesExpandedState.ALL;
-    flush();
+    await flush();
     assert.equal(getComputedStyle(expandBtn).display, 'none');
     assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
 
     // Only the expand button should be visible when NO files are expanded.
     element.filesExpanded = FilesExpandedState.NONE;
-    flush();
+    await flush();
     assert.notEqual(getComputedStyle(expandBtn).display, 'none');
     assert.equal(getComputedStyle(collapseBtn).display, 'none');
   });
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 8d0c203..b8f01de 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
@@ -14,6 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
 import '../../diff/gr-diff-cursor/gr-diff-cursor';
 import '../../diff/gr-diff-host/gr-diff-host';
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 4d04744..35f01d8 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-a11y-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       display: block;
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 5def25a..4acf245 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
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import '../../diff/gr-comment-api/gr-comment-api.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
 import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import './gr-file-list.js';
 import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api.js';
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts
index f9d21d9..421cd6e 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts
@@ -53,25 +53,27 @@
       );
       padding: 0 var(--spacing-m);
     }
-    gr-button.iron-selected[vote='max'] {
+    gr-tooltip-content.iron-selected > gr-button[vote='max'] {
       --button-background-color: var(--vote-color-approved);
     }
-    gr-button.iron-selected[vote='positive'] {
+    gr-tooltip-content.iron-selected > gr-buttonvote='positive'] {
       --button-background-color: var(--vote-color-recommended);
     }
-    gr-button.iron-selected[vote='min'] {
+    gr-tooltip-content.iron-selected > gr-button[vote='min'] {
       --button-background-color: var(--vote-color-rejected);
     }
-    gr-button.iron-selected[vote='negative'] {
+    gr-tooltip-content.iron-selected > gr-button[vote='negative'] {
       --button-background-color: var(--vote-color-disliked);
     }
-    gr-button.iron-selected[vote='neutral'] {
+    gr-tooltip-content.iron-selected > gr-button[vote='neutral'] {
       --button-background-color: var(--vote-color-neutral);
     }
-    gr-button.iron-selected[vote='positive']::part(paper-button) {
+    gr-tooltip-content.iron-selected
+      > gr-button[vote='positive']::part(paper-button) {
       border-color: var(--vote-outline-recommended);
     }
-    gr-button.iron-selected[vote='negative']::part(paper-button) {
+    gr-tooltip-content.iron-selected
+      > gr-button[vote='negative']::part(paper-button) {
       border-color: var(--vote-outline-disliked);
     }
     .placeholder {
@@ -116,17 +118,20 @@
       aria-labelledby="labelName"
     >
       <template is="dom-repeat" items="[[_items]]" as="value">
-        <gr-button
-          role="radio"
-          vote$="[[_computeVoteAttribute(value, index, _items.length)]]"
-          vote-chip
-          has-tooltip=""
+        <gr-tooltip-content
+          has-tooltip
+          title$="[[_computeLabelValueTitle(labels, label.name, value)]]"
           data-name$="[[label.name]]"
           data-value$="[[value]]"
-          title$="[[_computeLabelValueTitle(labels, label.name, value)]]"
         >
-          [[value]]</gr-button
-        >
+          <gr-button
+            role="radio"
+            vote="[[_computeVoteAttribute(value, index, _items.length)]]"
+            voteChip
+          >
+            [[value]]
+          </gr-button>
+        </gr-tooltip-content>
       </template>
     </iron-selector>
     <template
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
index e7ff236..34e959b 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
@@ -97,14 +97,14 @@
     }
   }
 
-  test('label picker', () => {
+  test('label picker', async () => {
     const labelsChangedHandler = sinon.stub();
     element.addEventListener('labels-changed', labelsChangedHandler);
     assert.ok(element.$.labelSelector);
     MockInteractions.tap(element.shadowRoot
         .querySelector(
-            'gr-button[data-value="-1"]'));
-    flush();
+            'gr-tooltip-content[data-value="-1"] > gr-button'));
+    await flush();
     assert.strictEqual(element.selectedValue, '-1');
     assert.strictEqual(element.selectedItem
         .textContent.trim(), '-1');
@@ -160,26 +160,6 @@
     checkAriaCheckedValid();
   });
 
-  test('do not display tooltips on touch devices', () => {
-    const verifiedBtn = element.shadowRoot
-        .querySelector(
-            'iron-selector > gr-button[data-value="-1"]');
-
-    // On touch devices, tooltips should not be shown.
-    verifiedBtn._isTouchDevice = true;
-    verifiedBtn._handleShowTooltip();
-    assert.isNotOk(verifiedBtn._tooltip);
-    verifiedBtn._handleHideTooltip();
-    assert.isNotOk(verifiedBtn._tooltip);
-
-    // On other devices, tooltips should be shown.
-    verifiedBtn._isTouchDevice = false;
-    verifiedBtn._handleShowTooltip();
-    assert.isOk(verifiedBtn._tooltip);
-    verifiedBtn._handleHideTooltip();
-    assert.isNotOk(verifiedBtn._tooltip);
-  });
-
   test('_computeLabelValue', () => {
     assert.strictEqual(element._computeLabelValue(element.labels,
         element.permittedLabels,
@@ -209,7 +189,7 @@
         'Code-Review'), []);
   });
 
-  test('changes in label score are reflected in the DOM', () => {
+  test('changes in label score are reflected in the DOM', async () => {
     element.labels = {
       'Code-Review': {
         values: {
@@ -232,9 +212,10 @@
         default_value: 0,
       },
     };
+    await flush();
     const selector = element.$.labelSelector;
     element.set('label', {name: 'Verified', value: ' 0'});
-    flush();
+    await flush();
     assert.strictEqual(selector.selected, ' 0');
     assert.strictEqual(
         element.$.selectedValueLabel.textContent.trim(), 'No score');
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
index cfecf91..f529464 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
@@ -85,7 +85,7 @@
     await flush();
   });
 
-  test('get and set label scores', () => {
+  test('get and set label scores', async () => {
     for (const label of Object.keys(element.permittedLabels!)) {
       const row = queryAndAssert<GrLabelScoreRow>(
         element,
@@ -93,6 +93,7 @@
       );
       row.setSelectedValue('-1');
     }
+    await flush();
     assert.deepEqual(element.getLabelValues(), {
       'Code-Review': -1,
       Verified: -1,
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 ddbd22e..c9680ef 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
@@ -325,8 +325,8 @@
         <template is="dom-if" if="[[!message.id]]">
           <span class="date">
             <gr-date-formatter
-              has-tooltip=""
-              show-date-and-time=""
+              withTooltip
+              showDateAndTime
               date-str="[[message.date]]"
             ></gr-date-formatter>
           </span>
@@ -334,8 +334,8 @@
         <template is="dom-if" if="[[message.id]]">
           <span class="date" on-click="_handleAnchorClick">
             <gr-date-formatter
-              has-tooltip=""
-              show-date-and-time=""
+              withTooltip
+              showDateAndTime
               date-str="[[message.date]]"
             ></gr-date-formatter>
           </span>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
index 933cb821..b8c9319 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
@@ -127,7 +127,7 @@
     const labelScoreRows = element.getLabelScores().shadowRoot
         .querySelector('gr-label-score-row[name="Code-Review"]');
     const selectedBtn = labelScoreRows.shadowRoot
-        .querySelector('gr-button[data-value="+1"].iron-selected');
+        .querySelector('gr-tooltip-content[data-value="+1"] > gr-button');
     assert.isOk(selectedBtn);
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index b571985..1973fe6 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -441,23 +441,26 @@
                 ></gr-account-label>
               </template>
             </template>
-            <gr-button
-              class="edit-attention-button"
-              on-click="_handleAttentionModify"
-              disabled="[[_sendDisabled]]"
-              link=""
-              position-below=""
-              data-label="Edit"
-              data-action-type="change"
-              data-action-key="edit"
-              has-tooltip=""
+            <gr-tooltip-content
+              has-tooltip
               title="[[_computeAttentionButtonTitle(_sendDisabled)]]"
-              role="button"
-              tabindex="0"
             >
-              <iron-icon icon="gr-icons:edit"></iron-icon>
-              Modify
-            </gr-button>
+              <gr-button
+                class="edit-attention-button"
+                on-click="_handleAttentionModify"
+                disabled="[[_sendDisabled]]"
+                link=""
+                position-below=""
+                data-label="Edit"
+                data-action-type="change"
+                data-action-key="edit"
+                role="button"
+                tabindex="0"
+              >
+                <iron-icon icon="gr-icons:edit"></iron-icon>
+                Modify
+              </gr-button>
+            </gr-tooltip-content>
           </div>
           <div>
             <a
@@ -612,26 +615,32 @@
             <!-- Use 'Send' here as the change may only about reviewers / ccs
                 and when this button is visible, the next button will always
                 be 'Start review' -->
-            <gr-button
-              link=""
-              disabled="[[_isState(knownLatestState, 'not-latest')]]"
-              class="action save"
+            <gr-tooltip-content
               has-tooltip=""
-              title="[[_saveTooltip]]"
-              on-click="_saveClickHandler"
-              >Send As WIP</gr-button
+              title$="[[_saveTooltip]]"
             >
+              <gr-button
+                link=""
+                disabled="[[_isState(knownLatestState, 'not-latest')]]"
+                class="action save"
+                on-click="_saveClickHandler"
+                >Send As WIP</gr-button
+              >
+            </gr-tooltip-content>
           </template>
-          <gr-button
-            id="sendButton"
-            primary=""
-            disabled="[[_sendDisabled]]"
-            class="action send"
+          <gr-tooltip-content
             has-tooltip=""
             title$="[[_computeSendButtonTooltip(canBeStarted, _commentEditing)]]"
-            on-click="_sendTapHandler"
-            >[[_sendButtonLabel]]</gr-button
           >
+            <gr-button
+              id="sendButton"
+              primary=""
+              disabled="[[_sendDisabled]]"
+              class="action send"
+              on-click="_sendTapHandler"
+              >[[_sendButtonLabel]]
+            </gr-button>
+          </gr-tooltip-content>
         </div>
       </section>
     </div>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index e3bbd9e..e57ffc7 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -116,7 +116,7 @@
     return {id: `${lastId++}` as GroupId};
   };
 
-  setup(() => {
+  setup(async () => {
     changeNum = 42 as NumericChangeId;
     patchNum = 1 as PatchSetNum;
 
@@ -168,7 +168,7 @@
     //     .returns(Promise.resolve({isLatest: true}));
 
     // Allow the elements created by dom-repeat to be stamped.
-    flush();
+    await flush();
   });
 
   function stubSaveReview(
@@ -216,6 +216,7 @@
     // which the dom-repeat elements are stamped.
     await flush();
     tap(queryAndAssert(element, '.send'));
+    await flush();
 
     const review = await saveReviewPromise;
     assert.deepEqual(review, {
@@ -1063,6 +1064,7 @@
     const label = 'Verified';
     const value = '+1';
     element.setLabelValue(label, value);
+    await flush();
 
     const labels = (
       queryAndAssert(element, '#labelScores') as GrLabelScores
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
index fa590a3..fd85117 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
@@ -16,7 +16,7 @@
  */
 import '../gr-submit-requirement-hovercard/gr-submit-requirement-hovercard';
 import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators';
+import {customElement, property, state} from 'lit/decorators';
 import {ParsedChangeInfo} from '../../../types/types';
 import {
   AccountInfo,
@@ -32,7 +32,14 @@
   iconForStatus,
 } from '../../../utils/label-util';
 import {fontStyles} from '../../../styles/gr-font-styles';
-import {charsOnly} from '../../../utils/string-util';
+import {charsOnly, pluralize} from '../../../utils/string-util';
+import {subscribe} from '../../lit/subscription-controller';
+import {
+  allRunsLatestPatchsetLatestAttempt$,
+  CheckRun,
+} from '../../../services/checks/checks-model';
+import {getResultsOf, hasResultsOf} from '../../../services/checks/checks-util';
+import {Category} from '../../../api/checks';
 
 @customElement('gr-submit-requirements')
 export class GrSubmitRequirements extends LitElement {
@@ -45,6 +52,9 @@
   @property({type: Boolean})
   mutable?: boolean;
 
+  @state()
+  runs: CheckRun[] = [];
+
   static override get styles() {
     return [
       fontStyles,
@@ -95,10 +105,25 @@
         td {
           padding: var(--spacing-s);
         }
+        .votes-cell {
+          display: flex;
+        }
+        .check-error {
+          margin-right: var(--spacing-l);
+        }
+        .check-error iron-icon {
+          color: var(--error-foreground);
+          vertical-align: top;
+        }
       `,
     ];
   }
 
+  constructor() {
+    super();
+    subscribe(this, allRunsLatestPatchsetLatestAttempt$, x => (this.runs = x));
+  }
+
   override render() {
     const submit_requirements = (this.change?.submit_requirements ?? []).filter(
       req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE
@@ -130,7 +155,12 @@
                   .text="${requirement.name}"
                 ></gr-limited-text>
               </td>
-              <td>${this.renderVotes(requirement)}</td>
+              <td>
+                <div class="votes-cell">
+                  ${this.renderVotes(requirement)}
+                  ${this.renderChecks(requirement)}
+                </div>
+              </td>
             </tr>`
           )}
         </tbody>
@@ -199,6 +229,28 @@
     );
   }
 
+  renderChecks(requirement: SubmitRequirementResultInfo) {
+    const requirementLabels = extractAssociatedLabels(requirement);
+    const requirementRuns = this.runs
+      .filter(run => hasResultsOf(run, Category.ERROR))
+      .filter(
+        run => run.labelName && requirementLabels.includes(run.labelName)
+      );
+    const runsCount = requirementRuns.reduce(
+      (sum, run) => sum + getResultsOf(run, Category.ERROR).length,
+      0
+    );
+    if (runsCount > 0) {
+      return html`<span class="check-error"
+        ><iron-icon icon="gr-icons:error"></iron-icon>${pluralize(
+          runsCount,
+          'error'
+        )}</span
+      >`;
+    }
+    return;
+  }
+
   renderTriggerVotes(submitReqs: SubmitRequirementResultInfo[]) {
     const labels = this.change?.labels ?? {};
     const allLabels = Object.keys(labels);
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 90f7215..68c7957 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -279,11 +279,10 @@
     ];
   }
 
-  override update(changedProperties: PropertyValues) {
+  override updated(changedProperties: PropertyValues) {
     if (changedProperties.has('result')) {
       this.isExpandable = !!this.result?.summary && !!this.result?.message;
     }
-    super.update(changedProperties);
   }
 
   override focus() {
@@ -978,6 +977,7 @@
   override render() {
     // To pass CSS mixins for @apply to Polymer components, they need to appear
     // in <style> inside the template.
+    /* eslint-disable lit/prefer-static-styles */
     const style = html`<style>
       .headerTopRow .right .goToLatest gr-button {
         --gr-button: {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 28354d2..76fa353 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -303,9 +303,6 @@
   renderStatusLink() {
     const link = this.run.statusLink;
     if (!link) return;
-    // For COMPLETED we think that the status link are too much clutter.
-    // That could be re-considered.
-    if (this.run.status !== RunStatus.RUNNING) return;
     return html`
       <a href="${link}" target="_blank" @click="${this.onLinkClick}"
         ><iron-icon
@@ -604,42 +601,48 @@
         @click="${() => fireRunSelectionReset(this)}"
         >Unselect All</gr-button
       >
-      <gr-button
-        class="font-normal"
-        link
+      <gr-tooltip-content
         title="${runButtonDisabled
           ? 'Disabled. Unselect checks without a "Run" action to enable the button.'
           : ''}"
-        has-tooltip="${runButtonDisabled}"
-        ?disabled="${runButtonDisabled}"
-        @click="${() => {
-          actions.forEach(action => this.checksService.triggerAction(action));
-        }}"
-        >Run Selected</gr-button
+        ?has-tooltip=${runButtonDisabled}
       >
+        <gr-button
+          class="font-normal"
+          link
+          ?disabled=${runButtonDisabled}
+          @click="${() => {
+            actions.forEach(action => this.checksService.triggerAction(action));
+          }}"
+          >Run Selected</gr-button
+        >
+      </gr-tooltip-content>
     `;
   }
 
   private renderCollapseButton() {
     return html`
-      <gr-button
-        link
-        class="expandButton"
-        role="switch"
-        ?aria-checked="${this.collapsed}"
-        aria-label="${this.collapsed
-          ? 'Expand runs panel'
-          : 'Collapse runs panel'}"
-        has-tooltip="true"
+      <gr-tooltip-content
+        has-tooltip
         title="${this.collapsed ? 'Expand runs panel' : 'Collapse runs panel'}"
-        @click="${() => (this.collapsed = !this.collapsed)}"
-        ><iron-icon
-          class="expandIcon"
-          icon="${this.collapsed
-            ? 'gr-icons:chevron-right'
-            : 'gr-icons:chevron-left'}"
-        ></iron-icon>
-      </gr-button>
+      >
+        <gr-button
+          link
+          class="expandButton"
+          role="switch"
+          ?aria-checked="${this.collapsed}"
+          aria-label="${this.collapsed
+            ? 'Expand runs panel'
+            : 'Collapse runs panel'}"
+          @click="${() => (this.collapsed = !this.collapsed)}"
+          ><iron-icon
+            class="expandIcon"
+            icon="${this.collapsed
+              ? 'gr-icons:chevron-right'
+              : 'gr-icons:chevron-left'}"
+          ></iron-icon>
+        </gr-button>
+      </gr-tooltip-content>
     `;
   }
 
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
index 1517d36..c2066d7 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
@@ -95,6 +95,7 @@
   override render() {
     // To pass CSS mixins for @apply to Polymer components, they need to appear
     // in <style> inside the template.
+    /* eslint-disable lit/prefer-static-styles */
     const customStyle = html`
       <style>
         gr-dropdown {
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 4a6f009..8e70eb9 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -177,8 +177,8 @@
 
   CHANGE_ID_QUERY: /^\/id\/(I[0-9a-f]{40})$/,
 
-  // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>][/].
-  CHANGE_LEGACY: /^\/c\/(\d+)\/?(((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
+  // Matches /c/<changeNum>/[*][/].
+  CHANGE_LEGACY: /^\/c\/(\d+)\/(.*)$/,
   CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
 
   // Matches
@@ -210,10 +210,6 @@
   // Matches /c/<project>/+/<changeNum>/[<patchNum|edit>]/<path>,edit[#lineNum]
   DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)\/(\d+|edit)\/(.+),edit(#\d+)?$/,
 
-  // Matches non-project-relative
-  // /c/<changeNum>/[<basePatchNum>..]<patchNum>/<path>.
-  DIFF_LEGACY: /^\/c\/(\d+)\/((-?\d+|edit)(\.\.(\d+|edit))?)\/(.+)/,
-
   // Matches diff routes using @\d+ to specify a file name (whether or not
   // the project name is included).
   // eslint-disable-next-line max-len
@@ -287,15 +283,6 @@
 
 type QueryStringItem = [string, string]; // [key, value]
 
-type GenerateUrlLegacyChangeViewParameters = Omit<
-  GenerateUrlChangeViewParameters,
-  'project'
->;
-type GenerateUrlLegacyDiffViewParameters = Omit<
-  GenerateUrlDiffViewParameters,
-  'project'
->;
-
 interface PatchRangeParams {
   patchNum?: PatchSetNum;
   basePatchNum?: BasePatchSetNum;
@@ -679,37 +666,6 @@
   }
 
   /**
-   * Given a set of params without a project, gets the project from the rest
-   * API project lookup and then sets the app params.
-   */
-  _normalizeLegacyRouteParams(
-    params: Readonly<
-      | GenerateUrlLegacyChangeViewParameters
-      | GenerateUrlLegacyDiffViewParameters
-    >
-  ) {
-    if (!params.changeNum) {
-      return Promise.resolve();
-    }
-
-    return this.restApiService
-      .getFromProjectLookup(params.changeNum)
-      .then(project => {
-        // Show a 404 and terminate if the lookup request failed. Attempting
-        // to redirect after failing to get the project loops infinitely.
-        if (!project) {
-          this._show404();
-          return;
-        }
-        const updatedParams:
-          | GenerateUrlChangeViewParameters
-          | GenerateUrlDiffViewParameters = {...params, project};
-        this._normalizePatchRangeParams(updatedParams);
-        this._redirect(this._generateUrl(updatedParams));
-      });
-  }
-
-  /**
    * Normalizes the params object, and determines if the URL needs to be
    * modified to fit the proper schema.
    *
@@ -1100,8 +1056,6 @@
 
     this._mapRoute(RoutePattern.CHANGE_LEGACY, '_handleChangeLegacyRoute');
 
-    this._mapRoute(RoutePattern.DIFF_LEGACY, '_handleDiffLegacyRoute');
-
     this._mapRoute(RoutePattern.AGREEMENTS, '_handleAgreementsRoute', true);
 
     this._mapRoute(
@@ -1666,41 +1620,26 @@
   }
 
   _handleChangeLegacyRoute(ctx: PageContextWithQueryMap) {
-    // Parameter order is based on the regex group number matched.
-    const params: GenerateUrlLegacyChangeViewParameters = {
-      changeNum: Number(ctx.params[0]) as NumericChangeId,
-      basePatchNum: convertToPatchSetNum(ctx.params[3]) as BasePatchSetNum,
-      patchNum: convertToPatchSetNum(ctx.params[5]),
-      view: GerritView.CHANGE,
-      querystring: ctx.querystring,
-    };
-
-    this._normalizeLegacyRouteParams(params);
+    const changeNum = Number(ctx.params[0]) as NumericChangeId;
+    if (!changeNum) {
+      this._show404();
+      return;
+    }
+    this.restApiService.getFromProjectLookup(changeNum).then(project => {
+      // Show a 404 and terminate if the lookup request failed. Attempting
+      // to redirect after failing to get the project loops infinitely.
+      if (!project) {
+        this._show404();
+        return;
+      }
+      this._redirect(`/c/${project}/+/${changeNum}/${ctx.params[1]}`);
+    });
   }
 
   _handleLegacyLinenum(ctx: PageContextWithQueryMap) {
     this._redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
   }
 
-  _handleDiffLegacyRoute(ctx: PageContextWithQueryMap) {
-    // Parameter order is based on the regex group number matched.
-    const params: GenerateUrlLegacyDiffViewParameters = {
-      changeNum: Number(ctx.params[0]) as NumericChangeId,
-      basePatchNum: convertToPatchSetNum(ctx.params[2]) as BasePatchSetNum,
-      patchNum: convertToPatchSetNum(ctx.params[4]),
-      path: ctx.params[5],
-      view: GerritView.DIFF,
-    };
-
-    const address = this._parseLineAddress(ctx.hash);
-    if (address) {
-      params.leftSide = address.leftSide;
-      params.lineNum = address.lineNum;
-    }
-
-    this._normalizeLegacyRouteParams(params);
-  }
-
   _handleDiffEditRoute(ctx: PageContextWithQueryMap) {
     // Parameter order is based on the regex group number matched.
     const project = ctx.params[0] as RepoName;
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
index 4c7855b..b91bf0c 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.js
@@ -192,7 +192,6 @@
       '_handleDiffRoute',
       '_handleDefaultRoute',
       '_handleChangeLegacyRoute',
-      '_handleDiffLegacyRoute',
       '_handleDocumentationRedirectRoute',
       '_handleDocumentationSearchRoute',
       '_handleDocumentationSearchRedirectRoute',
@@ -536,66 +535,6 @@
   });
 
   suite('param normalization', () => {
-    let projectLookupStub;
-    let generateUrlStub;
-
-    setup(() => {
-      projectLookupStub = stubRestApi('getFromProjectLookup');
-      generateUrlStub = sinon.stub(element, '_generateUrl');
-    });
-
-    suite('_normalizeLegacyRouteParams', () => {
-      let rangeStub;
-      let redirectStub;
-      let show404Stub;
-
-      setup(() => {
-        rangeStub = sinon.stub(element, '_normalizePatchRangeParams')
-            .returns(Promise.resolve());
-        redirectStub = sinon.stub(element, '_redirect');
-        show404Stub = sinon.stub(element, '_show404');
-      });
-
-      test('w/o changeNum', () => {
-        projectLookupStub.returns(Promise.resolve('foo/bar'));
-        const params = {};
-        return element._normalizeLegacyRouteParams(params).then(() => {
-          assert.isFalse(generateUrlStub.calledOnce);
-          assert.isFalse(projectLookupStub.called);
-          assert.isFalse(rangeStub.called);
-          assert.isFalse(redirectStub.called);
-          assert.isFalse(show404Stub.called);
-        });
-      });
-
-      test('w/ changeNum', () => {
-        projectLookupStub.returns(Promise.resolve('foo/bar'));
-        const params = {changeNum: 1234};
-
-        return element._normalizeLegacyRouteParams(params).then(() => {
-          assert.isTrue(generateUrlStub.calledOnce);
-          const updatedParams = generateUrlStub.lastCall.args[0];
-          assert.isTrue(projectLookupStub.called);
-          assert.isTrue(rangeStub.called);
-          assert.equal(updatedParams.project, 'foo/bar');
-          assert.isTrue(redirectStub.calledOnce);
-          assert.isFalse(show404Stub.called);
-        });
-      });
-
-      test('halts on project lookup failure', () => {
-        projectLookupStub.returns(Promise.resolve(undefined));
-        const params = {changeNum: 1234};
-        return element._normalizeLegacyRouteParams(params).then(() => {
-          assert.isFalse(generateUrlStub.calledOnce);
-          assert.isTrue(projectLookupStub.called);
-          assert.isFalse(rangeStub.called);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(show404Stub.calledOnce);
-        });
-      });
-    });
-
     suite('_normalizePatchRangeParams', () => {
       test('range n..n normalizes to n', () => {
         const params = {basePatchNum: 4, patchNum: 4};
@@ -1367,58 +1306,19 @@
         assert.isTrue(redirectStub.calledWithExactly('/c/12345'));
       });
 
-      test('_handleChangeLegacyRoute', () => {
-        const normalizeRouteStub = sinon.stub(element,
-            '_normalizeLegacyRouteParams');
+      test('_handleChangeLegacyRoute', async () => {
+        stubRestApi('getFromProjectLookup').returns(Promise.resolve('project'));
         const ctx = {
           params: [
             1234, // 0 Change number
-            null, // 1 Unused
-            null, // 2 Unused
-            6, // 3 Base patch number
-            null, // 4 Unused
-            9, // 5 Patch number
+            'comment/6789',
           ],
           querystring: '',
         };
         element._handleChangeLegacyRoute(ctx);
-        assert.isTrue(normalizeRouteStub.calledOnce);
-        assert.deepEqual(normalizeRouteStub.lastCall.args[0], {
-          changeNum: 1234,
-          basePatchNum: 6,
-          patchNum: 9,
-          view: GerritView.CHANGE,
-          querystring: '',
-        });
-      });
-
-      test('_handleDiffLegacyRoute', () => {
-        const normalizeRouteStub = sinon.stub(element,
-            '_normalizeLegacyRouteParams');
-        const ctx = {
-          params: [
-            1234, // 0 Change number
-            null, // 1 Unused
-            3, // 2 Base patch number
-            null, // 3 Unused
-            8, // 4 Patch number
-            'foo/bar', // 5 Diff path
-          ],
-          path: '/c/1234/3..8/foo/bar',
-          hash: 'b123',
-        };
-        element._handleDiffLegacyRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.isTrue(normalizeRouteStub.calledOnce);
-        assert.deepEqual(normalizeRouteStub.lastCall.args[0], {
-          changeNum: 1234,
-          basePatchNum: 3,
-          patchNum: 8,
-          view: GerritView.DIFF,
-          path: 'foo/bar',
-          lineNum: 123,
-          leftSide: true,
-        });
+        await flush();
+        assert.isTrue(redirectStub.calledWithExactly('/c/project/+/1234' +
+            '/comment/6789'));
       });
 
       test('_handleLegacyLinenum w/ @321', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
index d3d7615..94d37f5 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -186,6 +186,7 @@
         })
       );
       element._isApplyFixLoading = true;
+      await flush();
       const button = getConfirmButton();
       assert.isTrue(button.hasAttribute('disabled'));
       assert.equal(button.getAttribute('title'), '');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
index 480e26c..de7d007 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
@@ -15,9 +15,17 @@
  * limitations under the License.
  */
 import {DiffLayer, DiffLayerListener} from '../../../types/types';
-import {GrDiffLine, Side, TokenHighlightedListener} from '../../../api/diff';
+import {GrDiffLine, Side, TokenHighlightListener} from '../../../api/diff';
+import {assertIsDefined} from '../../../utils/common-util';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+
+import {
+  getLineElByChild,
+  getSideByLineEl,
+  getPreviousContentNodes,
+} from '../gr-diff/gr-diff-utils';
+
 import {
   getLineNumberByChild,
   lineNumberToNumber,
@@ -66,7 +74,7 @@
   private currentHighlight?: string;
 
   /** Trigger when a new token starts or stoped being highlighted.*/
-  private readonly tokenHighlightedListener?: TokenHighlightedListener;
+  private readonly tokenHighlightListener?: TokenHighlightListener;
 
   /**
    * The line of the currently highlighted token. We store this in order to
@@ -100,9 +108,9 @@
 
   constructor(
     container: HTMLElement = document.documentElement,
-    tokenHighlightedListener?: TokenHighlightedListener
+    tokenHighlightListener?: TokenHighlightListener
   ) {
-    this.tokenHighlightedListener = tokenHighlightedListener;
+    this.tokenHighlightListener = tokenHighlightListener;
     container.addEventListener('click', e => {
       this.handleContainerClick(e);
     });
@@ -260,18 +268,42 @@
     const oldLineNumber = this.currentHighlightLineNumber;
     this.currentHighlight = newHighlight;
     this.currentHighlightLineNumber = newLineNumber;
-
-    if (this.tokenHighlightedListener) {
-      this.tokenHighlightedListener(
-        newHighlight,
-        newLineNumber,
-        newHoveredElement
-      );
-    }
+    this.triggerTokenHighlightEvent(
+      newHighlight,
+      newLineNumber,
+      newHoveredElement
+    );
     this.notifyForToken(oldHighlight, oldLineNumber);
     this.notifyForToken(newHighlight, newLineNumber);
   }
 
+  triggerTokenHighlightEvent(
+    token: string | undefined,
+    line: number,
+    element: Element | undefined
+  ) {
+    if (!this.tokenHighlightListener) {
+      return;
+    }
+    if (!token || !element) {
+      this.tokenHighlightListener(undefined);
+      return;
+    }
+    const previousTextLength = getPreviousContentNodes(element)
+      .map(sib => sib.textContent!.length)
+      .reduce((partial_sum, a) => partial_sum + a, 0);
+    const lineEl = getLineElByChild(element);
+    assertIsDefined(lineEl, 'Line element should be found!');
+    const side = getSideByLineEl(lineEl);
+    const range = {
+      start_line: line,
+      start_column: previousTextLength + 1, // 1-based inclusive
+      end_line: line,
+      end_column: previousTextLength + token.length, // 1-based inclusive
+    };
+    this.tokenHighlightListener({token, element, side, range});
+  }
+
   getSortedLinesForSide(
     lineMapping: Map<string, Set<number>>,
     token: string | undefined,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts
index 4f44665..2993d35 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts
@@ -17,7 +17,7 @@
 
 import '../../../test/common-test-setup-karma';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
-import {Side} from '../../../api/diff';
+import {Side, TokenHighlightEventDetails} from '../../../api/diff';
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
 import {HOVER_DELAY_MS, TokenHighlightLayer} from './token-highlight-layer';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
@@ -66,14 +66,12 @@
   let container: HTMLElement;
   let listener: MockListener;
   let highlighter: TokenHighlightLayer;
-  let tokenHighlightingCalls: any[] = [];
+  let tokenHighlightingCalls: {details?: TokenHighlightEventDetails}[] = [];
 
-  function tokenHighlightedListener(
-    newHighlight: string | undefined,
-    newLineNumber: number,
-    hoveredElement?: Element
+  function tokenHighlightListener(
+    highlightDetails?: TokenHighlightEventDetails
   ) {
-    tokenHighlightingCalls.push({newHighlight, newLineNumber, hoveredElement});
+    tokenHighlightingCalls.push({details: highlightDetails});
   }
 
   setup(async () => {
@@ -81,7 +79,7 @@
     tokenHighlightingCalls = [];
     container = document.createElement('div');
     document.body.appendChild(container);
-    highlighter = new TokenHighlightLayer(container, tokenHighlightedListener);
+    highlighter = new TokenHighlightLayer(container, tokenHighlightListener);
     highlighter.addListener((...args) => listener.notify(...args));
   });
 
@@ -107,10 +105,13 @@
     const lineId = createLineId();
     const template = html`
       <div class="line">
-        <div data-value=${line} class="lineNum"></div>
-        <div id=${lineId} class="line-content">${text}</div>
+        <div data-value=${line} class="lineNum right"></div>
+        <div class="content">
+          <div id=${lineId} class="contentText">${text}</div>
+        </div>
       </div>
     `;
+
     const div = document.createElement('div');
     render(template, div);
     container.appendChild(div);
@@ -277,19 +278,16 @@
       assert.equal(tokenHighlightingCalls.length, 0);
       clock.tick(HOVER_DELAY_MS);
       assert.equal(tokenHighlightingCalls.length, 1);
-      assert.deepEqual(tokenHighlightingCalls[0], {
-        newHighlight: 'words',
-        newLineNumber: 1,
-        hoveredElement: words1,
+      assert.deepEqual(tokenHighlightingCalls[0].details, {
+        token: 'words',
+        side: Side.RIGHT,
+        element: words1,
+        range: {start_line: 1, start_column: 5, end_line: 1, end_column: 9},
       });
 
       MockInteractions.click(container);
       assert.equal(tokenHighlightingCalls.length, 2);
-      assert.deepEqual(tokenHighlightingCalls[1], {
-        newHighlight: undefined,
-        newLineNumber: 0,
-        hoveredElement: undefined,
-      });
+      assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
     });
 
     test('clicking clears highlight', async () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
index ba1abd0..496d6bf 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -579,6 +579,7 @@
 
     // To pass CSS mixins for @apply to Polymer components, they need to appear
     // in <style> inside the template.
+    /* eslint-disable lit/prefer-static-styles */
     const customStyle = html`
       <style>
         paper-item {
@@ -648,7 +649,9 @@
   // We don't want property changes in updateSizes() to trigger infinite update
   // loops, so we perform this in update() instead of updated().
   override update(changedProperties: PropertyValues) {
+    // eslint-disable-next-line lit/no-property-change-update
     if (!this.baseUrl) this.baseSelected = false;
+    // eslint-disable-next-line lit/no-property-change-update
     if (!this.revisionUrl) this.baseSelected = true;
     this.updateSizes();
     super.update(changedProperties);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
index 4e2b6a1..8a6d95d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
@@ -30,28 +30,34 @@
       width: 1.3rem;
     }
   </style>
-  <gr-button
-    id="sideBySideBtn"
-    link=""
+  <gr-tooltip-content
     has-tooltip=""
-    position-below="[[showTooltipBelow]]"
-    class$="[[_computeSideBySideSelected(mode)]]"
     title="Side-by-side diff"
-    aria-pressed$="[[isSideBySideSelected(mode)]]"
-    on-click="_handleSideBySideTap"
+    position-below="[[showTooltipBelow]]"
   >
-    <iron-icon icon="gr-icons:side-by-side"></iron-icon>
-  </gr-button>
-  <gr-button
-    id="unifiedBtn"
-    link=""
+    <gr-button
+      id="sideBySideBtn"
+      link=""
+      class$="[[_computeSideBySideSelected(mode)]]"
+      aria-pressed$="[[isSideBySideSelected(mode)]]"
+      on-click="_handleSideBySideTap"
+    >
+      <iron-icon icon="gr-icons:side-by-side"></iron-icon>
+    </gr-button>
+  </gr-tooltip-content>
+  <gr-tooltip-content
     has-tooltip=""
     position-below="[[showTooltipBelow]]"
     title="Unified diff"
-    class$="[[_computeUnifiedSelected(mode)]]"
-    aria-pressed$="[[isUnifiedSelected(mode)]]"
-    on-click="_handleUnifiedTap"
   >
-    <iron-icon icon="gr-icons:unified"></iron-icon>
-  </gr-button>
+    <gr-button
+      id="unifiedBtn"
+      link=""
+      class$="[[_computeUnifiedSelected(mode)]]"
+      aria-pressed$="[[isUnifiedSelected(mode)]]"
+      on-click="_handleUnifiedTap"
+    >
+      <iron-icon icon="gr-icons:unified"></iron-icon>
+    </gr-button>
+  </gr-tooltip-content>
 `;
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 b1d71cc..bb5ce94 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
@@ -16,6 +16,7 @@
  */
 import '@polymer/iron-dropdown/iron-dropdown';
 import '@polymer/iron-input/iron-input';
+import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dropdown/gr-dropdown';
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
index 7e0ca10..4f1047f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-a11y-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       display: block;
@@ -351,15 +354,15 @@
           hidden=""
         >
           <span class="preferences desktop">
-            <gr-button
-              link=""
-              class="prefsButton"
+            <gr-tooltip-content
               has-tooltip=""
               position-below=""
               title="Diff preferences"
-              on-click="_handlePrefsTap"
-              ><iron-icon icon="gr-icons:settings"></iron-icon
-            ></gr-button>
+            >
+              <gr-button link="" class="prefsButton" on-click="_handlePrefsTap"
+                ><iron-icon icon="gr-icons:settings"></iron-icon
+              ></gr-button>
+            </gr-tooltip-content>
           </span>
         </span>
         <gr-endpoint-decorator name="annotation-toggler">
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 fada9cb..7393606 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
@@ -129,6 +129,29 @@
   rootId: string;
 }
 
+const VISIBLE_TEXT_NODE_TYPES = [Node.TEXT_NODE, Node.ELEMENT_NODE];
+
+export function getPreviousContentNodes(node?: Node | null) {
+  const sibs = [];
+  while (node) {
+    const {parentNode, previousSibling} = node;
+    const topContentLevel =
+      parentNode &&
+      (parentNode as HTMLElement).classList.contains('contentText');
+    let previousEl: Node | undefined | null;
+    if (previousSibling) {
+      previousEl = previousSibling;
+    } else if (!topContentLevel) {
+      previousEl = parentNode?.previousSibling;
+    }
+    if (previousEl && VISIBLE_TEXT_NODE_TYPES.includes(previousEl.nodeType)) {
+      sibs.push(previousEl);
+    }
+    node = previousEl;
+  }
+  return sibs;
+}
+
 export function isThreadEl(node: Node): node is GrDiffThreadElement {
   return (
     node.nodeType === Node.ELEMENT_NODE &&
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index 3544834..0d6cadc 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -14,6 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
 import '../../shared/gr-select/gr-select';
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 5ab8449..ebbb0d6 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-a11y-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       align-items: center;
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
index bc3c054..3f2258d 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
@@ -45,6 +45,7 @@
   override render() {
     // To pass CSS mixins for @apply to Polymer components, they need to appear
     // in <style> inside the template.
+    /* eslint-disable lit/prefer-static-styles */
     const customStyle = html`
       <style>
         .row {
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
index 91882ab..3adb0f3 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
@@ -19,21 +19,15 @@
 import '../../shared/gr-list-view/gr-list-view';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-documentation-search_html';
-import {
-  ListViewMixin,
-  ListViewParams,
-} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {getBaseUrl} from '../../../utils/url-util';
 import {customElement, property} from '@polymer/decorators';
 import {DocResult} from '../../../types/common';
 import {fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
-
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = ListViewMixin(PolymerElement);
+import {ListViewParams} from '../../gr-app-types';
 
 @customElement('gr-documentation-search')
-export class GrDocumentationSearch extends base {
+export class GrDocumentationSearch extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -62,7 +56,7 @@
 
   _paramsChanged(params: ListViewParams) {
     this._loading = true;
-    this._filter = this.getFilterValue(params);
+    this._filter = params?.filter ?? '';
 
     return this._getDocumentationSearches(this._filter);
   }
@@ -87,6 +81,10 @@
     }
     return `${getBaseUrl()}/${url}`;
   }
+
+  computeLoadingClass(loading: boolean) {
+    return loading ? 'loading' : '';
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
index 5267b2d..bf6a0d5 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
@@ -21,8 +21,8 @@
 import {page} from '../../../utils/page-wrapper-utils';
 import 'lodash/lodash';
 import {stubRestApi} from '../../../test/test-utils';
-import {ListViewParams} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {DocResult} from '../../../types/common';
+import {ListViewParams} from '../../gr-app-types';
 
 const basicFixture = fixtureFromElement('gr-documentation-search');
 
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
index 1255256..db45c33 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
@@ -59,6 +59,7 @@
   override render() {
     // To pass CSS mixins for @apply to Polymer components, they need to appear
     // in <style> inside the template.
+    /* eslint-disable lit/prefer-static-styles */
     const customStyle = html`
       <style>
         gr-button,
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index 46a3e84..b6fe60b 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -353,6 +353,7 @@
     this.bindShortcut(Shortcut.REFRESH_CHANGE_LIST, 'shift+r:keyup');
     this.bindShortcut(Shortcut.EDIT_TOPIC, 't');
     this.bindShortcut(Shortcut.OPEN_SUBMIT_DIALOG, 'shift+s');
+    this.bindShortcut(Shortcut.TOGGLE_ATTENTION_SET, 'shift+t');
 
     this.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a:keyup');
     this.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd:keyup');
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index e5096c0..6c8bdb9 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -52,20 +52,21 @@
   groupId: GroupId;
 }
 
-export interface AppElementAdminParams {
+export interface ListViewParams {
+  filter?: string | null;
+  offset?: number | string;
+}
+
+export interface AppElementAdminParams extends ListViewParams {
   view: GerritView.ADMIN;
   adminView: string;
-  offset?: string | number;
-  filter?: string | null;
   openCreateModal?: boolean;
 }
 
-export interface AppElementRepoParams {
+export interface AppElementRepoParams extends ListViewParams {
   view: GerritView.REPO;
   detail?: RepoDetailView;
   repo: RepoName;
-  offset?: string | number;
-  filter?: string | null;
 }
 
 export interface AppElementDocSearchParams {
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
index 8ed6611..45a93bf 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
@@ -51,6 +51,11 @@
     return dom(this.popup) as unknown as HTMLElement;
   }
 
+  appendContent(el: HTMLElement) {
+    if (!this.popup) throw new Error('popup element not (yet) available');
+    this.popup.appendChild(el);
+  }
+
   /**
    * Opens the popup, inserts it into DOM over current UI.
    * Creates the popup if not previously created. Creates popup content element,
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts
index f913cf6..6580ad6 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts
@@ -14,6 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+/* eslint-disable lit/no-legacy-template-syntax,lit/prefer-static-styles */
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {customElement, property} from '@polymer/decorators';
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
index 514f00e..51259c8 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_html.ts
@@ -56,7 +56,7 @@
       <span class="title">Registered</span>
       <span class="value">
         <gr-date-formatter
-          has-tooltip=""
+          withTooltip
           date-str="[[_account.registered_on]]"
         ></gr-date-formatter>
       </span>
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
index b79e448..96b1ded 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
@@ -15,22 +15,18 @@
  * limitations under the License.
  */
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../../styles/shared-styles';
 import '../../../styles/gr-form-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-table-editor_html';
-import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
 import {customElement, property, observe} from '@polymer/decorators';
 import {ServerInfo} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
-
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = ChangeTableMixin(PolymerElement);
+import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
 
 @customElement('gr-change-table-editor')
-export class GrChangeTableEditor extends base {
+export class GrChangeTableEditor extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -51,18 +47,33 @@
 
   @observe('serverConfig')
   _configChanged(config: ServerInfo) {
-    this.defaultColumns = this.getEnabledColumns(
-      this.columnNames,
-      config,
-      this.flagsService.enabledExperiments
+    this.defaultColumns = columnNames.filter(col =>
+      this._isColumnEnabled(col, config, this.flagsService.enabledExperiments)
     );
     if (!this.displayedColumns) return;
     this.displayedColumns = this.displayedColumns.filter(column =>
-      this.isColumnEnabled(column, config, this.flagsService.enabledExperiments)
+      this._isColumnEnabled(
+        column,
+        config,
+        this.flagsService.enabledExperiments
+      )
     );
   }
 
   /**
+   * Is the column disabled by a server config or experiment? For example the
+   * assignee feature might be disabled and thus the corresponding column is
+   * also disabled.
+   *
+   */
+  _isColumnEnabled(column: string, config: ServerInfo, experiments: string[]) {
+    if (!config || !config.change) return true;
+    if (column === 'Assignee') return !!config.change.enable_assignee;
+    if (column === 'Comments') return experiments.includes('comments-column');
+    return true;
+  }
+
+  /**
    * Get the list of enabled column names from whichever checkboxes are
    * checked (excluding the number checkbox).
    */
@@ -79,6 +90,13 @@
       .map(checkbox => checkbox.name);
   }
 
+  _computeIsColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]) {
+    if (!columnsToDisplay || !columnToCheck) {
+      return false;
+    }
+    return !columnsToDisplay.includes(columnToCheck);
+  }
+
   /**
    * Handle a click on a checkbox container and relay the click to the checkbox it
    * contains.
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts
index a05ec73..e756a20 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_html.ts
@@ -74,7 +74,7 @@
                 type="checkbox"
                 name="[[item]]"
                 on-click="_handleTargetClick"
-                checked$="[[!isColumnHidden(item, displayedColumns)]]"
+                checked$="[[!_computeIsColumnHidden(item, displayedColumns)]]"
               />
             </td>
           </tr>
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
index 4f61972..4f8d0a0 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
@@ -117,7 +117,7 @@
 
   test('_getDisplayedColumns', () => {
     const enabledColumns = columns.filter(column =>
-      element.isColumnEnabled(column, element.serverConfig!, [])
+      element._isColumnEnabled(column, element.serverConfig!, [])
     );
     assert.deepEqual(element._getDisplayedColumns(), enabledColumns);
     const input = queryAndAssert<HTMLInputElement>(
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
index ace1e1a..4096b02 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
@@ -16,7 +16,6 @@
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../../styles/shared-styles';
 import '../../../styles/gr-form-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
index da4bb0a..94333c7 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -29,7 +29,6 @@
 import '../gr-change-table-editor/gr-change-table-editor';
 import '../../shared/gr-button/gr-button';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-diff-preferences/gr-diff-preferences';
 import '../../shared/gr-page-nav/gr-page-nav';
 import '../../shared/gr-select/gr-select';
@@ -47,7 +46,6 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-settings-view_html';
 import {getDocsBaseUrl} from '../../../utils/url-util';
-import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
 import {customElement, property, observe} from '@polymer/decorators';
 import {AppElementParams} from '../../gr-app-types';
 import {GrAccountInfo} from '../gr-account-info/gr-account-info';
@@ -76,6 +74,7 @@
   EmailStrategy,
   TimeFormat,
 } from '../../../constants/constants';
+import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
 
 const PREFS_SECTION_FIELDS: Array<keyof PreferencesInput> = [
   'changes_per_page',
@@ -139,11 +138,8 @@
   };
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = ChangeTableMixin(PolymerElement);
-
 @customElement('gr-settings-view')
-export class GrSettingsView extends base {
+export class GrSettingsView extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -262,8 +258,10 @@
         this._localMenu = this._cloneMenu(prefs.my);
         this._localChangeTableColumns =
           prefs.change_table.length === 0
-            ? this.columnNames
-            : this.renameProjectToRepoColumn(prefs.change_table);
+            ? columnNames
+            : prefs.change_table.map(column =>
+                column === 'Project' ? 'Repo' : column
+              );
       })
     );
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
index 66103fd..31c62b1 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
@@ -128,6 +128,7 @@
   override render() {
     // To pass CSS mixins for @apply to Polymer components, they need to appear
     // in <style> inside the template.
+    /* eslint-disable lit/prefer-static-styles */
     const customStyle = html`
       <style>
         .container {
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index f746c29..9897a9f 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -206,18 +206,7 @@
             ></gr-hovercard-account>`
           : ''}
         ${hasAttention
-          ? html`<gr-button
-              id="attentionButton"
-              link=""
-              aria-label="Remove user from attention set"
-              @click=${this._handleRemoveAttentionClick}
-              ?disabled=${!this._computeAttentionButtonEnabled(
-                highlightAttention,
-                account,
-                change,
-                this.selected,
-                this._selfAccount
-              )}
+          ? html` <gr-tooltip-content
               ?has-tooltip=${this._computeAttentionButtonEnabled(
                 highlightAttention,
                 account,
@@ -233,11 +222,25 @@
                 this.selected,
                 this._selfAccount
               )}"
-              ><iron-icon
-                class="attention"
-                icon="gr-icons:attention"
-              ></iron-icon>
-            </gr-button>`
+            >
+              <gr-button
+                id="attentionButton"
+                link=""
+                aria-label="Remove user from attention set"
+                @click=${this._handleRemoveAttentionClick}
+                ?disabled=${!this._computeAttentionButtonEnabled(
+                  highlightAttention,
+                  account,
+                  change,
+                  this.selected,
+                  this._selfAccount
+                )}
+                ><iron-icon
+                  class="attention"
+                  icon="gr-icons:attention"
+                ></iron-icon>
+              </gr-button>
+            </gr-tooltip-content>`
           : ''}
       </span>
       <span
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
index b302bee..fbf29c63 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
@@ -95,7 +95,7 @@
           ?hideStatus=${this.hideStatus}
           ?firstName=${this.firstName}
           .voteableText=${this.voteableText}
-          part="gr-account-link-text => gr-account-label-text"
+          exportparts="gr-account-label-text: gr-account-link-text"
         >
         </gr-account-label>
       </a>
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
index c38c196..ae3853e 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
@@ -96,6 +96,7 @@
   override render() {
     // To pass CSS mixins for @apply to Polymer components, they need to appear
     // in <style> inside the template.
+    /* eslint-disable lit/prefer-static-styles */
     const style = html`<style>
       .action {
         --gr-button: {
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index 1ece10a..f7ebd66 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -15,14 +15,11 @@
  * limitations under the License.
  */
 import '@polymer/paper-button/paper-button';
-import '../../../styles/shared-styles';
-import '../../../styles/gr-voting-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property, computed, observe} from '@polymer/decorators';
-import {htmlTemplate} from './gr-button_html';
-import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {votingStyles} from '../../../styles/gr-voting-styles';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property} from 'lit/decorators';
 import {
-  PolymerEvent,
   getEventPath,
   getKeyboardEvent,
   isModifierPressed,
@@ -37,87 +34,243 @@
   }
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = TooltipMixin(PolymerElement);
-
 @customElement('gr-button')
-export class GrButton extends base {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrButton extends LitElement {
+  private readonly reporting: ReportingService = appContext.reportingService;
 
   /**
    * Should this button be rendered as a vote chip? Then we are applying
    * the .voteChip class (see gr-voting-styles) to the paper-button.
    */
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   voteChip = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
-  downArrow = false;
-
-  @property({type: Boolean, reflectToAttribute: true})
-  link = false;
-
-  @property({type: Boolean})
-  noUppercase = false;
-
-  @property({type: Boolean, reflectToAttribute: true})
-  loading = false;
-
-  @property({type: Boolean, reflectToAttribute: true})
-  disabled: boolean | null = null;
-
-  @property({type: String})
-  tooltip = '';
-
   // Note: don't assign a value to this, since constructor is called
   // after created, the initial value maybe overridden by this
-  @property({type: String})
-  _initialTabindex?: string;
+  private initialTabindex?: string;
 
-  @computed('disabled', 'loading')
-  get _disabled() {
-    return this.disabled || this.loading;
+  @property({type: Boolean, reflect: true})
+  downArrow = false;
+
+  @property({type: Boolean, reflect: true})
+  link = false;
+
+  @property({type: Boolean, reflect: true})
+  loading = false;
+
+  @property({type: Boolean, reflect: true})
+  disabled: boolean | null = null;
+
+  static override get styles() {
+    return [
+      votingStyles,
+      spinnerStyles,
+      css`
+        /* general styles for all buttons */
+        :host {
+          --background-color: var(
+            --button-background-color,
+            var(--default-button-background-color)
+          );
+          --text-color: var(--default-button-text-color);
+          display: inline-block;
+          position: relative;
+        }
+        :host([hidden]) {
+          display: none;
+        }
+        :host([no-uppercase]) paper-button {
+          text-transform: none;
+        }
+        paper-button {
+          /* The next lines contains a copy of paper-button style.
+            Without a copy, the @apply works incorrectly with Polymer 2.
+            @apply is deprecated and is not recommended to use. It is expected
+            that @apply will be replaced with the ::part CSS pseudo-element.
+            After replacement copied lines can be removed.
+          */
+          @apply --layout-inline;
+          @apply --layout-center-center;
+          position: relative;
+          box-sizing: border-box;
+          min-width: 5.14em;
+          margin: 0 0.29em;
+          background: transparent;
+          -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+          -webkit-tap-highlight-color: transparent;
+          font: inherit;
+          text-transform: uppercase;
+          outline-width: 0;
+          border-top-left-radius: var(--border-radius);
+          border-top-right-radius: var(--border-radius);
+          border-bottom-right-radius: var(--border-radius);
+          border-bottom-left-radius: var(--border-radius);
+          -moz-user-select: none;
+          -ms-user-select: none;
+          -webkit-user-select: none;
+          user-select: none;
+          cursor: pointer;
+          z-index: 0;
+          padding: var(--spacing-m);
+
+          @apply --paper-font-common-base;
+          @apply --paper-button;
+          /* End of copy*/
+
+          /* paper-button sets this to anti-aliased, which appears different than
+            bold font elsewhere on macOS. */
+          -webkit-font-smoothing: initial;
+          align-items: center;
+          background-color: var(--background-color);
+          color: var(--text-color);
+          display: flex;
+          font-family: inherit;
+          justify-content: center;
+          margin: var(--margin, 0);
+          min-width: var(--border, 0);
+          padding: var(--padding, 4px 8px);
+          @apply --gr-button;
+        }
+        /* https://github.com/PolymerElements/paper-button/blob/2.x/paper-button.html */
+        /* BEGIN: Copy from paper-button */
+        paper-button[elevation='1'] {
+          @apply --paper-material-elevation-1;
+        }
+        paper-button[elevation='2'] {
+          @apply --paper-material-elevation-2;
+        }
+        paper-button[elevation='3'] {
+          @apply --paper-material-elevation-3;
+        }
+        paper-button[elevation='4'] {
+          @apply --paper-material-elevation-4;
+        }
+        paper-button[elevation='5'] {
+          @apply --paper-material-elevation-5;
+        }
+        /* END: Copy from paper-button */
+        paper-button:hover {
+          background: linear-gradient(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.12)),
+            var(--background-color);
+        }
+
+        /* Some mobile browsers treat focused element as hovered element.
+        As a result, element remains hovered after click (has grey background in default theme).
+        Use @media (hover:none) to remove background if
+        user's primary input mechanism can't hover over elements.
+        See: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/hover
+
+        Note 1: not all browsers support this media query
+        (see https://caniuse.com/#feat=css-media-interaction).
+        If browser doesn't support it, then the whole content of @media .. is ignored.
+        This is why the default behavior is placed outside of @media.
+        */
+        @media (hover: none) {
+          paper-button:hover {
+            background: transparent;
+          }
+        }
+
+        :host([primary]) {
+          --background-color: var(--primary-button-background-color);
+          --text-color: var(--primary-button-text-color);
+        }
+        :host([link][primary]) {
+          --text-color: var(--primary-button-background-color);
+        }
+
+        /* Keep below color definition for primary so that this takes precedence
+          when disabled. */
+        :host([disabled]),
+        :host([loading]) {
+          --background-color: var(--disabled-button-background-color);
+          --text-color: var(--deemphasized-text-color);
+          cursor: default;
+        }
+
+        /* Styles for link buttons specifically */
+        :host([link]) {
+          --background-color: transparent;
+          --margin: 0;
+          --padding: var(--spacing-s);
+        }
+        :host([disabled][link]),
+        :host([loading][link]) {
+          --background-color: transparent;
+          --text-color: var(--deemphasized-text-color);
+          cursor: default;
+        }
+
+        /* Styles for the optional down arrow */
+        :host(:not([down-arrow])) .downArrow {
+          display: none;
+        }
+        :host([down-arrow]) .downArrow {
+          border-top: 0.36em solid #ccc;
+          border-left: 0.36em solid transparent;
+          border-right: 0.36em solid transparent;
+          margin-bottom: var(--spacing-xxs);
+          margin-left: var(--spacing-m);
+          transition: border-top-color 200ms;
+        }
+        :host([down-arrow]) paper-button:hover .downArrow {
+          border-top-color: var(--deemphasized-text-color);
+        }
+      `,
+    ];
   }
 
-  @property({
-    computed: 'computeAriaDisabled(disabled, loading)',
-    reflectToAttribute: true,
-    type: String,
-  })
-  ariaDisabled!: string;
-
-  computeAriaDisabled() {
-    return this._disabled ? 'true' : 'false';
+  override render() {
+    return html`<paper-button
+      ?raised="${!this.link}"
+      ?disabled="${this.disabled || this.loading}"
+      role="button"
+      tabindex="-1"
+      part="paper-button"
+      class="${this.voteChip ? 'voteChip' : ''}"
+    >
+      ${this.loading ? html`<span class="loadingSpin"></span>` : ''}
+      <slot></slot>
+      <i class="downArrow"></i>
+    </paper-button>`;
   }
 
-  computePaperButtonClass(voteChip?: boolean) {
-    return voteChip ? 'voteChip' : '';
-  }
-
-  private readonly reporting: ReportingService = appContext.reportingService;
-
   constructor() {
     super();
-    this._initialTabindex = this.getAttribute('tabindex') || '0';
-    // TODO(TS): try avoid using unknown
-    this.addEventListener('click', e =>
-      this._handleAction(e as unknown as PolymerEvent)
-    );
+    this.initialTabindex = this.getAttribute('tabindex') || '0';
+    this.addEventListener('click', e => this._handleAction(e));
     this.addEventListener('keydown', e =>
       this._handleKeydown(e as unknown as CustomKeyboardEvent)
     );
   }
 
-  override ready() {
-    super.ready();
-    this._ensureAttribute('role', 'button');
-    this._ensureAttribute('tabindex', '0');
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('disabled')) {
+      this.setAttribute(
+        'tabindex',
+        this.disabled ? '-1' : this.initialTabindex || '0'
+      );
+    }
+    if (changedProperties.has('loading') || changedProperties.has('disabled')) {
+      this.setAttribute(
+        'aria-disabled',
+        this.disabled || this.loading ? 'true' : 'false'
+      );
+    }
   }
 
-  _handleAction(e: PolymerEvent) {
-    if (this._disabled) {
+  override connectedCallback() {
+    super.connectedCallback();
+    if (!this.getAttribute('role')) {
+      this.setAttribute('role', 'button');
+    }
+    if (!this.getAttribute('tabindex')) {
+      this.setAttribute('tabindex', '0');
+    }
+  }
+
+  _handleAction(e: MouseEvent) {
+    if (this.disabled || this.loading) {
       e.preventDefault();
       e.stopPropagation();
       e.stopImmediatePropagation();
@@ -127,15 +280,6 @@
     this.reporting.reportInteraction('button-click', {path: getEventPath(e)});
   }
 
-  @observe('disabled')
-  _disabledChanged(disabled: boolean) {
-    this.setAttribute(
-      'tabindex',
-      disabled ? '-1' : this._initialTabindex || '0'
-    );
-    this.updateStyles();
-  }
-
   _handleKeydown(e: CustomKeyboardEvent) {
     if (isModifierPressed(e)) {
       return;
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.ts
deleted file mode 100644
index 22ec2f4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.ts
+++ /dev/null
@@ -1,188 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-voting-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-spinner-styles">
-    /* general styles for all buttons */
-    :host {
-      --background-color: var(
-        --button-background-color,
-        var(--default-button-background-color)
-      );
-      --text-color: var(--default-button-text-color);
-      display: inline-block;
-      position: relative;
-    }
-    :host([hidden]) {
-      display: none;
-    }
-    :host([no-uppercase]) paper-button {
-      text-transform: none;
-    }
-    paper-button {
-      /* The next lines contains a copy of paper-button style.
-          Without a copy, the @apply works incorrectly with Polymer 2.
-          @apply is deprecated and is not recommended to use. It is expected
-          that @apply will be replaced with the ::part CSS pseudo-element.
-          After replacement copied lines can be removed.
-        */
-      @apply --layout-inline;
-      @apply --layout-center-center;
-      position: relative;
-      box-sizing: border-box;
-      min-width: 5.14em;
-      margin: 0 0.29em;
-      background: transparent;
-      -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-      -webkit-tap-highlight-color: transparent;
-      font: inherit;
-      text-transform: uppercase;
-      outline-width: 0;
-      border-top-left-radius: var(--border-radius);
-      border-top-right-radius: var(--border-radius);
-      border-bottom-right-radius: var(--border-radius);
-      border-bottom-left-radius: var(--border-radius);
-      -moz-user-select: none;
-      -ms-user-select: none;
-      -webkit-user-select: none;
-      user-select: none;
-      cursor: pointer;
-      z-index: 0;
-      padding: var(--spacing-m);
-
-      @apply --paper-font-common-base;
-      @apply --paper-button;
-      /* End of copy*/
-
-      /* paper-button sets this to anti-aliased, which appears different than
-          bold font elsewhere on macOS. */
-      -webkit-font-smoothing: initial;
-      align-items: center;
-      background-color: var(--background-color);
-      color: var(--text-color);
-      display: flex;
-      font-family: inherit;
-      justify-content: center;
-      margin: var(--margin, 0);
-      min-width: var(--border, 0);
-      padding: var(--padding, 4px 8px);
-      @apply --gr-button;
-    }
-    /* https://github.com/PolymerElements/paper-button/blob/2.x/paper-button.html */
-    /* BEGIN: Copy from paper-button */
-    paper-button[elevation='1'] {
-      @apply --paper-material-elevation-1;
-    }
-    paper-button[elevation='2'] {
-      @apply --paper-material-elevation-2;
-    }
-    paper-button[elevation='3'] {
-      @apply --paper-material-elevation-3;
-    }
-    paper-button[elevation='4'] {
-      @apply --paper-material-elevation-4;
-    }
-    paper-button[elevation='5'] {
-      @apply --paper-material-elevation-5;
-    }
-    /* END: Copy from paper-button */
-    paper-button:hover {
-      background: linear-gradient(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.12)),
-        var(--background-color);
-    }
-
-    /* Some mobile browsers treat focused element as hovered element.
-      As a result, element remains hovered after click (has grey background in default theme).
-      Use @media (hover:none) to remove background if
-      user's primary input mechanism can't hover over elements.
-      See: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/hover
-
-      Note 1: not all browsers support this media query
-      (see https://caniuse.com/#feat=css-media-interaction).
-      If browser doesn't support it, then the whole content of @media .. is ignored.
-      This is why the default behavior is placed outside of @media.
-      */
-    @media (hover: none) {
-      paper-button:hover {
-        background: transparent;
-      }
-    }
-
-    :host([primary]) {
-      --background-color: var(--primary-button-background-color);
-      --text-color: var(--primary-button-text-color);
-    }
-    :host([link][primary]) {
-      --text-color: var(--primary-button-background-color);
-    }
-
-    /* Keep below color definition for primary so that this takes precedence
-        when disabled. */
-    :host([disabled]),
-    :host([loading]) {
-      --background-color: var(--disabled-button-background-color);
-      --text-color: var(--deemphasized-text-color);
-      cursor: default;
-    }
-
-    /* Styles for link buttons specifically */
-    :host([link]) {
-      --background-color: transparent;
-      --margin: 0;
-      --padding: var(--spacing-s);
-    }
-    :host([disabled][link]),
-    :host([loading][link]) {
-      --background-color: transparent;
-      --text-color: var(--deemphasized-text-color);
-      cursor: default;
-    }
-
-    /* Styles for the optional down arrow */
-    :host(:not([down-arrow])) .downArrow {
-      display: none;
-    }
-    :host([down-arrow]) .downArrow {
-      border-top: 0.36em solid #ccc;
-      border-left: 0.36em solid transparent;
-      border-right: 0.36em solid transparent;
-      margin-bottom: var(--spacing-xxs);
-      margin-left: var(--spacing-m);
-      transition: border-top-color 200ms;
-    }
-    :host([down-arrow]) paper-button:hover .downArrow {
-      border-top-color: var(--deemphasized-text-color);
-    }
-  </style>
-  <paper-button
-    raised="[[!link]]"
-    disabled="[[_disabled]]"
-    tabindex="-1"
-    part="paper-button"
-    class$="[[computePaperButtonClass(voteChip)]]"
-  >
-    <template is="dom-if" if="[[loading]]">
-      <span class="loadingSpin"></span>
-    </template>
-    <slot></slot>
-    <i class="downArrow"></i>
-  </paper-button>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
index f0f122a..0149bd5 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
@@ -17,6 +17,7 @@
 
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import '../../../test/common-test-setup-karma';
+import './gr-button';
 import {addListener} from '@polymer/polymer/lib/utils/gestures';
 import {appContext} from '../../../services/app-context';
 import {html} from '@polymer/polymer/lib/utils/html-tag';
@@ -49,23 +50,26 @@
     return spy;
   };
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
+    await element.updateComplete;
   });
 
-  test('disabled is set by disabled', () => {
+  test('disabled is set by disabled', async () => {
     const paperBtn = queryAndAssert<PaperButtonElement>(
       element,
       'paper-button'
     );
     assert.isFalse(paperBtn.disabled);
     element.disabled = true;
+    await element.updateComplete;
     assert.isTrue(paperBtn.disabled);
     element.disabled = false;
+    await element.updateComplete;
     assert.isFalse(paperBtn.disabled);
   });
 
-  test('loading set from listener', () => {
+  test('loading set from listener', async () => {
     let resolve: Function;
     element.addEventListener('click', e => {
       const target = e.target as HTMLElement;
@@ -78,36 +82,44 @@
     );
     assert.isFalse(paperBtn.disabled);
     MockInteractions.tap(element);
+    await element.updateComplete;
     assert.isTrue(paperBtn.disabled);
     assert.isTrue(element.hasAttribute('loading'));
     resolve!();
-    flush();
+    await element.updateComplete;
     assert.isFalse(paperBtn.disabled);
     assert.isFalse(element.hasAttribute('loading'));
   });
 
-  test('tabindex should be -1 if disabled', () => {
+  test('tabindex should be -1 if disabled', async () => {
     element.disabled = true;
-    assert.isTrue(element.getAttribute('tabindex') === '-1');
+    await element.updateComplete;
+    assert.equal(element.getAttribute('tabindex'), '-1');
   });
 
   // Regression tests for Issue: 11969
-  test('tabindex should be reset to 0 if enabled', () => {
+  test('tabindex should be reset to 0 if enabled', async () => {
     element.disabled = false;
+    await element.updateComplete;
     assert.equal(element.getAttribute('tabindex'), '0');
     element.disabled = true;
+    await element.updateComplete;
     assert.equal(element.getAttribute('tabindex'), '-1');
     element.disabled = false;
+    await element.updateComplete;
     assert.equal(element.getAttribute('tabindex'), '0');
   });
 
-  test('tabindex should be preserved', () => {
+  test('tabindex should be preserved', async () => {
     const tabIndexElement = tabindexFixture.instantiate() as GrButton;
     tabIndexElement.disabled = false;
+    await element.updateComplete;
     assert.equal(tabIndexElement.getAttribute('tabindex'), '3');
     tabIndexElement.disabled = true;
+    await element.updateComplete;
     assert.equal(tabIndexElement.getAttribute('tabindex'), '-1');
     tabIndexElement.disabled = false;
+    await element.updateComplete;
     assert.equal(tabIndexElement.getAttribute('tabindex'), '3');
   });
 
@@ -152,8 +164,9 @@
   }
 
   suite('disabled', () => {
-    setup(() => {
+    setup(async () => {
       element.disabled = true;
+      await element.updateComplete;
     });
 
     for (const eventName of ['tap', 'click']) {
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
index 2ca2744b..455bd4e 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
@@ -80,8 +80,8 @@
     }
   </style>
   <gr-tooltip-content
-    has-tooltip=""
-    position-below=""
+    has-tooltip
+    position-below
     title="[[tooltipText]]"
     max-width="40em"
   >
@@ -101,9 +101,8 @@
       </a>
     </template>
     <template is="dom-if" if="[[!hasStatusLink(revertedChange, resolveWeblinks, status)]]">
-      <div class="chip" aria-label$="Label: [[status]]">
-        [[_computeStatusString(status)]]
-      </div>
+      <div class="chip" aria-label$="Label: [[status]]"
+      >[[_computeStatusString(status)]]</div>
     </template>
   </gr-tooltip-content>
 </span>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 82f6b46..b37613a 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -14,6 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
 import '../gr-comment/gr-comment';
 import '../../diff/gr-diff/gr-diff';
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 bf9a9dc..0752a82 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-a11y-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       font-family: var(--font-family);
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 418cd0e..fa04860 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -20,7 +20,6 @@
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../gr-button/gr-button';
 import '../gr-dialog/gr-dialog';
-import '../gr-date-formatter/gr-date-formatter';
 import '../gr-formatted-text/gr-formatted-text';
 import '../gr-icons/gr-icons';
 import '../gr-overlay/gr-overlay';
@@ -675,7 +674,13 @@
 
   @observe('comment.message')
   _commentMessageChanged(message: string) {
-    this._messageText = message || '';
+    /*
+     * Only overwrite the message text user has typed if there is no existing
+     * text typed by the user. This prevents the bug where creating another
+     * comment triggered a recomputation of comments and the text written by
+     * the user was lost.
+     */
+    if (!this._messageText) this._messageText = message || '';
   }
 
   _messageTextChanged(_: string, oldValue: string) {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
index b00bf8b..d1496fb 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
@@ -275,10 +275,10 @@
         </template>
         <gr-tooltip-content
           class="draftTooltip"
-          has-tooltip=""
+          has-tooltip
           title="[[_computeDraftTooltip(_unableToSave)]]"
           max-width="20em"
-          show-icon=""
+          show-icon
         >
           <span class="draftLabel">[[_computeDraftText(_unableToSave)]]</span>
         </gr-tooltip-content>
@@ -313,7 +313,7 @@
       <template is="dom-if" if="[[comment.updated]]">
         <span class="date" tabindex="0" on-click="_handleAnchorClick">
           <gr-date-formatter
-            has-tooltip=""
+            withTooltip
             date-str="[[comment.updated]]"
           ></gr-date-formatter>
         </span>
@@ -357,7 +357,7 @@
           <div class="respectfulReviewTip">
             <div>
               <gr-tooltip-content
-                has-tooltip=""
+                has-tooltip
                 title="Tips for respectful code reviews."
               >
                 <iron-icon
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 31a1614..b963d4b 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -217,6 +217,29 @@
       assert.isTrue(storageStub.called);
     });
 
+    test('comment message sets messageText only when empty', () => {
+      element.changeNum = 1 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      element._messageText = '';
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        line: 5,
+        path: 'test',
+        __editing: true,
+        __draft: true,
+        message: 'hello world',
+      };
+      // messageText was empty so overwrite the message now
+      assert.equal(element._messageText, 'hello world');
+
+      element.comment!.message = 'new message';
+      // messageText was already set so do not overwrite it
+      assert.equal(element._messageText, 'hello world');
+    });
+
     test('_getPatchNum', () => {
       element.side = 'PARENT';
       element.patchNum = 1 as PatchSetNum;
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
index 2e95bd0..0cd522a 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -89,6 +89,7 @@
   override render() {
     // To pass CSS mixins for @apply to Polymer components, they need to appear
     // in <style> inside the template.
+    /* eslint-disable lit/prefer-static-styles */
     const customStyle = html`
       <style>
         iron-icon {
@@ -109,7 +110,7 @@
           type="text"
           @click="${this._handleInputClick}"
           readonly=""
-          bind-value=${this.text}
+          bind-value=${this.text || ''}
         >
           <input
             id="input"
@@ -118,21 +119,24 @@
             type="text"
             @click="${this._handleInputClick}"
             readonly=""
-            .value=${this.text}
+            .value=${this.text || ''}
             part="text-container-style"
           />
         </iron-input>
-        <gr-button
-          id="copy-clipboard-button"
-          link=""
+        <gr-tooltip-content
           ?has-tooltip=${this.hasTooltip}
-          class="copyToClipboard"
           title="${ifDefined(this.buttonTitle)}"
-          @click="${this._copyToClipboard}"
-          aria-label="Click to copy to clipboard"
         >
-          <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
-        </gr-button>
+          <gr-button
+            id="copy-clipboard-button"
+            link=""
+            class="copyToClipboard"
+            @click="${this._copyToClipboard}"
+            aria-label="Click to copy to clipboard"
+          >
+            <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
+          </gr-button>
+        </gr-tooltip-content>
       </div> `;
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
index a3800a8..99f9265 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
@@ -14,11 +14,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-date-formatter_html';
-import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
-import {property, customElement} from '@polymer/decorators';
+import '../gr-tooltip-content/gr-tooltip-content';
+import {css, html, LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators';
 import {
   parseDate,
   fromNow,
@@ -75,17 +73,10 @@
   }
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = TooltipMixin(PolymerElement);
-
 @customElement('gr-date-formatter')
-export class GrDateFormatter extends base {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: String, notify: true})
-  dateStr: string | null = null;
+export class GrDateFormatter extends LitElement {
+  @property({type: String})
+  dateStr: string | undefined = undefined;
 
   @property({type: Boolean})
   showDateAndTime = false;
@@ -95,30 +86,20 @@
    * native browser tooltip.
    */
   @property({type: Boolean})
-  override hasTooltip = false;
+  withTooltip = false;
 
   @property({type: Boolean})
   showYesterday = false;
 
-  /**
-   * The title to be used as the native tooltip or by the tooltip behavior.
-   */
-  @property({
-    type: String,
-    reflectToAttribute: true,
-    computed: '_computeFullDateStr(dateStr, _timeFormat, _dateFormat)',
-  })
-  override title = '';
-
   /** @type {?{short: string, full: string}} */
   @property({type: Object})
-  _dateFormat?: DateFormatPair;
+  private dateFormat?: DateFormatPair;
 
   @property({type: String})
-  _timeFormat?: string;
+  private timeFormat?: string;
 
   @property({type: Boolean})
-  _relative = false;
+  private relative = false;
 
   @property({type: Boolean})
   forceRelative = false;
@@ -132,76 +113,110 @@
     super();
   }
 
+  static override get styles() {
+    return [
+      css`
+        host {
+          color: inherit;
+          display: inline;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.withTooltip) {
+      return this.renderDateString();
+    }
+
+    const fullDateStr = this.computeFullDateStr();
+    if (!fullDateStr) {
+      return this.renderDateString();
+    }
+    return html`
+      <gr-tooltip-content has-tooltip title=${fullDateStr}>
+        ${this.renderDateString()}
+      </gr-tooltip-content>
+    `;
+  }
+
+  private renderDateString() {
+    return html` <span>${this._computeDateStr()}</span>`;
+  }
+
   override connectedCallback() {
     super.connectedCallback();
     this._loadPreferences();
   }
 
+  // private but used by tests
   _getUtcOffsetString() {
     return utcOffsetString();
   }
 
+  // private but used by tests
   _loadPreferences() {
     return this._getLoggedIn().then(loggedIn => {
       if (!loggedIn) {
-        this._timeFormat = TimeFormats.TIME_24;
-        this._dateFormat = DateFormats.STD;
-        this._relative = this.forceRelative;
+        this.timeFormat = TimeFormats.TIME_24;
+        this.dateFormat = DateFormats.STD;
+        this.relative = this.forceRelative;
         return;
       }
-      return Promise.all([this._loadTimeFormat(), this._loadRelative()]);
+      return Promise.all([this._loadTimeFormat(), this.loadRelative()]);
     });
   }
 
+  // private but used in gr/file-list_test.js
   _loadTimeFormat() {
-    return this._getPreferences().then(preferences => {
+    return this.getPreferences().then(preferences => {
       if (!preferences) {
         throw Error('Preferences is not set');
       }
-      this._decideTimeFormat(preferences.time_format);
-      this._decideDateFormat(preferences.date_format);
+      this.decideTimeFormat(preferences.time_format);
+      this.decideDateFormat(preferences.date_format);
     });
   }
 
-  _decideTimeFormat(timeFormat: TimeFormat) {
+  private decideTimeFormat(timeFormat: TimeFormat) {
     switch (timeFormat) {
       case TimeFormat.HHMM_12:
-        this._timeFormat = TimeFormats.TIME_12;
+        this.timeFormat = TimeFormats.TIME_12;
         break;
       case TimeFormat.HHMM_24:
-        this._timeFormat = TimeFormats.TIME_24;
+        this.timeFormat = TimeFormats.TIME_24;
         break;
       default:
         assertNever(timeFormat, `Invalid time format: ${timeFormat}`);
     }
   }
 
-  _decideDateFormat(dateFormat: DateFormat) {
+  private decideDateFormat(dateFormat: DateFormat) {
     switch (dateFormat) {
       case DateFormat.STD:
-        this._dateFormat = DateFormats.STD;
+        this.dateFormat = DateFormats.STD;
         break;
       case DateFormat.US:
-        this._dateFormat = DateFormats.US;
+        this.dateFormat = DateFormats.US;
         break;
       case DateFormat.ISO:
-        this._dateFormat = DateFormats.ISO;
+        this.dateFormat = DateFormats.ISO;
         break;
       case DateFormat.EURO:
-        this._dateFormat = DateFormats.EURO;
+        this.dateFormat = DateFormats.EURO;
         break;
       case DateFormat.UK:
-        this._dateFormat = DateFormats.UK;
+        this.dateFormat = DateFormats.UK;
         break;
       default:
         assertNever(dateFormat, `Invalid date format: ${dateFormat}`);
     }
   }
 
-  _loadRelative() {
-    return this._getPreferences().then(prefs => {
+  private loadRelative() {
+    return this.getPreferences().then(prefs => {
       // prefs.relative_date_in_change_table is not set when false.
-      this._relative =
+      this.relative =
         this.forceRelative || !!(prefs && prefs.relative_date_in_change_table);
     });
   }
@@ -210,70 +225,60 @@
     return this.restApiService.getLoggedIn();
   }
 
-  _getPreferences() {
+  private getPreferences() {
     return this.restApiService.getPreferences();
   }
 
-  _computeDateStr(
-    dateStr?: Timestamp,
-    timeFormat?: string,
-    dateFormat?: DateFormatPair,
-    relative?: boolean,
-    showDateAndTime?: boolean,
-    showYesterday?: boolean
-  ) {
-    if (!dateStr || !timeFormat || !dateFormat) {
+  // private but used by tests
+  _computeDateStr() {
+    if (!this.dateStr || !this.timeFormat || !this.dateFormat) {
       return '';
     }
-    const date = parseDate(dateStr);
+    const date = parseDate(this.dateStr as Timestamp);
     if (!isValidDate(date)) {
       return '';
     }
-    if (relative) {
+    if (this.relative) {
       return fromNow(date, this.relativeOptionNoAgo);
     }
     const now = new Date();
-    let format = dateFormat.full;
+    let format = this.dateFormat.full;
     if (isWithinDay(now, date)) {
-      format = timeFormat;
-    } else if (showYesterday && wasYesterday(now, date)) {
-      return `Yesterday at ${formatDate(date, timeFormat)}`;
+      format = this.timeFormat;
+    } else if (this.showYesterday && wasYesterday(now, date)) {
+      return `Yesterday at ${formatDate(date, this.timeFormat)}`;
     } else {
       if (isWithinHalfYear(now, date)) {
-        format = dateFormat.short;
+        format = this.dateFormat.short;
       }
-      if (this.showDateAndTime || showDateAndTime) {
-        format = `${format} ${timeFormat}`;
+      if (this.showDateAndTime || this.showDateAndTime) {
+        format = `${format} ${this.timeFormat}`;
       }
     }
     return formatDate(date, format);
   }
 
-  _timeToSecondsFormat(timeFormat: string | undefined) {
-    return timeFormat === TimeFormats.TIME_12
-      ? TimeFormats.TIME_12_WITH_SEC
-      : TimeFormats.TIME_24_WITH_SEC;
-  }
-
-  _computeFullDateStr(
-    dateStr?: Timestamp,
-    timeFormat?: string,
-    dateFormat?: DateFormatPair
-  ) {
+  private computeFullDateStr() {
     // Polymer 2: check for undefined
-    if ([dateStr, timeFormat].includes(undefined) || !dateFormat) {
+    if (
+      [this.dateStr, this.timeFormat].includes(undefined) ||
+      !this.dateFormat
+    ) {
       return undefined;
     }
 
-    if (!dateStr) {
+    if (!this.dateStr) {
       return '';
     }
-    const date = parseDate(dateStr);
+    const date = parseDate(this.dateStr as Timestamp);
     if (!isValidDate(date)) {
       return '';
     }
-    let format = dateFormat.full + ', ';
-    format += this._timeToSecondsFormat(timeFormat);
+    let format = this.dateFormat.full + ', ';
+    format +=
+      this.timeFormat === TimeFormats.TIME_12
+        ? TimeFormats.TIME_12_WITH_SEC
+        : TimeFormats.TIME_24_WITH_SEC;
     return formatDate(date, format) + this._getUtcOffsetString();
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts
deleted file mode 100644
index 4808832..0000000
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    :host {
-      color: inherit;
-      display: inline;
-    }
-  </style>
-  <span>
-    [[_computeDateStr(dateStr, _timeFormat, _dateFormat, _relative,
-    showDateAndTime, showYesterday)]]
-  </span>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
index 9a96c2d..860a7e7 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
@@ -22,14 +22,18 @@
 import {stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromTemplate(html`
-<gr-date-formatter date-str="2015-09-24 23:30:17.033000000"></gr-date-formatter>
+<gr-date-formatter withTooltip dateStr="2015-09-24 23:30:17.033000000">
+</gr-date-formatter>
+`);
+
+const lightFixture = fixtureFromTemplate(html`
+<gr-date-formatter dateStr="2015-09-24 23:30:17.033000000"></gr-date-formatter>
 `);
 
 suite('gr-date-formatter tests', () => {
   let element;
 
   setup(() => {
-
   });
 
   /**
@@ -41,7 +45,7 @@
     return d;
   }
 
-  function testDates(nowStr, dateStr, expected, expectedWithDateAndTime,
+  async function testDates(nowStr, dateStr, expected, expectedWithDateAndTime,
       expectedTooltip) {
     // Normalize and convert the date to mimic server response.
     dateStr = normalizedDate(dateStr)
@@ -50,13 +54,13 @@
         .slice(0, -1);
     sinon.useFakeTimers(normalizedDate(nowStr).getTime());
     element.dateStr = dateStr;
-    flush();
-    const span = element.shadowRoot
-        .querySelector('span');
+    await element.updateComplete;
+    const span = element.shadowRoot.querySelector('span');
+    const tooltip = element.shadowRoot.querySelector('gr-tooltip-content');
     assert.equal(span.textContent.trim(), expected);
-    assert.equal(element.title, expectedTooltip);
+    assert.equal(tooltip.title, expectedTooltip);
     element.showDateAndTime = true;
-    flush();
+    await element.updateComplete;
     assert.equal(span.textContent.trim(), expectedWithDateAndTime);
   }
 
@@ -81,35 +85,37 @@
 
     test('invalid dates are quietly rejected', () => {
       assert.notOk((new Date('foo')).valueOf());
-      assert.equal(element._computeDateStr('foo', 'h:mm A'), '');
+      element.dateStr = 'foo';
+      element.timeFormat = 'h:mm A';
+      assert.equal(element._computeDateStr(), '');
     });
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '15:34',
           '15:34',
           'Jul 29, 2015, 15:34:14');
     });
 
-    test('Within 24 hours on different days', () => {
-      testDates('2015-07-29 03:34:14.985000000',
+    test('Within 24 hours on different days', async () => {
+      await testDates('2015-07-29 03:34:14.985000000',
           '2015-07-28 20:25:14.985000000',
           'Jul 28',
           'Jul 28 20:25',
           'Jul 28, 2015, 20:25:14');
     });
 
-    test('More than 24 hours but less than six months', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('More than 24 hours but less than six months', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-06-15 03:25:14.985000000',
           'Jun 15',
           'Jun 15 03:25',
           'Jun 15, 2015, 03:25:14');
     });
 
-    test('More than six months', () => {
-      testDates('2015-09-15 20:34:00.000000000',
+    test('More than six months', async () => {
+      await testDates('2015-09-15 20:34:00.000000000',
           '2015-01-15 03:25:00.000000000',
           'Jan 15, 2015',
           'Jan 15, 2015 03:25',
@@ -128,24 +134,24 @@
       return element._loadPreferences();
     }));
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '15:34',
           '15:34',
           '07/29/15, 15:34:14');
     });
 
-    test('Within 24 hours on different days', () => {
-      testDates('2015-07-29 03:34:14.985000000',
+    test('Within 24 hours on different days', async () => {
+      await testDates('2015-07-29 03:34:14.985000000',
           '2015-07-28 20:25:14.985000000',
           '07/28',
           '07/28 20:25',
           '07/28/15, 20:25:14');
     });
 
-    test('More than 24 hours but less than six months', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('More than 24 hours but less than six months', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-06-15 03:25:14.985000000',
           '06/15',
           '06/15 03:25',
@@ -164,24 +170,24 @@
       return element._loadPreferences();
     }));
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '15:34',
           '15:34',
           '2015-07-29, 15:34:14');
     });
 
-    test('Within 24 hours on different days', () => {
-      testDates('2015-07-29 03:34:14.985000000',
+    test('Within 24 hours on different days', async () => {
+      await testDates('2015-07-29 03:34:14.985000000',
           '2015-07-28 20:25:14.985000000',
           '07-28',
           '07-28 20:25',
           '2015-07-28, 20:25:14');
     });
 
-    test('More than 24 hours but less than six months', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('More than 24 hours but less than six months', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-06-15 03:25:14.985000000',
           '06-15',
           '06-15 03:25',
@@ -200,24 +206,24 @@
       return element._loadPreferences();
     }));
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '15:34',
           '15:34',
           '29.07.2015, 15:34:14');
     });
 
-    test('Within 24 hours on different days', () => {
-      testDates('2015-07-29 03:34:14.985000000',
+    test('Within 24 hours on different days', async () => {
+      await testDates('2015-07-29 03:34:14.985000000',
           '2015-07-28 20:25:14.985000000',
           '28. Jul',
           '28. Jul 20:25',
           '28.07.2015, 20:25:14');
     });
 
-    test('More than 24 hours but less than six months', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('More than 24 hours but less than six months', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-06-15 03:25:14.985000000',
           '15. Jun',
           '15. Jun 03:25',
@@ -236,24 +242,24 @@
       return element._loadPreferences();
     }));
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '15:34',
           '15:34',
           '29/07/2015, 15:34:14');
     });
 
-    test('Within 24 hours on different days', () => {
-      testDates('2015-07-29 03:34:14.985000000',
+    test('Within 24 hours on different days', async () => {
+      await testDates('2015-07-29 03:34:14.985000000',
           '2015-07-28 20:25:14.985000000',
           '28/07',
           '28/07 20:25',
           '28/07/2015, 20:25:14');
     });
 
-    test('More than 24 hours but less than six months', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('More than 24 hours but less than six months', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-06-15 03:25:14.985000000',
           '15/06',
           '15/06 03:25',
@@ -273,8 +279,8 @@
       })
     );
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '3:34 PM',
           '3:34 PM',
@@ -294,8 +300,8 @@
       })
     );
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '3:34 PM',
           '3:34 PM',
@@ -315,8 +321,8 @@
       })
     );
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '3:34 PM',
           '3:34 PM',
@@ -336,8 +342,8 @@
       })
     );
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '3:34 PM',
           '3:34 PM',
@@ -357,8 +363,8 @@
       })
     );
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '3:34 PM',
           '3:34 PM',
@@ -377,16 +383,16 @@
       return element._loadPreferences();
     }));
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '5 hours ago',
           '5 hours ago',
           'Jul 29, 2015, 3:34:14 PM');
     });
 
-    test('More than six months', () => {
-      testDates('2015-09-15 20:34:00.000000000',
+    test('More than six months', async () => {
+      await testDates('2015-09-15 20:34:00.000000000',
           '2015-01-15 03:25:00.000000000',
           '8 months ago',
           '8 months ago',
@@ -405,10 +411,10 @@
     }));
 
     test('Preferences are respected', () => {
-      assert.equal(element._timeFormat, 'h:mm A');
-      assert.equal(element._dateFormat.short, 'MM/DD');
-      assert.equal(element._dateFormat.full, 'MM/DD/YY');
-      assert.isTrue(element._relative);
+      assert.equal(element.timeFormat, 'h:mm A');
+      assert.equal(element.dateFormat.short, 'MM/DD');
+      assert.equal(element.dateFormat.full, 'MM/DD/YY');
+      assert.isTrue(element.relative);
     });
   });
 
@@ -419,10 +425,38 @@
     }));
 
     test('Default preferences are respected', () => {
-      assert.equal(element._timeFormat, 'HH:mm');
-      assert.equal(element._dateFormat.short, 'MMM DD');
-      assert.equal(element._dateFormat.full, 'MMM DD, YYYY');
-      assert.isFalse(element._relative);
+      assert.equal(element.timeFormat, 'HH:mm');
+      assert.equal(element.dateFormat.short, 'MMM DD');
+      assert.equal(element.dateFormat.full, 'MMM DD, YYYY');
+      assert.isFalse(element.relative);
+    });
+  });
+
+  suite('with tooltip', () => {
+    setup(async () => {
+      await stubRestAPI(null);
+      element = basicFixture.instantiate();
+      await element._loadPreferences();
+      await element.updateComplete;
+    });
+
+    test('Tooltip is present', () => {
+      const tooltip = element.shadowRoot.querySelector('gr-tooltip-content');
+      assert.isOk(tooltip);
+    });
+  });
+
+  suite('without tooltip', () => {
+    setup(async () => {
+      await stubRestAPI(null);
+      element = lightFixture.instantiate();
+      await element._loadPreferences();
+      await element.updateComplete;
+    });
+
+    test('Tooltip is absent', () => {
+      const tooltip = element.shadowRoot.querySelector('gr-tooltip-content');
+      assert.isNotOk(tooltip);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
index 18a46a0..9ec1d39 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
@@ -123,6 +123,7 @@
     class="dropdown-trigger"
     on-click="_showDropdownTapHandler"
     slot="dropdown-trigger"
+    no-uppercase
   >
     <span id="triggerText">[[text]]</span>
     <gr-copy-clipboard
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
index f1f6bf8..076553b 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
@@ -128,7 +128,7 @@
             <span class="value">[[_computeReason(change)]]</span>
             <template is="dom-if" if="[[_computeLastUpdate(change)]]">
               (<gr-date-formatter
-                has-tooltip
+                withTooltip
                 date-str="[[_computeLastUpdate(change)]]"
               ></gr-date-formatter
               >)
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
index e9a224c..82f64d0 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
@@ -109,7 +109,7 @@
     stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
     const reloadListener = sinon.spy();
     element._target.addEventListener('reload', reloadListener);
-    flush();
+    await flush();
     const button = element.shadowRoot.querySelector('.removeReviewerOrCC');
     assert.isOk(button);
     assert.equal(button.innerText, 'Remove Reviewer');
@@ -132,7 +132,7 @@
     const reloadListener = sinon.spy();
     element._target.addEventListener('reload', reloadListener);
 
-    flush();
+    await flush();
     const button = element.shadowRoot.querySelector('.changeReviewerOrCC');
 
     assert.isOk(button);
@@ -156,7 +156,7 @@
     stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
     const reloadListener = sinon.spy();
     element._target.addEventListener('reload', reloadListener);
-    flush();
+    await flush();
 
     const button = element.shadowRoot.querySelector('.changeReviewerOrCC');
     assert.isOk(button);
@@ -180,7 +180,7 @@
     const reloadListener = sinon.spy();
     element._target.addEventListener('reload', reloadListener);
 
-    flush();
+    await flush();
     const button = element.shadowRoot.querySelector('.removeReviewerOrCC');
 
     assert.equal(button.innerText, 'Remove CC');
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
index 203784d..87f6052 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
@@ -125,7 +125,7 @@
           .querySelector('[data-action-key="' + key + '"]'));
     });
 
-    test('action button properties', () => {
+    test('action button properties', async () => {
       const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
       flush();
       const button = element.shadowRoot
@@ -137,17 +137,17 @@
       changeActions.setTitle(key, 'Yo hint');
       changeActions.setEnabled(key, false);
       changeActions.setIcon(key, 'pupper');
-      flush();
+      await flush();
       assert.equal(button.getAttribute('data-label'), 'Yo');
-      assert.equal(button.getAttribute('title'), 'Yo hint');
+      assert.equal(button.parentElement.getAttribute('title'), 'Yo hint');
       assert.isTrue(button.disabled);
       assert.equal(button.querySelector('iron-icon').icon,
           'gr-icons:pupper');
     });
 
-    test('hide action buttons', () => {
+    test('hide action buttons', async () => {
       const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      flush();
+      await flush();
       let button = element.shadowRoot
           .querySelector('[data-action-key="' + key + '"]');
       assert.isOk(button);
@@ -168,7 +168,7 @@
           .querySelector('[data-action-key="' + key + '"]'));
       changeActions.setActionOverflow(
           changeActions.ActionType.REVISION, key, true);
-      flush();
+      await flush();
       assert.isNotOk(element.shadowRoot
           .querySelector('[data-action-key="' + key + '"]'));
       assert.isFalse(element.$.moreActions.hidden);
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 85491ef..1d4bd06 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
@@ -34,6 +34,7 @@
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {formStyles} from '../../../styles/gr-form-styles';
 import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
 import {subpageStyles} from '../../../styles/gr-subpage-styles';
 import {tableStyles} from '../../../styles/gr-table-styles';
 
@@ -125,6 +126,7 @@
     font: fontStyles,
     form: formStyles,
     menuPage: menuPageStyles,
+    spinner: spinnerStyles,
     subPage: subpageStyles,
     table: tableStyles,
   };
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index e01c5d3..dba36a4 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -22,6 +22,7 @@
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
 import '../gr-label/gr-label';
+import '../gr-tooltip-content/gr-tooltip-content';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-label-info_html';
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
index f31b57f..552bd08 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
@@ -101,13 +101,14 @@
     >
       <tr class="labelValueContainer">
         <td>
-          <gr-label
-            has-tooltip=""
+          <gr-tooltip-content
+            has-tooltip
             title="[[_computeValueTooltip(labelInfo, mappedLabel.value)]]"
-            class$="[[mappedLabel.className]] voteChip font-small"
           >
-            [[mappedLabel.value]]
-          </gr-label>
+            <gr-label class$="[[mappedLabel.className]] voteChip font-small">
+              [[mappedLabel.value]]
+            </gr-label>
+          </gr-tooltip-content>
         </td>
         <td>
           <gr-account-link
@@ -116,16 +117,17 @@
           ></gr-account-link>
         </td>
         <td>
-          <gr-button
-            link=""
-            aria-label="Remove vote"
-            on-click="_onDeleteVote"
-            tooltip="Remove vote"
-            data-account-id$="[[mappedLabel.account._account_id]]"
-            class$="deleteBtn [[_computeDeleteClass(mappedLabel.account, mutable, change)]]"
-          >
-            <iron-icon icon="gr-icons:delete"></iron-icon>
-          </gr-button>
+          <gr-tooltip-content has-tooltip title="Remove vote">
+            <gr-button
+              link=""
+              aria-label="Remove vote"
+              on-click="_onDeleteVote"
+              data-account-id$="[[mappedLabel.account._account_id]]"
+              class$="deleteBtn [[_computeDeleteClass(mappedLabel.account, mutable, change)]]"
+            >
+              <iron-icon icon="gr-icons:delete"></iron-icon>
+            </gr-button>
+          </gr-tooltip-content>
         </td>
       </tr>
     </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
index fd10145..842b35e 100644
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
@@ -21,10 +21,8 @@
  * used in gr-label-info.
  */
 
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement} from '@polymer/decorators';
-import {htmlTemplate} from './gr-label_html';
-import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
+import {html, LitElement} from 'lit';
+import {customElement} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -32,12 +30,13 @@
   }
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = TooltipMixin(PolymerElement);
-
 @customElement('gr-label')
-export class GrLabel extends base {
-  static get template() {
-    return htmlTemplate;
+export class GrLabel extends LitElement {
+  static override get styles() {
+    return [];
+  }
+
+  override render() {
+    return html` <slot></slot> `;
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
index f5a0c4d..7008db2 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
@@ -14,10 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-limited-text_html';
-import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
-import {customElement, observe, property} from '@polymer/decorators';
+import {customElement, property} from 'lit/decorators';
+import {html, LitElement} from 'lit';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -25,9 +23,6 @@
   }
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = TooltipMixin(PolymerElement);
-
 /**
  * The gr-limited-text element is for displaying text with a maximum length
  * (in number of characters) to display. If the length of the text exceeds the
@@ -35,57 +30,56 @@
  * and a tooltip containing the full text is enabled.
  */
 @customElement('gr-limited-text')
-export class GrLimitedText extends base {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrLimitedText extends LitElement {
   /** The un-truncated text to display. */
   @property({type: String})
-  text?: string;
+  text = '';
 
   /** The maximum length for the text to display before truncating. */
   @property({type: Number})
-  limit?: number;
+  limit = 0;
 
   @property({type: String})
   tooltip?: string;
 
-  /** Boolean property used by TooltipMixin. */
-  @property({type: Boolean})
-  override hasTooltip = false;
+  static override get styles() {
+    return [];
+  }
 
-  /** Boolean property used by TooltipMixin. */
-  @property({type: Boolean})
-  disableTooltip = false;
-
-  /**
-   * The text or limit have changed. Recompute whether a tooltip needs to be
-   * enabled.
-   */
-  @observe('text', 'tooltip', 'limit')
-  _updateTitle(text?: string, tooltip?: string, limit?: number) {
-    text = text ?? '';
-    tooltip = tooltip ?? '';
-    limit = limit ?? 0;
-
-    this.hasTooltip = !!tooltip || (!!limit && text.length > limit);
-    if (this.hasTooltip && !this.disableTooltip) {
-      // Combine the text and title if over-length
-      if (limit && text.length > limit) {
-        this.title = `${text}${tooltip ? ` (${tooltip})` : ''}`;
-      } else {
-        this.title = tooltip;
-      }
+  override render() {
+    if (this.tooltip || this.tooLong()) {
+      return html` <gr-tooltip-content
+        has-tooltip
+        .title=${this.renderTooltip()}
+      >
+        ${this.renderText()}
+      </gr-tooltip-content>`;
     } else {
-      this.title = '';
+      return this.renderText();
     }
   }
 
-  _computeDisplayText(text?: string, limit?: number) {
-    if (!!limit && !!text && text.length > limit) {
-      return text.substr(0, limit - 1) + '…';
+  // Should be private but used in tests.
+  renderText() {
+    if (this.tooLong()) {
+      return this.text.substr(0, this.limit - 1) + '…';
     }
-    return text;
+    return this.text;
+  }
+
+  private renderTooltip() {
+    if (this.tooLong()) {
+      return `${this.text}${this.tooltip ? ` (${this.tooltip})` : ''}`;
+    } else if (this.tooltip) {
+      return this.tooltip;
+    } else {
+      return '';
+    }
+  }
+
+  private tooLong() {
+    if (!this.limit) return false;
+    if (!this.text) return false;
+    return this.text.length > this.limit;
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.ts b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.ts
deleted file mode 100644
index b942d07..0000000
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html` [[_computeDisplayText(text, limit)]] `;
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js
index 3b99d6d..e3e72d0 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js
@@ -23,77 +23,65 @@
 suite('gr-limited-text tests', () => {
   let element;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
+    await element.updateComplete;
   });
 
-  test('tooltip without title input', () => {
-    const updateSpy = sinon.spy(element, '_updateTitle');
+  test('tooltip without title input', async () => {
     element.text = 'abc 123';
-    flush();
-    assert.isTrue(updateSpy.calledOnce);
-    assert.isNotOk(element.getAttribute('title'));
-    assert.isFalse(element.hasTooltip);
+    await element.updateComplete;
+    assert.isNotOk(element.shadowRoot.querySelector('gr-tooltip-content'));
 
     element.limit = 10;
-    flush();
-    assert.isTrue(updateSpy.calledTwice);
-    assert.isNotOk(element.getAttribute('title'));
-    assert.isFalse(element.hasTooltip);
+    await element.updateComplete;
+    assert.isNotOk(element.shadowRoot.querySelector('gr-tooltip-content'));
 
     element.limit = 3;
-    flush();
-    assert.equal(updateSpy.callCount, 3);
-    assert.equal(element.getAttribute('title'), 'abc 123');
-    assert.equal(element.title, 'abc 123');
-    assert.isTrue(element.hasTooltip);
+    await element.updateComplete;
+    assert.isOk(element.shadowRoot.querySelector('gr-tooltip-content'));
+    assert.equal(
+        element.shadowRoot.querySelector('gr-tooltip-content').title,
+        'abc 123');
 
     element.limit = 100;
-    flush();
-    assert.equal(updateSpy.callCount, 4);
-    assert.isFalse(element.hasTooltip);
+    await element.updateComplete;
+    assert.isNotOk(element.shadowRoot.querySelector('gr-tooltip-content'));
 
     element.limit = null;
-    flush();
-    assert.equal(updateSpy.callCount, 5);
-    assert.isNotOk(element.getAttribute('title'));
-    assert.isFalse(element.hasTooltip);
+    await element.updateComplete;
+    assert.isNotOk(element.shadowRoot.querySelector('gr-tooltip-content'));
   });
 
-  test('with tooltip input', () => {
-    const updateSpy = sinon.spy(element, '_updateTitle');
+  test('with tooltip input', async () => {
     element.tooltip = 'abc 123';
-    flush();
-    assert.isTrue(updateSpy.calledOnce);
-    assert.isTrue(element.hasTooltip);
-    assert.equal(element.getAttribute('title'), 'abc 123');
-    assert.equal(element.title, 'abc 123');
+    await element.updateComplete;
+    let tooltipContent = element.shadowRoot.querySelector('gr-tooltip-content');
+    assert.isOk(tooltipContent);
+    assert.equal(tooltipContent.title, 'abc 123');
 
     element.text = 'abc';
-    flush();
-    assert.equal(element.getAttribute('title'), 'abc 123');
-    assert.isTrue(element.hasTooltip);
+    await element.updateComplete;
+    tooltipContent = element.shadowRoot.querySelector('gr-tooltip-content');
+    assert.isOk(tooltipContent);
+    assert.equal(tooltipContent.title, 'abc 123');
 
     element.text = 'abcdef';
     element.limit = 3;
-    flush();
-    assert.equal(element.getAttribute('title'), 'abcdef (abc 123)');
-    assert.isTrue(element.hasTooltip);
+    await element.updateComplete;
+    tooltipContent = element.shadowRoot.querySelector('gr-tooltip-content');
+    assert.isOk(tooltipContent);
+    assert.equal(tooltipContent.title, 'abcdef (abc 123)');
   });
 
   test('_computeDisplayText', () => {
-    assert.equal(element._computeDisplayText('foo bar', 100), 'foo bar');
-    assert.equal(element._computeDisplayText('foo bar', 4), 'foo…');
-    assert.equal(element._computeDisplayText('foo bar', null), 'foo bar');
-  });
-
-  test('when disable tooltip', () => {
-    sinon.spy(element, '_updateTitle');
-    element.text = 'abcdefghijklmn';
-    element.disableTooltip = true;
-    element.limit = 10;
-    flush();
-    assert.equal(element.getAttribute('title'), '');
+    element.text = 'foo bar';
+    element.limit = 100;
+    assert.equal(element.renderText(), 'foo bar');
+    element.limit = 4;
+    assert.equal(element.renderText(), 'foo…');
+    element.limit = 0;
+    assert.equal(element.renderText(), 'foo bar');
   });
 });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
index 9c6d947..801b8bf 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
@@ -87,6 +87,7 @@
   override render() {
     // To pass CSS mixins for @apply to Polymer components, they need to appear
     // in <style> inside the template.
+    /* eslint-disable lit/prefer-static-styles */
     const customStyle = html`
       <style>
         gr-button::part(paper-button),
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
index 9d228ab..2b9a868 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
@@ -15,10 +15,13 @@
  * limitations under the License.
  */
 import '../gr-icons/gr-icons';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-tooltip-content_html';
-import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
-import {customElement, property} from '@polymer/decorators';
+import '../gr-tooltip/gr-tooltip';
+import {getRootElement} from '../../../scripts/rootElement';
+import {GrTooltip} from '../gr-tooltip/gr-tooltip';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+
+const BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -26,21 +29,202 @@
   }
 }
 
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = TooltipMixin(PolymerElement);
-
-/**
- * Transclude anything inside and wrap them to support tooltip functionality.
- */
 @customElement('gr-tooltip-content')
-export class GrTooltipContent extends base {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrTooltipContent extends LitElement {
+  @property({type: Boolean, attribute: 'has-tooltip', reflect: true})
+  hasTooltip = false;
 
-  @property({type: String, reflectToAttribute: true})
+  @property({type: Boolean, attribute: 'position-below', reflect: true})
+  positionBelow = false;
+
+  @property({type: String, attribute: 'max-width', reflect: true})
   maxWidth?: string;
 
   @property({type: Boolean})
   showIcon = false;
+
+  // Should be private but used in tests.
+  @state()
+  isTouchDevice = 'ontouchstart' in document.documentElement;
+
+  // Should be private but used in tests.
+  tooltip: GrTooltip | null = null;
+
+  @state()
+  private originalTitle = '';
+
+  private hasSetupTooltipListeners = false;
+
+  private readonly windowScrollHandler: () => void;
+
+  private readonly showHandler: () => void;
+
+  private readonly hideHandler: (e: Event) => void;
+
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
+  constructor() {
+    super();
+    this.windowScrollHandler = () => this._handleWindowScroll();
+    this.showHandler = () => this._handleShowTooltip();
+    this.hideHandler = (e: Event | undefined) => this._handleHideTooltip(e);
+  }
+
+  override disconnectedCallback() {
+    this._handleHideTooltip(undefined);
+    this.removeEventListener('mouseenter', this.showHandler);
+    window.removeEventListener('scroll', this.windowScrollHandler);
+    super.disconnectedCallback();
+  }
+
+  static override get styles() {
+    return [
+      css`
+        iron-icon {
+          width: var(--line-height-normal);
+          height: var(--line-height-normal);
+          vertical-align: top;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <slot></slot>
+      ${this.renderIcon()}
+    `;
+  }
+
+  renderIcon() {
+    if (!this.showIcon) return;
+    return html`<iron-icon icon="gr-icons:info"></iron-icon>`;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('hasTooltip')) {
+      this.setupTooltipListeners();
+    }
+  }
+
+  private setupTooltipListeners() {
+    if (!this.hasTooltip) {
+      if (this.hasSetupTooltipListeners) {
+        // if attribute set to false, remove the listener
+        this.removeEventListener('mouseenter', this.showHandler);
+        this.hasSetupTooltipListeners = false;
+      }
+      return;
+    }
+
+    if (this.hasSetupTooltipListeners) {
+      return;
+    }
+    this.hasSetupTooltipListeners = true;
+    this.addEventListener('mouseenter', this.showHandler);
+  }
+
+  _handleShowTooltip() {
+    if (this.isTouchDevice) {
+      return;
+    }
+
+    if (
+      !this.hasAttribute('title') ||
+      this.getAttribute('title') === '' ||
+      this.tooltip
+    ) {
+      return;
+    }
+
+    // Store the title attribute text then set it to an empty string to
+    // prevent it from showing natively.
+    this.originalTitle = this.getAttribute('title') || '';
+    this.setAttribute('title', '');
+
+    const tooltip = document.createElement('gr-tooltip');
+    tooltip.text = this.originalTitle;
+    tooltip.maxWidth = this.getAttribute('max-width') || '';
+    tooltip.positionBelow = this.hasAttribute('position-below');
+
+    // Set visibility to hidden before appending to the DOM so that
+    // calculations can be made based on the element’s size.
+    tooltip.style.visibility = 'hidden';
+    getRootElement().appendChild(tooltip);
+    this._positionTooltip(tooltip);
+    tooltip.style.visibility = 'initial';
+
+    this.tooltip = tooltip;
+    window.addEventListener('scroll', this.windowScrollHandler);
+    this.addEventListener('mouseleave', this.hideHandler);
+    this.addEventListener('click', this.hideHandler);
+    tooltip.addEventListener('mouseleave', this.hideHandler);
+  }
+
+  _handleHideTooltip(e: Event | undefined) {
+    if (this.isTouchDevice) {
+      return;
+    }
+    if (!this.hasAttribute('title') || !this.originalTitle) {
+      return;
+    }
+    // Do not hide if mouse left this or this.tooltip and came to this or
+    // this.tooltip
+    if (
+      (e as MouseEvent)?.relatedTarget === this.tooltip ||
+      (e as MouseEvent)?.relatedTarget === this
+    ) {
+      return;
+    }
+
+    window.removeEventListener('scroll', this.windowScrollHandler);
+    this.removeEventListener('mouseleave', this.hideHandler);
+    this.removeEventListener('click', this.hideHandler);
+    this.setAttribute('title', this.originalTitle);
+    this.tooltip?.removeEventListener('mouseleave', this.hideHandler);
+
+    if (this.tooltip?.parentNode) {
+      this.tooltip.parentNode.removeChild(this.tooltip);
+    }
+    this.tooltip = null;
+  }
+
+  _handleWindowScroll() {
+    if (!this.tooltip) {
+      return;
+    }
+    // This wait is needed for tooltips to be positioned correctly in Firefox
+    // and Safari.
+    this.updateComplete.then(() => this._positionTooltip(this.tooltip));
+  }
+
+  // private but used in tests.
+  async _positionTooltip(tooltip: GrTooltip | null) {
+    if (tooltip === null) return;
+    const rect = this.getBoundingClientRect();
+    const boxRect = tooltip.getBoundingClientRect();
+    if (!tooltip.parentElement) {
+      return;
+    }
+    const parentRect = tooltip.parentElement.getBoundingClientRect();
+    const top = rect.top - parentRect.top;
+    const left = rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
+    const right = parentRect.width - left - boxRect.width;
+    if (left < 0) {
+      tooltip.updateStyles({
+        '--gr-tooltip-arrow-center-offset': `${left}px`,
+      });
+    } else if (right < 0) {
+      tooltip.updateStyles({
+        '--gr-tooltip-arrow-center-offset': `${-0.5 * right}px`,
+      });
+    }
+    tooltip.style.left = `${Math.max(0, left)}px`;
+
+    if (!this.positionBelow) {
+      tooltip.style.top = `${Math.max(0, top)}px`;
+      tooltip.style.transform = `translateY(calc(-100% - ${BOTTOM_OFFSET}px))`;
+    } else {
+      tooltip.style.top = `${top + rect.height + BOTTOM_OFFSET}px`;
+    }
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.ts
deleted file mode 100644
index 952420d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    iron-icon {
-      width: var(--line-height-normal);
-      height: var(--line-height-normal);
-      vertical-align: top;
-    }
-  </style>
-  <slot></slot
-  ><!--
- --><iron-icon icon="gr-icons:info" hidden$="[[!showIcon]]"></iron-icon>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js
index f905eaa..8d3bbb0 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js
@@ -17,35 +17,162 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-tooltip-content.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 
-const basicFixture = fixtureFromTemplate(html`
-<gr-tooltip-content>
-    </gr-tooltip-content>
-`);
+const basicFixture = fixtureFromElement('gr-tooltip-content');
 
 suite('gr-tooltip-content tests', () => {
   let element;
-  setup(() => {
+
+  function makeTooltip(tooltipRect, parentRect) {
+    return {
+      getBoundingClientRect() { return tooltipRect; },
+      updateStyles: sinon.stub(),
+      style: {left: 0, top: 0},
+      parentElement: {
+        getBoundingClientRect() { return parentRect; },
+      },
+    };
+  }
+
+  setup(async () => {
     element = basicFixture.instantiate();
+    element.title = 'title';
+    await element.updateComplete;
   });
 
   test('icon is not visible by default', () => {
-    assert.equal(dom(element.root)
-        .querySelector('iron-icon').hidden, true);
+    assert.isNotOk(element.shadowRoot.querySelector('iron-icon'));
   });
 
-  test('position-below attribute is reflected', () => {
+  test('icon is visible with showIcon property', async () => {
+    element.showIcon = true;
+    await element.updateComplete;
+    assert.isOk(element.shadowRoot.querySelector('iron-icon'));
+  });
+
+  test('position-below attribute is reflected', async () => {
     assert.isFalse(element.hasAttribute('position-below'));
     element.positionBelow = true;
+    await element.updateComplete;
     assert.isTrue(element.hasAttribute('position-below'));
   });
 
-  test('icon is visible with showIcon property', () => {
-    element.showIcon = true;
-    assert.equal(dom(element.root)
-        .querySelector('iron-icon').hidden, false);
+  test('normal position', () => {
+    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
+      return {top: 100, left: 100, width: 200};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 50},
+        {top: 0, left: 0, width: 1000});
+
+    element._positionTooltip(tooltip);
+    assert.isFalse(tooltip.updateStyles.called);
+    assert.equal(tooltip.style.left, '175px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('left side position', () => {
+    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
+      return {top: 100, left: 10, width: 50};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 120},
+        {top: 0, left: 0, width: 1000});
+
+    element._positionTooltip(tooltip);
+    assert.isTrue(tooltip.updateStyles.called);
+    const offset = tooltip.updateStyles
+        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
+    assert.isBelow(parseFloat(offset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '0px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('right side position', () => {
+    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
+      return {top: 100, left: 950, width: 50};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 120},
+        {top: 0, left: 0, width: 1000});
+
+    element._positionTooltip(tooltip);
+    assert.isTrue(tooltip.updateStyles.called);
+    const offset = tooltip.updateStyles
+        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
+    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '915px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('position to bottom', () => {
+    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
+      return {top: 100, left: 950, width: 50, height: 50};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 120},
+        {top: 0, left: 0, width: 1000});
+
+    element.positionBelow = true;
+    element._positionTooltip(tooltip);
+    assert.isTrue(tooltip.updateStyles.called);
+    const offset = tooltip.updateStyles
+        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
+    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '915px');
+    assert.equal(tooltip.style.top, '157.2px');
+  });
+
+  test('hides tooltip when detached', async () => {
+    sinon.stub(element, '_handleHideTooltip');
+    element.remove();
+    await element.updateComplete;
+    assert.isTrue(element._handleHideTooltip.called);
+  });
+
+  test('sets up listeners when has-tooltip is changed', async () => {
+    const addListenerStub = sinon.stub(element, 'addEventListener');
+    element.hasTooltip = true;
+    await element.updateComplete;
+    assert.isTrue(addListenerStub.called);
+  });
+
+  test('clean up listeners when has-tooltip changed to false', async () => {
+    const removeListenerStub = sinon.stub(element, 'removeEventListener');
+    element.hasTooltip = true;
+    await element.updateComplete;
+    element.hasTooltip = false;
+    await element.updateComplete;
+    assert.isTrue(removeListenerStub.called);
+  });
+
+  test('do not display tooltips on touch devices', async () => {
+    // On touch devices, tooltips should not be shown.
+    element.isTouchDevice = true;
+    await element.updateComplete;
+
+    // fire mouse-enter
+    element._handleShowTooltip();
+    await element.updateComplete;
+    assert.isNotOk(element.tooltip);
+
+    // fire mouse-enter
+    element._handleHideTooltip();
+    await element.updateComplete;
+    assert.isNotOk(element.tooltip);
+
+    // On other devices, tooltips should be shown.
+    element.isTouchDevice = false;
+
+    // fire mouse-enter
+    element._handleShowTooltip();
+    await element.updateComplete;
+    assert.isOk(element.tooltip);
+
+    // fire mouse-enter
+    element._handleHideTooltip();
+    await element.updateComplete;
+    assert.isNotOk(element.tooltip);
   });
 });
 
diff --git a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts
deleted file mode 100644
index 9e4608f..0000000
--- a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {PolymerElement} from '@polymer/polymer';
-import {Constructor} from '../../utils/common-util';
-import {property} from '@polymer/decorators';
-import {ServerInfo} from '../../types/common';
-
-/**
- * @polymer
- * @mixinFunction
- */
-export const ChangeTableMixin = <T extends Constructor<PolymerElement>>(
-  superClass: T
-) => {
-  /**
-   * @polymer
-   * @mixinClass
-   */
-  class Mixin extends superClass {
-    @property({type: Array})
-    readonly columnNames: string[] = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Reviewers',
-      'Comments',
-      'Repo',
-      'Branch',
-      'Updated',
-      'Size',
-    ];
-
-    isColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]) {
-      if (!columnsToDisplay || !columnToCheck) {
-        return false;
-      }
-      return !columnsToDisplay.includes(columnToCheck);
-    }
-
-    /**
-     * Is the column disabled by a server config or experiment? For example the
-     * assignee feature might be disabled and thus the corresponding column is
-     * also disabled.
-     *
-     */
-    isColumnEnabled(column: string, config: ServerInfo, experiments: string[]) {
-      if (!config || !config.change) return true;
-      if (column === 'Assignee') return !!config.change.enable_assignee;
-      if (column === 'Comments') return experiments.includes('comments-column');
-      return true;
-    }
-
-    /**
-     * @return enabled columns, see isColumnEnabled().
-     */
-    getEnabledColumns(
-      columns: string[],
-      config: ServerInfo,
-      experiments: string[]
-    ) {
-      return columns.filter(col =>
-        this.isColumnEnabled(col, config, experiments)
-      );
-    }
-
-    /**
-     * The Project column was renamed to Repo, but some users may have
-     * preferences that use its old name. If that column is found, rename it
-     * before use.
-     *
-     * @return If the column was renamed, returns a new array
-     * with the corrected name. Otherwise, it returns the original param.
-     */
-    renameProjectToRepoColumn(columns: string[]) {
-      const projectIndex = columns.indexOf('Project');
-      if (projectIndex === -1) {
-        return columns;
-      }
-      const newColumns = [...columns];
-      newColumns[projectIndex] = 'Repo';
-      return newColumns;
-    }
-  }
-
-  return Mixin as T & Constructor<ChangeTableMixinInterface>;
-};
-
-export interface ChangeTableMixinInterface {
-  readonly columnNames: string[];
-  isColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]): boolean;
-  isColumnEnabled(
-    column: string,
-    config: ServerInfo,
-    experiments: string[]
-  ): boolean;
-  getEnabledColumns(
-    columns: string[],
-    config: ServerInfo,
-    experiments: string[]
-  ): string[];
-  renameProjectToRepoColumn(columns: string[]): string[];
-}
diff --git a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin_test.js b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin_test.js
deleted file mode 100644
index 8bc223f..0000000
--- a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin_test.js
+++ /dev/null
@@ -1,81 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../test/common-test-setup-karma.js';
-import {ChangeTableMixin} from './gr-change-table-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = ChangeTableMixin(PolymerElement);
-
-class GrChangeTableMixinTestElement extends base {
-  static get is() { return 'gr-change-table-mixin-test-element'; }
-}
-
-customElements.define(GrChangeTableMixinTestElement.is,
-    GrChangeTableMixinTestElement);
-
-const basicFixture = fixtureFromElement(
-    'gr-change-table-mixin-test-element');
-
-suite('gr-change-table-mixin tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('isColumnHidden', () => {
-    const columnToCheck = 'Repo';
-    let columnsToDisplay = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Repo',
-      'Branch',
-      'Updated',
-      'Size',
-    ];
-    assert.isFalse(element.isColumnHidden(columnToCheck, columnsToDisplay));
-
-    columnsToDisplay = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Branch',
-      'Updated',
-      'Size',
-    ];
-    assert.isTrue(element.isColumnHidden(columnToCheck, columnsToDisplay));
-  });
-
-  test('renameProjectToRepoColumn maps Project to Repo', () => {
-    const columns = [
-      'Subject',
-      'Status',
-      'Owner',
-    ];
-    assert.deepEqual(element.renameProjectToRepoColumn(columns),
-        columns.slice(0));
-    assert.deepEqual(
-        element.renameProjectToRepoColumn(columns.concat(['Project'])),
-        columns.slice(0).concat(['Repo']));
-  });
-});
-
diff --git a/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin.ts b/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin.ts
deleted file mode 100644
index 70d212c..0000000
--- a/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {encodeURL, getBaseUrl} from '../../utils/url-util';
-import {PolymerElement} from '@polymer/polymer';
-import {Constructor} from '../../utils/common-util';
-
-/**
- * @polymer
- * @mixinFunction
- */
-export const ListViewMixin = <T extends Constructor<PolymerElement>>(
-  superClass: T
-) => {
-  /**
-   * @polymer
-   * @mixinClass
-   */
-  class Mixin extends superClass {
-    computeLoadingClass(loading: boolean): string {
-      return loading ? 'loading' : '';
-    }
-
-    computeShownItems<T>(items: T[]): T[] {
-      return items.slice(0, 25);
-    }
-
-    getUrl(path: string, item: string) {
-      return getBaseUrl() + path + encodeURL(item, true);
-    }
-
-    getFilterValue<T extends ListViewParams>(params: T): string {
-      if (!params) {
-        return '';
-      }
-      return params.filter || '';
-    }
-
-    getOffsetValue<T extends ListViewParams>(params: T): number {
-      if (params?.offset) {
-        return Number(params.offset);
-      }
-      return 0;
-    }
-  }
-
-  return Mixin as T & Constructor<ListViewMixinInterface>;
-};
-
-export interface ListViewMixinInterface {
-  computeLoadingClass(loading: boolean): string;
-  computeShownItems<T>(items: T[]): T[];
-  getUrl(path: string, item: string): string;
-  getFilterValue<T extends ListViewParams>(params: T): string;
-  getOffsetValue<T extends ListViewParams>(params: T): number;
-}
-
-export interface ListViewParams {
-  filter?: string | null;
-  offset?: number | string;
-}
diff --git a/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin_test.js b/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin_test.js
deleted file mode 100644
index d2b429f..0000000
--- a/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin_test.js
+++ /dev/null
@@ -1,80 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../test/common-test-setup-karma.js';
-import {ListViewMixin} from './gr-list-view-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-
-const basicFixture = fixtureFromElement(
-    'gr-list-view-mixin-test-element');
-
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = ListViewMixin(PolymerElement);
-
-class GrListViewMixinTestElement extends base {
-  static get is() { return 'gr-list-view-mixin-test-element'; }
-}
-
-customElements.define(GrListViewMixinTestElement.is,
-    GrListViewMixinTestElement);
-
-suite('gr-list-view-mixin tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('computeLoadingClass', () => {
-    assert.equal(element.computeLoadingClass(true), 'loading');
-    assert.equal(element.computeLoadingClass(false), '');
-  });
-
-  test('computeShownItems', () => {
-    const myArr = new Array(26);
-    assert.equal(element.computeShownItems(myArr).length, 25);
-  });
-
-  test('getUrl', () => {
-    assert.equal(element.getUrl('/path/to/something/', 'item'),
-        '/path/to/something/item');
-    assert.equal(element.getUrl('/path/to/something/', 'item%test'),
-        '/path/to/something/item%2525test');
-  });
-
-  test('getFilterValue', () => {
-    let params;
-    assert.equal(element.getFilterValue(params), '');
-
-    params = {filter: null};
-    assert.equal(element.getFilterValue(params), '');
-
-    params = {filter: 'test'};
-    assert.equal(element.getFilterValue(params), 'test');
-  });
-
-  test('getOffsetValue', () => {
-    let params;
-    assert.equal(element.getOffsetValue(params), 0);
-
-    params = {offset: null};
-    assert.equal(element.getOffsetValue(params), 0);
-
-    params = {offset: 1};
-    assert.equal(element.getOffsetValue(params), 1);
-  });
-});
-
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
deleted file mode 100644
index 3e20d1d..0000000
--- a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
+++ /dev/null
@@ -1,227 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../elements/shared/gr-tooltip/gr-tooltip';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {getRootElement} from '../../scripts/rootElement';
-import {property, observe} from '@polymer/decorators';
-import {GrTooltip} from '../../elements/shared/gr-tooltip/gr-tooltip';
-import {PolymerElement} from '@polymer/polymer';
-import {Constructor} from '../../utils/common-util';
-
-const BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
-
-/** The interface corresponding to TooltipMixin */
-export interface TooltipMixinInterface {
-  hasTooltip: boolean;
-  positionBelow: boolean;
-  _isTouchDevice: boolean;
-  _tooltip: GrTooltip | null;
-  _titleText: string;
-  _hasSetupTooltipListeners: boolean;
-}
-
-/**
- * @polymer
- * @mixinFunction
- */
-export const TooltipMixin = <T extends Constructor<PolymerElement>>(
-  superClass: T
-) => {
-  /**
-   * @polymer
-   * @mixinClass
-   */
-  class Mixin extends superClass {
-    @property({type: Boolean})
-    hasTooltip = false;
-
-    @property({type: Boolean, reflectToAttribute: true})
-    positionBelow = false;
-
-    @property({type: Boolean})
-    _isTouchDevice = 'ontouchstart' in document.documentElement;
-
-    @property({type: Object})
-    _tooltip: GrTooltip | null = null;
-
-    @property({type: String})
-    _titleText = '';
-
-    @property({type: Boolean})
-    _hasSetupTooltipListeners = false;
-
-    // Handler for mouseenter event
-    private mouseenterHandler?: (e: MouseEvent) => void;
-
-    // Handler for scrolling on window
-    private readonly windowScrollHandler: () => void;
-
-    // Handler for showing the tooltip, will be attached to certain events
-    private readonly showHandler: () => void;
-
-    // Handler for hiding the tooltip, will be attached to certain events
-    private readonly hideHandler: (e: Event) => void;
-
-    // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
-    constructor(..._: any[]) {
-      super();
-      this.windowScrollHandler = () => this._handleWindowScroll();
-      this.showHandler = () => this._handleShowTooltip();
-      this.hideHandler = (e: Event | undefined) => this._handleHideTooltip(e);
-    }
-
-    override disconnectedCallback() {
-      // NOTE: if you define your own `detached` in your component
-      // then this won't take affect (as its not a class yet)
-      this._handleHideTooltip(undefined);
-      if (this.mouseenterHandler) {
-        this.removeEventListener('mouseenter', this.mouseenterHandler);
-      }
-      window.removeEventListener('scroll', this.windowScrollHandler);
-      super.disconnectedCallback();
-    }
-
-    @observe('hasTooltip')
-    _setupTooltipListeners() {
-      if (!this.mouseenterHandler) {
-        this.mouseenterHandler = this.showHandler;
-      }
-
-      if (!this.hasTooltip) {
-        // if attribute set to false, remove the listener
-        this.removeEventListener('mouseenter', this.mouseenterHandler);
-        this._hasSetupTooltipListeners = false;
-        return;
-      }
-
-      if (this._hasSetupTooltipListeners) {
-        return;
-      }
-      this._hasSetupTooltipListeners = true;
-
-      this.addEventListener('mouseenter', this.mouseenterHandler);
-    }
-
-    _handleShowTooltip() {
-      if (this._isTouchDevice) {
-        return;
-      }
-
-      if (
-        !this.hasAttribute('title') ||
-        this.getAttribute('title') === '' ||
-        this._tooltip
-      ) {
-        return;
-      }
-
-      // Store the title attribute text then set it to an empty string to
-      // prevent it from showing natively.
-      this._titleText = this.getAttribute('title') || '';
-      this.setAttribute('title', '');
-
-      const tooltip = document.createElement('gr-tooltip');
-      tooltip.text = this._titleText;
-      tooltip.maxWidth = this.getAttribute('max-width') || '';
-      tooltip.positionBelow = this.hasAttribute('position-below');
-
-      // Set visibility to hidden before appending to the DOM so that
-      // calculations can be made based on the element’s size.
-      tooltip.style.visibility = 'hidden';
-      getRootElement().appendChild(tooltip);
-      this._positionTooltip(tooltip);
-      tooltip.style.visibility = 'initial';
-
-      this._tooltip = tooltip;
-      window.addEventListener('scroll', this.windowScrollHandler);
-      this.addEventListener('mouseleave', this.hideHandler);
-      this.addEventListener('click', this.hideHandler);
-      tooltip.addEventListener('mouseleave', this.hideHandler);
-    }
-
-    _handleHideTooltip(e: Event | undefined) {
-      if (this._isTouchDevice) {
-        return;
-      }
-      if (!this.hasAttribute('title') || !this._titleText) {
-        return;
-      }
-      // Do not hide if mouse left this or this._tooltip and came to this or
-      // this._tooltip
-      if (
-        (e as MouseEvent)?.relatedTarget === this._tooltip ||
-        (e as MouseEvent)?.relatedTarget === this
-      ) {
-        return;
-      }
-
-      window.removeEventListener('scroll', this.windowScrollHandler);
-      this.removeEventListener('mouseleave', this.hideHandler);
-      this.removeEventListener('click', this.hideHandler);
-      this.setAttribute('title', this._titleText);
-      this._tooltip?.removeEventListener('mouseleave', this.hideHandler);
-
-      if (this._tooltip?.parentNode) {
-        this._tooltip.parentNode.removeChild(this._tooltip);
-      }
-      this._tooltip = null;
-    }
-
-    _handleWindowScroll() {
-      if (!this._tooltip) {
-        return;
-      }
-
-      this._positionTooltip(this._tooltip);
-    }
-
-    _positionTooltip(tooltip: GrTooltip) {
-      // This flush is needed for tooltips to be positioned correctly in Firefox
-      // and Safari.
-      flush();
-      const rect = this.getBoundingClientRect();
-      const boxRect = tooltip.getBoundingClientRect();
-      if (!tooltip.parentElement) {
-        return;
-      }
-      const parentRect = tooltip.parentElement.getBoundingClientRect();
-      const top = rect.top - parentRect.top;
-      const left =
-        rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
-      const right = parentRect.width - left - boxRect.width;
-      if (left < 0) {
-        tooltip.updateStyles({
-          '--gr-tooltip-arrow-center-offset': `${left}px`,
-        });
-      } else if (right < 0) {
-        tooltip.updateStyles({
-          '--gr-tooltip-arrow-center-offset': `${-0.5 * right}px`,
-        });
-      }
-      tooltip.style.left = `${Math.max(0, left)}px`;
-
-      if (!this.positionBelow) {
-        tooltip.style.top = `${Math.max(0, top)}px`;
-        tooltip.style.transform = `translateY(calc(-100% - ${BOTTOM_OFFSET}px))`;
-      } else {
-        tooltip.style.top = `${top + rect.height + BOTTOM_OFFSET}px`;
-      }
-    }
-  }
-
-  return Mixin as T & Constructor<TooltipMixinInterface>;
-};
diff --git a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin_test.js b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin_test.js
deleted file mode 100644
index 69e6e86..0000000
--- a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin_test.js
+++ /dev/null
@@ -1,140 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../test/common-test-setup-karma.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {TooltipMixin} from './gr-tooltip-mixin.js';
-
-const basicFixture = fixtureFromElement('gr-tooltip-mixin-element');
-
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = TooltipMixin(PolymerElement);
-
-class GrTooltipMixinTestElement extends base {
-  static get is() {
-    return 'gr-tooltip-mixin-element';
-  }
-}
-
-customElements.define(GrTooltipMixinTestElement.is,
-    GrTooltipMixinTestElement);
-
-suite('gr-tooltip-mixin tests', () => {
-  let element;
-
-  function makeTooltip(tooltipRect, parentRect) {
-    return {
-      getBoundingClientRect() { return tooltipRect; },
-      updateStyles: sinon.stub(),
-      style: {left: 0, top: 0},
-      parentElement: {
-        getBoundingClientRect() { return parentRect; },
-      },
-    };
-  }
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('normal position', () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 100, width: 200};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 50},
-        {top: 0, left: 0, width: 1000});
-
-    element._positionTooltip(tooltip);
-    assert.isFalse(tooltip.updateStyles.called);
-    assert.equal(tooltip.style.left, '175px');
-    assert.equal(tooltip.style.top, '100px');
-  });
-
-  test('left side position', () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 10, width: 50};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 120},
-        {top: 0, left: 0, width: 1000});
-
-    element._positionTooltip(tooltip);
-    assert.isTrue(tooltip.updateStyles.called);
-    const offset = tooltip.updateStyles
-        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-    assert.isBelow(parseFloat(offset.replace(/px$/, '')), 0);
-    assert.equal(tooltip.style.left, '0px');
-    assert.equal(tooltip.style.top, '100px');
-  });
-
-  test('right side position', () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 950, width: 50};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 120},
-        {top: 0, left: 0, width: 1000});
-
-    element._positionTooltip(tooltip);
-    assert.isTrue(tooltip.updateStyles.called);
-    const offset = tooltip.updateStyles
-        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
-    assert.equal(tooltip.style.left, '915px');
-    assert.equal(tooltip.style.top, '100px');
-  });
-
-  test('position to bottom', () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 950, width: 50, height: 50};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 120},
-        {top: 0, left: 0, width: 1000});
-
-    element.positionBelow = true;
-    element._positionTooltip(tooltip);
-    assert.isTrue(tooltip.updateStyles.called);
-    const offset = tooltip.updateStyles
-        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
-    assert.equal(tooltip.style.left, '915px');
-    assert.equal(tooltip.style.top, '157.2px');
-  });
-
-  test('hides tooltip when detached', () => {
-    sinon.stub(element, '_handleHideTooltip');
-    element.remove();
-    flush();
-    assert.isTrue(element._handleHideTooltip.called);
-  });
-
-  test('sets up listeners when has-tooltip is changed', () => {
-    const addListenerStub = sinon.stub(element, 'addEventListener');
-    element.hasTooltip = true;
-    assert.isTrue(addListenerStub.called);
-  });
-
-  test('clean up listeners when has-tooltip changed to false', () => {
-    const removeListenerStub = sinon.stub(element, 'removeEventListener');
-    element.hasTooltip = true;
-    element.hasTooltip = false;
-    assert.isTrue(removeListenerStub.called);
-  });
-});
-
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 5c4c9ce..9092919 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
@@ -151,6 +151,7 @@
   TOGGLE_CHANGE_STAR = 'TOGGLE_CHANGE_STAR',
   REFRESH_CHANGE_LIST = 'REFRESH_CHANGE_LIST',
   OPEN_SUBMIT_DIALOG = 'OPEN_SUBMIT_DIALOG',
+  TOGGLE_ATTENTION_SET = 'TOGGLE_ATTENTION_SET',
 
   OPEN_REPLY_DIALOG = 'OPEN_REPLY_DIALOG',
   OPEN_DOWNLOAD_DIALOG = 'OPEN_DOWNLOAD_DIALOG',
@@ -334,6 +335,11 @@
   ShortcutSection.ACTIONS,
   'Open submit dialog'
 );
+_describe(
+  Shortcut.TOGGLE_ATTENTION_SET,
+  ShortcutSection.ACTIONS,
+  'Toggle attention set status'
+);
 _describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS, 'Add a change topic');
 _describe(
   Shortcut.DIFF_AGAINST_BASE,
diff --git a/polygerrit-ui/app/styles/gr-a11y-styles.ts b/polygerrit-ui/app/styles/gr-a11y-styles.ts
new file mode 100644
index 0000000..a1fa62b
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-a11y-styles.ts
@@ -0,0 +1,42 @@
+/**
+ * @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} from 'lit';
+
+export const a11yStyles = css`
+  .assistive-tech-only {
+    user-select: none;
+    clip: rect(1px, 1px, 1px, 1px);
+    height: 1px;
+    margin: 0;
+    overflow: hidden;
+    padding: 0;
+    position: absolute;
+    white-space: nowrap;
+    width: 1px;
+    z-index: -1000;
+  }
+`;
+
+const $_documentContainer = document.createElement('template');
+$_documentContainer.innerHTML = `<dom-module id="gr-a11y-styles">
+  <template>
+    <style>
+    ${a11yStyles.cssText}
+    </style>
+  </template>
+</dom-module>`;
+document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-spinner-styles.ts b/polygerrit-ui/app/styles/gr-spinner-styles.ts
index 0f8341f..6015be4 100644
--- a/polygerrit-ui/app/styles/gr-spinner-styles.ts
+++ b/polygerrit-ui/app/styles/gr-spinner-styles.ts
@@ -16,13 +16,6 @@
  */
 import {css} from 'lit';
 
-// Mark the file as a module. Otherwise typescript assumes this is a script
-// and $_documentContainer is a global variable.
-// See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
-
-const $_documentContainer = document.createElement('template');
-
 export const spinnerStyles = css`
   .loadingSpin {
     border: 2px solid var(--disabled-button-background-color);
@@ -43,6 +36,7 @@
   }
 `;
 
+const $_documentContainer = document.createElement('template');
 $_documentContainer.innerHTML = `<dom-module id="gr-spinner-styles">
   <template>
     <style>
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.ts b/polygerrit-ui/app/styles/gr-voting-styles.ts
index 12d0784..a623d99 100644
--- a/polygerrit-ui/app/styles/gr-voting-styles.ts
+++ b/polygerrit-ui/app/styles/gr-voting-styles.ts
@@ -18,24 +18,26 @@
 // Mark the file as a module. Otherwise typescript assumes this is a script
 // and $_documentContainer is a global variable.
 // See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
+import {css} from 'lit';
+
+export const votingStyles = css`
+  .voteChip {
+    border: 1px solid var(--border-color);
+    /* max rounded */
+    border-radius: 1em;
+    box-shadow: none;
+    box-sizing: border-box;
+    min-width: 3em;
+    color: var(--vote-text-color);
+  }
+`;
 
 const $_documentContainer = document.createElement('template');
-
 $_documentContainer.innerHTML = `<dom-module id="gr-voting-styles">
   <template>
     <style>
-      .voteChip {
-        border: 1px solid var(--border-color);
-        /* max rounded */
-        border-radius: 1em;
-        box-shadow: none;
-        box-sizing: border-box;
-        min-width: 3em;
-        color: var(--vote-text-color);
-      }
+    ${votingStyles.cssText}
     </style>
   </template>
 </dom-module>`;
-
 document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index b58d5b9..98f6eb2 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -224,19 +224,6 @@
     --iron-autogrow-textarea_-_white-space: pre-wrap;
   }
 
-  .assistive-tech-only {
-    user-select: none;
-    clip: rect(1px, 1px, 1px, 1px);
-    height: 1px;
-    margin: 0;
-    overflow: hidden;
-    padding: 0;
-    position: absolute;
-    white-space: nowrap;
-    width: 1px;
-    z-index: -1000;
-  }
-
   /**
    * TODO: Remove these rules and change (plugin) users to rely on
    * gr-spinner-styles directly.
diff --git a/polygerrit-ui/app/utils/attention-set-util.ts b/polygerrit-ui/app/utils/attention-set-util.ts
index 631b633..dcd2863 100644
--- a/polygerrit-ui/app/utils/attention-set-util.ts
+++ b/polygerrit-ui/app/utils/attention-set-util.ts
@@ -16,6 +16,7 @@
  */
 
 import {AccountInfo, ChangeInfo, ServerInfo} from '../types/common';
+import {ParsedChangeInfo} from '../types/types';
 import {
   getAccountTemplate,
   isServiceUser,
@@ -29,7 +30,7 @@
 
 export function hasAttention(
   account?: AccountInfo,
-  change?: ChangeInfo
+  change?: ChangeInfo | ParsedChangeInfo
 ): boolean {
   return (
     canHaveAttention(account) &&
@@ -41,7 +42,7 @@
 export function getReason(
   config?: ServerInfo,
   account?: AccountInfo,
-  change?: ChangeInfo
+  change?: ChangeInfo | ParsedChangeInfo
 ) {
   if (!hasAttention(account, change)) return '';
   if (change?.attention_set === undefined) return '';
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index c94493b..278e7f3 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -215,7 +215,7 @@
 }
 
 export function isUploader(
-  change?: ChangeInfo,
+  change?: ChangeInfo | ParsedChangeInfo,
   account?: AccountInfo
 ): boolean {
   if (!change || !account) return false;
@@ -224,7 +224,7 @@
 }
 
 export function isInvolved(
-  change?: ChangeInfo,
+  change?: ChangeInfo | ParsedChangeInfo,
   account?: AccountInfo
 ): boolean {
   const owner = isOwner(change, account);
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 5370cf9..0002254 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -100,7 +100,7 @@
 }
 
 export function query<E extends Element = Element>(
-  el: Element | undefined,
+  el: Element | null | undefined,
   selector: string
 ): E | undefined {
   if (!el) return undefined;
@@ -109,7 +109,7 @@
 }
 
 export function queryAndAssert<E extends Element = Element>(
-  el: Element | undefined,
+  el: Element | null | undefined,
   selector: string
 ): E {
   const found = query<E>(el, selector);
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 16129af..7b1f3e3 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -171,7 +171,7 @@
  *  getEventPath(e); // eg: div.class1>p#pid.class2
  * }
  */
-export function getEventPath<T extends PolymerEvent>(e?: T) {
+export function getEventPath<T extends MouseEvent>(e?: T) {
   if (!e) return '';
 
   let path = e.composedPath();
diff --git a/yarn.lock b/yarn.lock
index be412a5..17c08c3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -954,6 +954,15 @@
     semver "^7.3.5"
     spdx-expression-parse "^3.0.1"
 
+eslint-plugin-lit@^1.5.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-lit/-/eslint-plugin-lit-1.5.1.tgz#e5b86fee4aeb6023ad4bb90b3d9e462ca8eff755"
+  integrity sha512-pYB0QM11uyOk5L55QfGhBmWi8a56PkNsnx+zVpY4bxz9YVquEo4BeRnFmf9AwFyT89rhGud9QruFhM2xJ4piwg==
+  dependencies:
+    parse5 "^6.0.1"
+    parse5-htmlparser2-tree-adapter "^6.0.1"
+    requireindex "^1.2.0"
+
 eslint-plugin-node@^11.1.0:
   version "11.1.0"
   resolved "https://registry.yarnpkg.com/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz#c95544416ee4ada26740a30474eefc5402dc671d"
@@ -2145,6 +2154,13 @@
     json-parse-even-better-errors "^2.3.0"
     lines-and-columns "^1.1.6"
 
+parse5-htmlparser2-tree-adapter@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6"
+  integrity sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==
+  dependencies:
+    parse5 "^6.0.1"
+
 parse5@^3.0.1:
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-3.0.3.tgz#042f792ffdd36851551cf4e9e066b3874ab45b5c"
@@ -2152,6 +2168,11 @@
   dependencies:
     "@types/node" "*"
 
+parse5@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
+  integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==
+
 path-exists@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
@@ -2386,6 +2407,11 @@
   resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
   integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
 
+requireindex@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/requireindex/-/requireindex-1.2.0.tgz#3463cdb22ee151902635aa6c9535d4de9c2ef1ef"
+  integrity sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==
+
 resolve-from@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"