Merge changes Idf06a875,I6b10e93f

* changes:
  Make ChangeControl#getRange private and migrate caller
  Migrate ApprovalsUtil#checkApprovals() to PermissionBackend
diff --git a/Documentation/dev-plugins-pg.txt b/Documentation/dev-plugins-pg.txt
index 9d36758..e1bf39e 100644
--- a/Documentation/dev-plugins-pg.txt
+++ b/Documentation/dev-plugins-pg.txt
@@ -45,14 +45,14 @@
 decoration case, a hook is set with a `content` attribute that points to the DOM
 element.
 
-1. Get the DOM hook API instance via `plugin.getDomHook(endpointName)`
+1. Get the DOM hook API instance via `plugin.hook(endpointName)`
 2. Set up an `onAttached` callback
 3. Callback is called when the hook element is created and inserted into DOM
 4. Use element.content to get UI element
 
 ``` js
 Gerrit.install(function(plugin) {
-  const domHook = plugin.getDomHook('reply-text');
+  const domHook = plugin.hook('reply-text');
   domHook.onAttached(element => {
     if (!element.content) { return; }
     // element.content is a reply dialog text area.
@@ -70,7 +70,7 @@
 
 ``` js
 Gerrit.install(function(plugin) {
-  const domHook = plugin.getDomHook('reply-text');
+  const domHook = plugin.hook('reply-text');
   domHook.onAttached(element => {
     if (!element.content) { return; }
     element.content.style.border = '1px red dashed';
@@ -86,7 +86,7 @@
 
 ``` js
 Gerrit.install(function(plugin) {
-  const domHook = plugin.getDomHook('header-title', {replace: true});
+  const domHook = plugin.hook('header-title', {replace: true});
   domHook.onAttached(element => {
     element.appendChild(document.createElement('my-site-header'));
   });
diff --git a/contrib/abandon_stale.py b/contrib/abandon_stale.py
index fb9eb12..d734cd1 100755
--- a/contrib/abandon_stale.py
+++ b/contrib/abandon_stale.py
@@ -191,7 +191,7 @@
 
         try:
             gerrit.post("/changes/" + change_id + "/abandon",
-                        data='{"message" : "%s"}' % abandon_message)
+                        data={"message" : "%s" % abandon_message})
             abandoned += 1
         except Exception as e:
             errors += 1
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
index bfa21cb..d409a74 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -19,6 +19,7 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_CONTENT;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
@@ -31,6 +32,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.Util.category;
 import static com.google.gerrit.server.project.Util.value;
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
@@ -81,6 +83,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.LabelInfo;
@@ -3127,6 +3130,49 @@
     gApi.changes().id(r.getChangeId()).setMessage(getCommitMessage(r.getChangeId()));
   }
 
+  @Test
+  public void fourByteEmoji() throws Exception {
+    // U+1F601 GRINNING FACE WITH SMILING EYES
+    String smile = new String(Character.toChars(0x1f601));
+    assertThat(smile).isEqualTo("😁");
+    assertThat(smile).hasLength(2); // Thanks, Java.
+    assertThat(smile.getBytes(UTF_8)).hasLength(4);
+
+    String subject = "A happy change " + smile;
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, subject, FILE_NAME, FILE_CONTENT)
+            .to("refs/for/master");
+    r.assertOkStatus();
+    String id = r.getChangeId();
+
+    ReviewInput ri = ReviewInput.approve();
+    ri.message = "I like it " + smile;
+    ReviewInput.CommentInput ci = new ReviewInput.CommentInput();
+    ci.path = FILE_NAME;
+    ci.side = Side.REVISION;
+    ci.message = "Good " + smile;
+    ri.comments = ImmutableMap.of(FILE_NAME, ImmutableList.of(ci));
+    gApi.changes().id(id).current().review(ri);
+
+    ChangeInfo info =
+        gApi.changes()
+            .id(id)
+            .get(
+                EnumSet.of(
+                    ListChangesOption.MESSAGES,
+                    ListChangesOption.CURRENT_COMMIT,
+                    ListChangesOption.CURRENT_REVISION));
+    assertThat(info.subject).isEqualTo(subject);
+    assertThat(Iterables.getLast(info.messages).message).endsWith(ri.message);
+    assertThat(Iterables.getOnlyElement(info.revisions.values()).commit.message)
+        .startsWith(subject);
+
+    List<CommentInfo> comments =
+        Iterables.getOnlyElement(gApi.changes().id(id).comments().values());
+    assertThat(Iterables.getOnlyElement(comments).message).isEqualTo(ci.message);
+  }
+
   private String getCommitMessage(String changeId) throws RestApiException, IOException {
     return gApi.changes().id(changeId).current().file("/COMMIT_MSG").content().asString();
   }
diff --git a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 558cc78..51c60af 100644
--- a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -41,6 +41,10 @@
   // @see https://github.com/w3c/preload/issues/32 regarding crossorigin
   <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff2" as="font" type="font/woff2" crossorigin>{\n}
   <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff" as="font" type="font/woff" crossorigin>{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff" as="font" type="font/woff" crossorigin>{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff2" as="font" type="font/woff2" crossorigin>{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff" as="font" type="font/woff" crossorigin>{\n}
   <link rel="stylesheet" href="{$staticResourcePath}/styles/fonts.css">{\n}
   <link rel="stylesheet" href="{$staticResourcePath}/styles/main.css">{\n}
   <script src="{$staticResourcePath}/bower_components/webcomponentsjs/webcomponents-lite.js"></script>{\n}
diff --git a/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java b/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
index 4efbecc..072d1ed 100644
--- a/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -619,7 +619,6 @@
 
     // If the build system provides us with a source root, use that.
     try (InputStream stream = self.getResourceAsStream(SOURCE_ROOT_RESOURCE)) {
-      System.err.println("URL: " + stream);
       if (stream != null) {
         try (Scanner scan = new Scanner(stream, UTF_8.name()).useDelimiter("\n")) {
           if (scan.hasNext()) {
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
index f1eef0b..89de9dc 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -102,19 +102,13 @@
   }
 
   public static String changeMetaRef(Change.Id id) {
-    StringBuilder r = new StringBuilder();
-    r.append(REFS_CHANGES);
-    r.append(shard(id.get()));
-    r.append(META_SUFFIX);
-    return r.toString();
+    StringBuilder r = newStringBuilder().append(REFS_CHANGES);
+    return shard(id.get(), r).append(META_SUFFIX).toString();
   }
 
   public static String robotCommentsRef(Change.Id id) {
-    StringBuilder r = new StringBuilder();
-    r.append(REFS_CHANGES);
-    r.append(shard(id.get()));
-    r.append(ROBOT_COMMENTS_SUFFIX);
-    return r.toString();
+    StringBuilder r = newStringBuilder().append(REFS_CHANGES);
+    return shard(id.get(), r).append(ROBOT_COMMENTS_SUFFIX).toString();
   }
 
   public static boolean isNoteDbMetaRef(String ref) {
@@ -129,16 +123,12 @@
   }
 
   public static String refsUsers(Account.Id accountId) {
-    StringBuilder r = new StringBuilder();
-    r.append(REFS_USERS);
-    r.append(shard(accountId.get()));
-    return r.toString();
+    StringBuilder r = newStringBuilder().append(REFS_USERS);
+    return shard(accountId.get(), r).toString();
   }
 
   public static String refsDraftComments(Change.Id changeId, Account.Id accountId) {
-    StringBuilder r = buildRefsPrefix(REFS_DRAFT_COMMENTS, changeId.get());
-    r.append(accountId.get());
-    return r.toString();
+    return buildRefsPrefix(REFS_DRAFT_COMMENTS, changeId.get()).append(accountId.get()).toString();
   }
 
   public static String refsDraftCommentsPrefix(Change.Id changeId) {
@@ -146,9 +136,7 @@
   }
 
   public static String refsStarredChanges(Change.Id changeId, Account.Id accountId) {
-    StringBuilder r = buildRefsPrefix(REFS_STARRED_CHANGES, changeId.get());
-    r.append(accountId.get());
-    return r.toString();
+    return buildRefsPrefix(REFS_STARRED_CHANGES, changeId.get()).append(accountId.get()).toString();
   }
 
   public static String refsStarredChangesPrefix(Change.Id changeId) {
@@ -156,11 +144,8 @@
   }
 
   private static StringBuilder buildRefsPrefix(String prefix, int id) {
-    StringBuilder r = new StringBuilder();
-    r.append(prefix);
-    r.append(shard(id));
-    r.append('/');
-    return r;
+    StringBuilder r = newStringBuilder().append(prefix);
+    return shard(id, r).append('/');
   }
 
   public static String refsCacheAutomerge(String hash) {
@@ -171,15 +156,18 @@
     if (id < 0) {
       return null;
     }
-    StringBuilder r = new StringBuilder();
+    return shard(id, newStringBuilder()).toString();
+  }
+
+  private static StringBuilder shard(int id, StringBuilder sb) {
     int n = id % 100;
     if (n < 10) {
-      r.append('0');
+      sb.append('0');
     }
-    r.append(n);
-    r.append('/');
-    r.append(id);
-    return r.toString();
+    sb.append(n);
+    sb.append('/');
+    sb.append(id);
+    return sb;
   }
 
   /**
@@ -363,5 +351,12 @@
     return Integer.valueOf(name.substring(i, name.length()));
   }
 
+  private static StringBuilder newStringBuilder() {
+    // Many refname types in this file are always are longer than the default of 16 chars, so
+    // presize StringBuilders larger by default. This hurts readability less than accurate
+    // calculations would, at a negligible cost to memory overhead.
+    return new StringBuilder(64);
+  }
+
   private RefNames() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
index 5551320..3928b4f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -20,7 +20,6 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.reviewdb.client.Account;
@@ -126,13 +125,9 @@
     // Don't use Maps.newTreeMap(Comparator) due to OpenJDK bug 100167.
     out.approvals = new TreeMap<>(labelTypes.nameComparator());
     for (PatchSetApproval ca : approvals) {
-      for (PermissionRange pr : cd.changeControl().getLabelRanges()) {
-        if (!pr.isEmpty()) {
-          LabelType at = labelTypes.byLabel(ca.getLabelId());
-          if (at != null) {
-            out.approvals.put(at.getName(), formatValue(ca.getValue()));
-          }
-        }
+      LabelType at = labelTypes.byLabel(ca.getLabelId());
+      if (at != null) {
+        out.approvals.put(at.getName(), formatValue(ca.getValue()));
       }
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
index 4c38661..f5e4542 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
@@ -48,7 +48,6 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.EnumSet;
-import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -269,11 +268,6 @@
     return canAbandon(db) && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
   }
 
-  /** All value ranges of any allowed label permission. */
-  public List<PermissionRange> getLabelRanges() {
-    return getRefControl().getLabelRanges(isOwner());
-  }
-
   /** The range of permitted values associated with a label permission. */
   private PermissionRange getRange(String permission) {
     return getRefControl().getRange(permission, isOwner());
@@ -315,7 +309,7 @@
   }
 
   /** Is this user the owner of the change? */
-  boolean isOwner() {
+  private boolean isOwner() {
     if (getUser().isIdentifiedUser()) {
       Account.Id id = getUser().asIdentifiedUser().getAccountId();
       return id.equals(getChange().getOwner());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
index 5694139..3736360 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
@@ -323,27 +323,6 @@
     return canForcePerform(Permission.EDIT_TOPIC_NAME);
   }
 
-  /** All value ranges of any allowed label permission. */
-  List<PermissionRange> getLabelRanges(boolean isChangeOwner) {
-    List<PermissionRange> r = new ArrayList<>();
-    for (Map.Entry<String, List<PermissionRule>> e : relevant.getDeclaredPermissions()) {
-      if (Permission.isLabel(e.getKey())) {
-        int min = 0;
-        int max = 0;
-        for (PermissionRule rule : e.getValue()) {
-          if (projectControl.match(rule, isChangeOwner)) {
-            min = Math.min(min, rule.getMin());
-            max = Math.max(max, rule.getMax());
-          }
-        }
-        if (min != 0 || max != 0) {
-          r.add(new PermissionRange(e.getKey(), min, max));
-        }
-      }
-    }
-    return r;
-  }
-
   /** The range of permitted values associated with a label permission. */
   PermissionRange getRange(String permission) {
     return getRange(permission, false);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index 591fcc2..b042183 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -87,7 +87,9 @@
       }
     }
     // The change owner may remove any zero or positive score.
-    if (changeControl.isOwner() && 0 <= value) {
+    if (currentUser.isIdentifiedUser()
+        && currentUser.getAccountId().equals(notes.getChange().getOwner())
+        && 0 <= value) {
       return true;
     }
     // Users with the remove reviewer permission, the branch owner, project
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
index 9cba53b..e875388 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
@@ -14,18 +14,10 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.errors.InvalidNameException;
-import com.google.gerrit.extensions.api.access.AccessSectionInfo;
-import com.google.gerrit.extensions.api.access.PermissionInfo;
-import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -37,10 +29,8 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.GroupsCollection;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -49,44 +39,35 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
-import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class SetAccess implements RestModifyView<ProjectResource, ProjectAccessInput> {
   protected final GroupBackend groupBackend;
   private final PermissionBackend permissionBackend;
-  private final GroupsCollection groupsCollection;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
-  private final AllProjectsName allProjects;
-  private final Provider<SetParent> setParent;
   private final GetAccess getAccess;
   private final ProjectCache projectCache;
   private final Provider<IdentifiedUser> identifiedUser;
+  private final SetAccessUtil accessUtil;
 
   @Inject
   private SetAccess(
       GroupBackend groupBackend,
       PermissionBackend permissionBackend,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      AllProjectsName allProjects,
-      Provider<SetParent> setParent,
-      GroupsCollection groupsCollection,
       ProjectCache projectCache,
       GetAccess getAccess,
-      Provider<IdentifiedUser> identifiedUser) {
+      Provider<IdentifiedUser> identifiedUser,
+      SetAccessUtil accessUtil) {
     this.groupBackend = groupBackend;
     this.permissionBackend = permissionBackend;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.allProjects = allProjects;
-    this.setParent = setParent;
-    this.groupsCollection = groupsCollection;
     this.getAccess = getAccess;
     this.projectCache = projectCache;
     this.identifiedUser = identifiedUser;
+    this.accessUtil = accessUtil;
   }
 
   @Override
@@ -94,113 +75,39 @@
       throws ResourceNotFoundException, ResourceConflictException, IOException, AuthException,
           BadRequestException, UnprocessableEntityException, OrmException,
           PermissionBackendException {
-    List<AccessSection> removals = getAccessSections(input.remove);
-    List<AccessSection> additions = getAccessSections(input.add);
     MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
 
-    ProjectControl projectControl = rsrc.getControl();
     ProjectConfig config;
 
-    Project.NameKey newParentProjectName =
-        input.parent == null ? null : new Project.NameKey(input.parent);
-
+    List<AccessSection> removals = accessUtil.getAccessSections(input.remove);
+    List<AccessSection> additions = accessUtil.getAccessSections(input.add);
     try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
       config = ProjectConfig.read(md);
 
-      // Perform removal checks
-      for (AccessSection section : removals) {
+      // Check that the user has the right permissions.
+      boolean checkedAdmin = false;
+      for (AccessSection section : Iterables.concat(additions, removals)) {
         boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
-
         if (isGlobalCapabilities) {
-          checkGlobalCapabilityPermissions(config.getName());
-        } else if (!projectControl.controlForRef(section.getName()).isOwner()) {
+          if (!checkedAdmin) {
+            permissionBackend.user(identifiedUser).check(GlobalPermission.ADMINISTRATE_SERVER);
+            checkedAdmin = true;
+          }
+        } else if (!rsrc.getControl().controlForRef(section.getName()).isOwner()) {
           throw new AuthException(
-              "You are not allowed to edit permissionsfor ref: " + section.getName());
-        }
-      }
-      // Perform addition checks
-      for (AccessSection section : additions) {
-        String name = section.getName();
-        boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(name);
-
-        if (isGlobalCapabilities) {
-          checkGlobalCapabilityPermissions(config.getName());
-        } else {
-          if (!AccessSection.isValid(name)) {
-            throw new BadRequestException("invalid section name");
-          }
-          if (!projectControl.controlForRef(name).isOwner()) {
-            throw new AuthException("You are not allowed to edit permissionsfor ref: " + name);
-          }
-          RefPattern.validate(name);
-        }
-
-        // Check all permissions for soundness
-        for (Permission p : section.getPermissions()) {
-          if (isGlobalCapabilities && !GlobalCapability.isCapability(p.getName())) {
-            throw new BadRequestException(
-                "Cannot add non-global capability " + p.getName() + " to global capabilities");
-          }
+              "You are not allowed to edit permissions for ref: " + section.getName());
         }
       }
 
-      // Apply removals
-      for (AccessSection section : removals) {
-        if (section.getPermissions().isEmpty()) {
-          // Remove entire section
-          config.remove(config.getAccessSection(section.getName()));
-        }
-        // Remove specific permissions
-        for (Permission p : section.getPermissions()) {
-          if (p.getRules().isEmpty()) {
-            config.remove(config.getAccessSection(section.getName()), p);
-          } else {
-            for (PermissionRule r : p.getRules()) {
-              config.remove(config.getAccessSection(section.getName()), p, r);
-            }
-          }
-        }
-      }
+      accessUtil.validateChanges(config, removals, additions);
+      accessUtil.applyChanges(config, removals, additions);
 
-      // Apply additions
-      for (AccessSection section : additions) {
-        AccessSection currentAccessSection = config.getAccessSection(section.getName());
-
-        if (currentAccessSection == null) {
-          // Add AccessSection
-          config.replace(section);
-        } else {
-          for (Permission p : section.getPermissions()) {
-            Permission currentPermission = currentAccessSection.getPermission(p.getName());
-            if (currentPermission == null) {
-              // Add Permission
-              currentAccessSection.addPermission(p);
-            } else {
-              for (PermissionRule r : p.getRules()) {
-                // AddPermissionRule
-                currentPermission.add(r);
-              }
-            }
-          }
-        }
-      }
-
-      if (newParentProjectName != null
-          && !config.getProject().getNameKey().equals(allProjects)
-          && !config.getProject().getParent(allProjects).equals(newParentProjectName)) {
-        try {
-          setParent
-              .get()
-              .validateParentUpdate(
-                  projectControl.getProject().getNameKey(),
-                  projectControl.getUser().asIdentifiedUser(),
-                  MoreObjects.firstNonNull(newParentProjectName, allProjects).get(),
-                  true);
-        } catch (UnprocessableEntityException e) {
-          throw new ResourceConflictException(e.getMessage(), e);
-        }
-        config.getProject().setParentName(newParentProjectName);
-      }
+      accessUtil.setParentName(
+          identifiedUser.get(),
+          config,
+          rsrc.getNameKey(),
+          input.parent == null ? null : new Project.NameKey(input.parent),
+          !checkedAdmin);
 
       if (!Strings.isNullOrEmpty(input.message)) {
         if (!input.message.endsWith("\n")) {
@@ -221,68 +128,4 @@
 
     return getAccess.apply(rsrc.getNameKey());
   }
-
-  private List<AccessSection> getAccessSections(Map<String, AccessSectionInfo> sectionInfos)
-      throws UnprocessableEntityException {
-    if (sectionInfos == null) {
-      return Collections.emptyList();
-    }
-
-    List<AccessSection> sections = new ArrayList<>(sectionInfos.size());
-    for (Map.Entry<String, AccessSectionInfo> entry : sectionInfos.entrySet()) {
-      AccessSection accessSection = new AccessSection(entry.getKey());
-
-      if (entry.getValue().permissions == null) {
-        continue;
-      }
-
-      for (Map.Entry<String, PermissionInfo> permissionEntry :
-          entry.getValue().permissions.entrySet()) {
-        Permission p = new Permission(permissionEntry.getKey());
-        if (permissionEntry.getValue().exclusive != null) {
-          p.setExclusiveGroup(permissionEntry.getValue().exclusive);
-        }
-
-        if (permissionEntry.getValue().rules == null) {
-          continue;
-        }
-        for (Map.Entry<String, PermissionRuleInfo> permissionRuleInfoEntry :
-            permissionEntry.getValue().rules.entrySet()) {
-          PermissionRuleInfo pri = permissionRuleInfoEntry.getValue();
-
-          GroupDescription.Basic group = groupsCollection.parseId(permissionRuleInfoEntry.getKey());
-          if (group == null) {
-            throw new UnprocessableEntityException(
-                permissionRuleInfoEntry.getKey() + " is not a valid group ID");
-          }
-          PermissionRule r = new PermissionRule(GroupReference.forGroup(group));
-          if (pri != null) {
-            if (pri.max != null) {
-              r.setMax(pri.max);
-            }
-            if (pri.min != null) {
-              r.setMin(pri.min);
-            }
-            r.setAction(GetAccess.ACTION_TYPE.inverse().get(pri.action));
-            if (pri.force != null) {
-              r.setForce(pri.force);
-            }
-          }
-          p.add(r);
-        }
-        accessSection.getPermissions().add(p);
-      }
-      sections.add(accessSection);
-    }
-    return sections;
-  }
-
-  private void checkGlobalCapabilityPermissions(Project.NameKey projectName)
-      throws BadRequestException, AuthException, PermissionBackendException {
-    if (!allProjects.equals(projectName)) {
-      throw new BadRequestException(
-          "Cannot edit global capabilities for projects other than " + allProjects.get());
-    }
-    permissionBackend.user(identifiedUser).check(GlobalPermission.ADMINISTRATE_SERVER);
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccessUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccessUtil.java
new file mode 100644
index 0000000..848d68c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccessUtil.java
@@ -0,0 +1,224 @@
+// 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.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.errors.InvalidNameException;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.GroupsCollection;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+@Singleton
+public class SetAccessUtil {
+  private final GroupsCollection groupsCollection;
+  private final AllProjectsName allProjects;
+  private final Provider<SetParent> setParent;
+
+  @Inject
+  private SetAccessUtil(
+      GroupsCollection groupsCollection,
+      AllProjectsName allProjects,
+      Provider<SetParent> setParent) {
+    this.groupsCollection = groupsCollection;
+    this.allProjects = allProjects;
+    this.setParent = setParent;
+  }
+
+  List<AccessSection> getAccessSections(Map<String, AccessSectionInfo> sectionInfos)
+      throws UnprocessableEntityException {
+    if (sectionInfos == null) {
+      return Collections.emptyList();
+    }
+
+    List<AccessSection> sections = new ArrayList<>(sectionInfos.size());
+    for (Map.Entry<String, AccessSectionInfo> entry : sectionInfos.entrySet()) {
+      if (entry.getValue().permissions == null) {
+        continue;
+      }
+
+      AccessSection accessSection = new AccessSection(entry.getKey());
+      for (Map.Entry<String, PermissionInfo> permissionEntry :
+          entry.getValue().permissions.entrySet()) {
+        if (permissionEntry.getValue().rules == null) {
+          continue;
+        }
+
+        Permission p = new Permission(permissionEntry.getKey());
+        if (permissionEntry.getValue().exclusive != null) {
+          p.setExclusiveGroup(permissionEntry.getValue().exclusive);
+        }
+
+        for (Map.Entry<String, PermissionRuleInfo> permissionRuleInfoEntry :
+            permissionEntry.getValue().rules.entrySet()) {
+          GroupDescription.Basic group = groupsCollection.parseId(permissionRuleInfoEntry.getKey());
+          if (group == null) {
+            throw new UnprocessableEntityException(
+                permissionRuleInfoEntry.getKey() + " is not a valid group ID");
+          }
+
+          PermissionRuleInfo pri = permissionRuleInfoEntry.getValue();
+          PermissionRule r = new PermissionRule(GroupReference.forGroup(group));
+          if (pri != null) {
+            if (pri.max != null) {
+              r.setMax(pri.max);
+            }
+            if (pri.min != null) {
+              r.setMin(pri.min);
+            }
+            r.setAction(GetAccess.ACTION_TYPE.inverse().get(pri.action));
+            if (pri.force != null) {
+              r.setForce(pri.force);
+            }
+          }
+          p.add(r);
+        }
+        accessSection.getPermissions().add(p);
+      }
+      sections.add(accessSection);
+    }
+    return sections;
+  }
+
+  /**
+   * Checks that the removals and additions are logically valid, but doesn't check current user's
+   * permission.
+   */
+  void validateChanges(
+      ProjectConfig config, List<AccessSection> removals, List<AccessSection> additions)
+      throws BadRequestException, InvalidNameException {
+    // Perform permission checks
+    for (AccessSection section : Iterables.concat(additions, removals)) {
+      boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
+      if (isGlobalCapabilities) {
+        if (!allProjects.equals(config.getName())) {
+          throw new BadRequestException(
+              "Cannot edit global capabilities for projects other than " + allProjects.get());
+        }
+      }
+    }
+
+    // Perform addition checks
+    for (AccessSection section : additions) {
+      String name = section.getName();
+      boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(name);
+
+      if (!isGlobalCapabilities) {
+        if (!AccessSection.isValid(name)) {
+          throw new BadRequestException("invalid section name");
+        }
+        RefPattern.validate(name);
+      } else {
+        // Check all permissions for soundness
+        for (Permission p : section.getPermissions()) {
+          if (!GlobalCapability.isCapability(p.getName())) {
+            throw new BadRequestException(
+                "Cannot add non-global capability " + p.getName() + " to global capabilities");
+          }
+        }
+      }
+    }
+  }
+
+  void applyChanges(
+      ProjectConfig config, List<AccessSection> removals, List<AccessSection> additions) {
+    // Apply removals
+    for (AccessSection section : removals) {
+      if (section.getPermissions().isEmpty()) {
+        // Remove entire section
+        config.remove(config.getAccessSection(section.getName()));
+        continue;
+      }
+
+      // Remove specific permissions
+      for (Permission p : section.getPermissions()) {
+        if (p.getRules().isEmpty()) {
+          config.remove(config.getAccessSection(section.getName()), p);
+        } else {
+          for (PermissionRule r : p.getRules()) {
+            config.remove(config.getAccessSection(section.getName()), p, r);
+          }
+        }
+      }
+    }
+
+    // Apply additions
+    for (AccessSection section : additions) {
+      AccessSection currentAccessSection = config.getAccessSection(section.getName());
+
+      if (currentAccessSection == null) {
+        // Add AccessSection
+        config.replace(section);
+      } else {
+        for (Permission p : section.getPermissions()) {
+          Permission currentPermission = currentAccessSection.getPermission(p.getName());
+          if (currentPermission == null) {
+            // Add Permission
+            currentAccessSection.addPermission(p);
+          } else {
+            for (PermissionRule r : p.getRules()) {
+              // AddPermissionRule
+              currentPermission.add(r);
+            }
+          }
+        }
+      }
+    }
+  }
+
+  void setParentName(
+      IdentifiedUser identifiedUser,
+      ProjectConfig config,
+      Project.NameKey projectName,
+      Project.NameKey newParentProjectName,
+      boolean checkAdmin)
+      throws ResourceConflictException, AuthException, PermissionBackendException {
+    if (newParentProjectName != null
+        && !config.getProject().getNameKey().equals(allProjects)
+        && !config.getProject().getParent(allProjects).equals(newParentProjectName)) {
+      try {
+        setParent
+            .get()
+            .validateParentUpdate(
+                projectName, identifiedUser, newParentProjectName.get(), checkAdmin);
+      } catch (UnprocessableEntityException e) {
+        throw new ResourceConflictException(e.getMessage(), e);
+      }
+      config.getProject().setParentName(newParentProjectName);
+    }
+  }
+}
diff --git a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
index 0a75bfc..f2d07ed 100644
--- a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -395,7 +395,7 @@
 
     MyParser(Object bean) {
       super(bean);
-      parseAdditionalOptions("", bean, new HashSet<>());
+      parseAdditionalOptions(bean, new HashSet<>());
       ensureOptionsInitialized();
     }
 
@@ -433,7 +433,7 @@
       }
     }
 
-    private void parseAdditionalOptions(String prefix, Object bean, Set<Object> parsedBeans) {
+    private void parseAdditionalOptions(Object bean, Set<Object> parsedBeans) {
       for (Class<?> c = bean.getClass(); c != null; c = c.getSuperclass()) {
         for (Field f : c.getDeclaredFields()) {
           if (f.isAnnotationPresent(Options.class)) {
@@ -443,8 +443,7 @@
             } catch (IllegalAccessException e) {
               throw new IllegalAnnotationError(e);
             }
-            parseWithPrefix(
-                prefix + f.getAnnotation(Options.class).prefix(), additionalBean, parsedBeans);
+            parseWithPrefix(f.getAnnotation(Options.class).prefix(), additionalBean, parsedBeans);
           }
         }
       }
diff --git a/lib/fonts/BUILD b/lib/fonts/BUILD
index 57429f3..025b93e 100644
--- a/lib/fonts/BUILD
+++ b/lib/fonts/BUILD
@@ -3,8 +3,12 @@
 # Roboto Mono. Version 2.136
 # https://github.com/google/roboto/releases/tag/v2.136
 filegroup(
-    name = "robotomono",
+    name = "robotofonts",
     srcs = [
+        "Roboto-Medium.woff",
+        "Roboto-Medium.woff2",
+        "Roboto-Regular.woff",
+        "Roboto-Regular.woff2",
         "RobotoMono-Regular.woff",
         "RobotoMono-Regular.woff2",
     ],
diff --git a/lib/fonts/Roboto-Medium.woff b/lib/fonts/Roboto-Medium.woff
new file mode 100644
index 0000000..720bd3e
--- /dev/null
+++ b/lib/fonts/Roboto-Medium.woff
Binary files differ
diff --git a/lib/fonts/Roboto-Medium.woff2 b/lib/fonts/Roboto-Medium.woff2
new file mode 100644
index 0000000..c003fba
--- /dev/null
+++ b/lib/fonts/Roboto-Medium.woff2
Binary files differ
diff --git a/lib/fonts/Roboto-Regular.woff b/lib/fonts/Roboto-Regular.woff
new file mode 100644
index 0000000..03e84eb
--- /dev/null
+++ b/lib/fonts/Roboto-Regular.woff
Binary files differ
diff --git a/lib/fonts/Roboto-Regular.woff2 b/lib/fonts/Roboto-Regular.woff2
new file mode 100644
index 0000000..6fa4939
--- /dev/null
+++ b/lib/fonts/Roboto-Regular.woff2
Binary files differ
diff --git a/plugins/replication b/plugins/replication
index 297b749..643d635 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 297b749038153527291b43cb08b162eb475adcd7
+Subproject commit 643d635a4502e2a2df6cb02edade88bad3fd953a
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index d4d2322..31ab6aa 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -29,7 +29,7 @@
 genrule2(
     name = "fonts",
     srcs = [
-        "//lib/fonts:robotomono",
+        "//lib/fonts:robotofonts",
     ],
     outs = ["fonts.zip"],
     cmd = " && ".join([
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
index 00aeb91..72439e2 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
@@ -70,6 +70,7 @@
     attached() {
       this._getCreateGroupCapability();
       this.fire('title-change', {title: 'Groups'});
+      this._maybeOpenCreateOverlay(this.params);
     },
 
     _paramsChanged(params) {
@@ -81,6 +82,16 @@
           this._offset);
     },
 
+    /**
+     * Opens the create overlay if the route has a hash 'create'
+     * @param {!Object} params
+     */
+    _maybeOpenCreateOverlay(params) {
+      if (params && params.openCreateModal) {
+        this.$.createOverlay.open();
+      }
+    },
+
     _computeGroupUrl(id) {
       return this.getUrl(this._path + '/', id);
     },
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
index 4cc1afe..06428c7 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
@@ -90,6 +90,18 @@
       test('_shownGroups', () => {
         assert.equal(element._shownGroups.length, 25);
       });
+
+      test('_maybeOpenCreateOverlay', () => {
+        const overlayOpen = sandbox.stub(element.$.createOverlay, 'open');
+        element._maybeOpenCreateOverlay();
+        assert.isFalse(overlayOpen.called);
+        const params = {};
+        element._maybeOpenCreateOverlay(params);
+        assert.isFalse(overlayOpen.called);
+        params.openCreateModal = true;
+        element._maybeOpenCreateOverlay(params);
+        assert.isTrue(overlayOpen.called);
+      });
     });
 
     suite('test with less then 25 groups', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.js b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.js
index 3b568ca..070cc2f 100644
--- a/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.js
@@ -70,6 +70,7 @@
     attached() {
       this._getCreateProjectCapability();
       this.fire('title-change', {title: 'Projects'});
+      this._maybeOpenCreateOverlay(this.params);
     },
 
     _paramsChanged(params) {
@@ -81,6 +82,16 @@
           this._offset);
     },
 
+    /**
+     * Opens the create overlay if the route has a hash 'create'
+     * @param {!Object} params
+     */
+    _maybeOpenCreateOverlay(params) {
+      if (params && params.openCreateModal) {
+        this.$.createOverlay.open();
+      }
+    },
+
     _computeProjectUrl(name) {
       return this.getUrl(this._path + '/', name);
     },
diff --git a/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list_test.html b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list_test.html
index 117df3a..87732b8 100644
--- a/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list_test.html
@@ -66,13 +66,11 @@
     suite('list with projects', () => {
       setup(done => {
         projects = _.times(26, projectGenerator);
-
         stub('gr-rest-api-interface', {
           getProjects(num, offset) {
             return Promise.resolve(projects);
           },
         });
-
         element._paramsChanged(value).then(() => { flush(done); });
       });
 
@@ -86,6 +84,18 @@
       test('_shownProjects', () => {
         assert.equal(element._shownProjects.length, 25);
       });
+
+      test('_maybeOpenCreateOverlay', () => {
+        const overlayOpen = sandbox.stub(element.$.createOverlay, 'open');
+        element._maybeOpenCreateOverlay();
+        assert.isFalse(overlayOpen.called);
+        const params = {};
+        element._maybeOpenCreateOverlay(params);
+        assert.isFalse(overlayOpen.called);
+        params.openCreateModal = true;
+        element._maybeOpenCreateOverlay(params);
+        assert.isTrue(overlayOpen.called);
+      });
     });
 
     suite('list with less then 25 projects', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
index 1d07cfe..fdd9b26 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
@@ -77,10 +77,15 @@
       <div class="patchFiles">
         <label>Patch file</label>
         <div>
-          <a id="download" href$="[[_computeDownloadLink(change, patchNum)]]">
+          <a
+              id="download"
+              href$="[[_computeDownloadLink(change, patchNum)]]"
+              download>
             [[_computeDownloadFilename(change, patchNum)]]
           </a>
-          <a href$="[[_computeZipDownloadLink(change, patchNum)]]">
+          <a
+              href$="[[_computeZipDownloadLink(change, patchNum)]]"
+              download>
             [[_computeZipDownloadFilename(change, patchNum)]]
           </a>
         </div>
@@ -89,7 +94,9 @@
         <label>Archive</label>
         <div id="archives" class="archives">
           <template is="dom-repeat" items="[[config.archives]]" as="format">
-            <a href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]">
+            <a
+                href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]"
+                download>
               [[format]]
             </a>
           </template>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
index b52363b..f6e1748 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
@@ -115,31 +115,39 @@
     let sandbox;
 
     setup(() => {
-      element = fixture('basic');
       sandbox = sinon.sandbox.create();
+
+      element = fixture('basic');
+      element.patchNum = '1';
+      element.config = {
+        schemes: {
+          'anonymous http': {},
+          'http': {},
+          'repo': {},
+          'ssh': {},
+        },
+        archives: ['tgz', 'tar', 'tbz2', 'txz'],
+      };
+
+      flushAsynchronousOperations();
     });
 
     teardown(() => {
       sandbox.restore();
     });
 
+    test('anchors use download attribute', () => {
+      const anchors = Polymer.dom(element.root).querySelectorAll('a');
+      assert.isTrue(!anchors.some(a => !a.hasAttribute('download')));
+    });
+
     suite('gr-download-dialog tests with no fetch options', () => {
       setup(() => {
         element.change = getChangeObjectNoFetch();
-        element.patchNum = '1';
-        element.config = {
-          schemes: {
-            'anonymous http': {},
-            'http': {},
-            'repo': {},
-            'ssh': {},
-          },
-          archives: ['tgz', 'tar', 'tbz2', 'txz'],
-        };
+        flushAsynchronousOperations();
       });
 
       test('focuses on first download link if no copy links', () => {
-        flushAsynchronousOperations();
         const focusStub = sandbox.stub(element.$.download, 'focus');
         element.focus();
         assert.isTrue(focusStub.called);
@@ -150,20 +158,10 @@
     suite('gr-download-dialog with fetch options', () => {
       setup(() => {
         element.change = getChangeObject();
-        element.patchNum = '1';
-        element.config = {
-          schemes: {
-            'anonymous http': {},
-            'http': {},
-            'repo': {},
-            'ssh': {},
-          },
-          archives: ['tgz', 'tar', 'tbz2', 'txz'],
-        };
+        flushAsynchronousOperations();
       });
 
       test('focuses on first copy link', () => {
-        flushAsynchronousOperations();
         const focusStub = sinon.stub(element.$.downloadCommands, 'focusOnCopy');
         element.focus();
         flushAsynchronousOperations();
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
index c147456..e95bd41 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
@@ -66,7 +66,7 @@
       if (switchAccountUrl) {
         const replacements = {path};
         const url = this._interpolateUrl(switchAccountUrl, replacements);
-        links.push({name: 'Switch account', url});
+        links.push({name: 'Switch account', url, external: true});
       }
       links.push({name: 'Sign out', url: '/logout'});
       return links;
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
index 6017cb9..1183d9c 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
@@ -83,14 +83,20 @@
       // Unparameterized switch account link.
       let links = element._getLinks('/switch-account');
       assert.equal(links.length, 3);
-      assert.deepEqual(links[1],
-          {name: 'Switch account', url: '/switch-account'});
+      assert.deepEqual(links[1], {
+        name: 'Switch account',
+        url: '/switch-account',
+        external: true,
+      });
 
       // Parameterized switch account link.
       links = element._getLinks('/switch-account${path}', '/c/123');
       assert.equal(links.length, 3);
-      assert.deepEqual(links[1],
-          {name: 'Switch account', url: '/switch-account/c/123'});
+      assert.deepEqual(links[1], {
+        name: 'Switch account',
+        url: '/switch-account/c/123',
+        external: true,
+      });
     });
 
     test('_interpolateUrl', () => {
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
index c1a5978..5523032 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
@@ -78,6 +78,9 @@
         flex: 1;
         justify-content: flex-end;
       }
+      .rightItems gr-endpoint-decorator:not(:empty) {
+        margin-left: 1em;
+      }
       gr-search-bar {
         flex-grow: 1;
         margin-left: .5em;
@@ -121,6 +124,7 @@
         }
         gr-search-bar,
         .browse,
+        .rightItems .hideOnMobile,
         .links > li.hideOnMobile {
           display: none;
         }
@@ -159,6 +163,9 @@
       </ul>
       <div class="rightItems">
         <gr-search-bar value="{{searchQuery}}" role="search"></gr-search-bar>
+        <gr-endpoint-decorator
+            class="hideOnMobile"
+            name="header-browse-source"></gr-endpoint-decorator>
         <div class="accountContainer" id="accountContainer">
           <a class="loginButton" href$="[[_loginURL]]">Sign in</a>
           <gr-account-dropdown account="[[_account]]"></gr-account-dropdown>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index 1ce3c23..2de74e1 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -46,6 +46,12 @@
     GROUP_LIST_FILTER: '/admin/groups/q/filter::filter',
     GROUP_LIST_FILTER_OFFSET: '/admin/groups/q/filter::filter,:offset',
 
+    // Matches /admin/create-project
+    LEGACY_CREATE_PROJECT: /^\/admin\/create-project\/?$/,
+
+    // Matches /admin/create-project
+    LEGACY_CREATE_GROUP: /^\/admin\/create-group\/?$/,
+
     // Matches /admin/projects/<project>
     PROJECT: /^\/admin\/projects\/([^,]+)$/,
 
@@ -72,6 +78,8 @@
     TAG_LIST_FILTER_OFFSET:
         '/admin/projects/:project,tags/q/filter::filter,:offset',
 
+    PLUGINS: /^\/plugins\/(.+)$/,
+
     PLUGIN_LIST: /^\/admin\/plugins(\/)?$/,
 
     // Matches /admin/plugins[,<offset>][/].
@@ -463,6 +471,12 @@
       this._mapRoute(RoutePattern.TAG_LIST_FILTER,
           '_handleTagListFilterRoute');
 
+      this._mapRoute(RoutePattern.LEGACY_CREATE_GROUP,
+          '_handleCreateGroupRoute', true);
+
+      this._mapRoute(RoutePattern.LEGACY_CREATE_PROJECT,
+          '_handleCreateProjectRoute', true);
+
       this._mapRoute(RoutePattern.PROJECT_LIST_OFFSET,
           '_handleProjectListOffsetRoute');
 
@@ -474,6 +488,8 @@
 
       this._mapRoute(RoutePattern.PROJECT, '_handleProjectRoute');
 
+      this._mapRoute(RoutePattern.PLUGINS, '_handlePassThroughRoute');
+
       this._mapRoute(RoutePattern.PLUGIN_LIST_OFFSET,
           '_handlePluginListOffsetRoute', true);
 
@@ -612,6 +628,7 @@
         adminView: 'gr-admin-group-list',
         offset: data.params[1] || 0,
         filter: null,
+        openCreateModal: data.hash === 'create',
       });
     },
 
@@ -728,6 +745,7 @@
         adminView: 'gr-project-list',
         offset: data.params[1] || 0,
         filter: null,
+        openCreateModal: data.hash === 'create',
       });
     },
 
@@ -748,6 +766,18 @@
       });
     },
 
+    _handleCreateProjectRoute(data) {
+      // Redirects the legacy route to the new route, which displays the project
+      // list with a hash 'create'.
+      this._redirect('/admin/projects#create');
+    },
+
+    _handleCreateGroupRoute(data) {
+      // Redirects the legacy route to the new route, which displays the group
+      // list with a hash 'create'.
+      this._redirect('/admin/groups#create');
+    },
+
     _handleProjectRoute(data) {
       this._setParams({
         view: Gerrit.Nav.View.ADMIN,
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
index c32f6d4..831b905 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
@@ -125,6 +125,8 @@
       const shouldRequireAutoAuth = [
         '_handleAdminPlaceholderRoute',
         '_handleAgreementsRoute',
+        '_handleCreateGroupRoute',
+        '_handleCreateProjectRoute',
         '_handleDiffEditRoute',
         '_handleGroupAuditLogRoute',
         '_handleGroupInfoRoute',
@@ -641,6 +643,7 @@
             adminView: 'gr-admin-group-list',
             offset: 0,
             filter: null,
+            openCreateModal: false,
           });
 
           data.params[1] = 42;
@@ -649,6 +652,16 @@
             adminView: 'gr-admin-group-list',
             offset: 42,
             filter: null,
+            openCreateModal: false,
+          });
+
+          data.hash = 'create';
+          assertDataToParams(data, '_handleGroupListOffsetRoute', {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-admin-group-list',
+            offset: 42,
+            filter: null,
+            openCreateModal: true,
           });
         });
 
@@ -812,6 +825,7 @@
               adminView: 'gr-project-list',
               offset: 0,
               filter: null,
+              openCreateModal: false,
             });
 
             data.params[1] = 42;
@@ -820,6 +834,16 @@
               adminView: 'gr-project-list',
               offset: 42,
               filter: null,
+              openCreateModal: false,
+            });
+
+            data.hash = 'create';
+            assertDataToParams(data, '_handleProjectListOffsetRoute', {
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-list',
+              offset: 42,
+              filter: null,
+              openCreateModal: true,
             });
           });
 
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index defbe8a..406a4a7 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -46,6 +46,7 @@
 <link rel="import" href="./core/gr-reporting/gr-reporting.html">
 <link rel="import" href="./core/gr-router/gr-router.html">
 <link rel="import" href="./diff/gr-diff-view/gr-diff-view.html">
+<link rel="import" href="./plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="./plugins/gr-external-style/gr-external-style.html">
 <link rel="import" href="./plugins/gr-plugin-host/gr-plugin-host.html">
 <link rel="import" href="./settings/gr-cla-view/gr-cla-view.html">
@@ -201,6 +202,7 @@
           on-close="_handleRegistrationDialogClose">
       </gr-registration-dialog>
     </gr-overlay>
+    <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
     <gr-error-manager id="errorManager"></gr-error-manager>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting"></gr-reporting>
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
index 02a2085..889333b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
@@ -14,49 +14,122 @@
 (function(window) {
   'use strict';
 
-  function GrDomHooks(plugin) {
+  function GrDomHooksManager(plugin) {
     this._plugin = plugin;
     this._hooks = {};
   }
 
-  GrDomHooks.prototype._getName = function(endpointName) {
-    return this._plugin.getPluginName() + '-autogenerated-' + endpointName;
+  GrDomHooksManager.prototype._getHookName = function(endpointName,
+      opt_moduleName) {
+    if (opt_moduleName) {
+      return endpointName + ' ' + opt_moduleName;
+    } else {
+      return this._plugin.getPluginName() + '-autogenerated-' + endpointName;
+    }
   };
 
-  GrDomHooks.prototype.getDomHook = function(endpointName) {
-    const hookName = this._getName(endpointName);
+  GrDomHooksManager.prototype.getDomHook = function(endpointName,
+      opt_moduleName) {
+    const hookName = this._getHookName(endpointName, opt_moduleName);
     if (!this._hooks[hookName]) {
-      this._hooks[hookName] = new GrDomHook(hookName);
+      this._hooks[hookName] = new GrDomHook(hookName, opt_moduleName);
     }
     return this._hooks[hookName];
   };
 
-  function GrDomHook(hookName) {
+  function GrDomHook(hookName, opt_moduleName) {
+    this._instances = [];
     this._callbacks = [];
-    // Expose to closure.
-    const callbacks = this._callbacks;
-    this._componentClass = Polymer({
+    if (opt_moduleName) {
+      this._moduleName = opt_moduleName;
+    } else {
+      this._moduleName = hookName;
+      this._createPlaceholder(hookName);
+    }
+  }
+
+  GrDomHook.prototype._createPlaceholder = function(hookName) {
+    Polymer({
       is: hookName,
       properties: {
         plugin: Object,
         content: Object,
       },
-      attached() {
-        callbacks.forEach(callback => {
-          callback(this);
-        });
-      },
     });
-  }
+  };
 
+  GrDomHook.prototype.handleInstanceDetached = function(instance) {
+    const index = this._instances.indexOf(instance);
+    if (index !== -1) {
+      this._instances.splice(index, 1);
+    }
+  };
+
+  GrDomHook.prototype.handleInstanceAttached = function(instance) {
+    this._instances.push(instance);
+    this._callbacks.forEach(callback => callback(instance));
+  };
+
+  /**
+   * Get instance of last DOM hook element attached into the endpoint.
+   * Returns a Promise, that's resolved when attachment is done.
+   * @return {!Promise<!Element>}
+   */
+  GrDomHook.prototype.getLastAttached = function() {
+    if (this._instances.length) {
+      return Promise.resolve(this._instances.slice(-1)[0]);
+    }
+    if (!this._lastAttachedPromise) {
+      let resolve;
+      const promise = new Promise(r => resolve = r);
+      this._callbacks.push(resolve);
+      this._lastAttachedPromise = promise.then(element => {
+        this._lastAttachedPromise = null;
+        const index = this._callbacks.indexOf(resolve);
+        if (index !== -1) {
+          this._callbacks.splice(index, 1);
+        }
+        return element;
+      });
+    }
+    return this._lastAttachedPromise;
+  };
+
+  /**
+   * Get all DOM hook elements.
+   */
+  GrDomHook.prototype.getAllAttached = function() {
+    return this._instances;
+  };
+
+  /**
+   * Install a new callback to invoke when a new instance of DOM hook element
+   * is attached.
+   * @param {function(Element)} callback
+   */
   GrDomHook.prototype.onAttached = function(callback) {
     this._callbacks.push(callback);
     return this;
   };
 
+  /**
+   * Name of DOM hook element that will be installed into the endpoint.
+   */
   GrDomHook.prototype.getModuleName = function() {
-    return this._componentClass.prototype.is;
+    return this._moduleName;
   };
 
-  window.GrDomHooks = GrDomHooks;
+  GrDomHook.prototype.getPublicAPI = function() {
+    const result = {};
+    const exposedMethods = [
+      'onAttached', 'getLastAttached', 'getAllAttached', 'getModuleName',
+    ];
+    for (const p of exposedMethods) {
+      result[p] = this[p].bind(this);
+    }
+    return result;
+  };
+
+  window.GrDomHook = GrDomHook;
+  window.GrDomHooksManager = GrDomHooksManager;
 })(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
index f92f5c5..f5a7f6f 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
@@ -33,28 +33,110 @@
 
 <script>
   suite('gr-dom-hooks tests', () => {
+    const PUBLIC_METHODS =
+        ['onAttached', 'getLastAttached', 'getAllAttached', 'getModuleName'];
+
     let instance;
     let sandbox;
+    let hook;
+    let hookInternal;
 
     setup(() => {
       sandbox = sinon.sandbox.create();
       let plugin;
       Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
-      instance = new GrDomHooks(plugin);
+      instance = new GrDomHooksManager(plugin);
     });
 
     teardown(() => {
       sandbox.restore();
     });
 
-    test('defines a Polymer components', () => {
-      const onAttachedSpy = sandbox.spy();
-      instance.getDomHook('foo-bar').onAttached(onAttachedSpy);
-      const hookName = Object.keys(instance._hooks).pop();
-      assert.equal(hookName, 'testplugin-autogenerated-foo-bar');
-      const el = fixture('basic').appendChild(document.createElement(hookName));
-      assert.isTrue(onAttachedSpy.calledWithExactly(el));
+    suite('placeholder', () => {
+      setup(()=>{
+        sandbox.stub(GrDomHook.prototype, '_createPlaceholder');
+        hookInternal = instance.getDomHook('foo-bar');
+        hook = hookInternal.getPublicAPI();
+      });
+
+      test('public hook API has only public methods', () => {
+        assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
+      });
+
+      test('registers placeholder class', () => {
+        assert.isTrue(hookInternal._createPlaceholder.calledWithExactly(
+            'testplugin-autogenerated-foo-bar'));
+      });
+
+      test('getModuleName()', () => {
+        const hookName = Object.keys(instance._hooks).pop();
+        assert.equal(hookName, 'testplugin-autogenerated-foo-bar');
+        assert.equal(hook.getModuleName(), 'testplugin-autogenerated-foo-bar');
+      });
+    });
+
+    suite('custom element', () => {
+      setup(() => {
+        hookInternal = instance.getDomHook('foo-bar', 'my-el');
+        hook = hookInternal.getPublicAPI();
+      });
+
+      test('public hook API has only public methods', () => {
+        assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
+      });
+
+      test('getModuleName()', () => {
+        const hookName = Object.keys(instance._hooks).pop();
+        assert.equal(hookName, 'foo-bar my-el');
+        assert.equal(hook.getModuleName(), 'my-el');
+      });
+
+      test('onAttached', () => {
+        const onAttachedSpy = sandbox.spy();
+        hook.onAttached(onAttachedSpy);
+        const [el1, el2] = [
+          document.createElement(hook.getModuleName()),
+          document.createElement(hook.getModuleName()),
+        ];
+        hookInternal.handleInstanceAttached(el1);
+        hookInternal.handleInstanceAttached(el2);
+        assert.isTrue(onAttachedSpy.firstCall.calledWithExactly(el1));
+        assert.isTrue(onAttachedSpy.secondCall.calledWithExactly(el2));
+      });
+
+      test('getAllAttached', () => {
+        const [el1, el2] = [
+          document.createElement(hook.getModuleName()),
+          document.createElement(hook.getModuleName()),
+        ];
+        el1.textContent = 'one';
+        el2.textContent = 'two';
+        hookInternal.handleInstanceAttached(el1);
+        hookInternal.handleInstanceAttached(el2);
+        assert.deepEqual([el1, el2], hook.getAllAttached());
+        hookI.handleInstanceDetached(el1);
+        assert.deepEqual([el2], hook.getAllAttached());
+      });
+
+      test('getLastAttached', () => {
+        const beforeAttachedPromise = hook.getLastAttached().then(
+            el => assert.strictEqual(el1, el));
+        const [el1, el2] = [
+          document.createElement(hook.getModuleName()),
+          document.createElement(hook.getModuleName()),
+        ];
+        el1.textContent = 'one';
+        el2.textContent = 'two';
+        hookInternal.handleInstanceAttached(el1);
+        hookInternal.handleInstanceAttached(el2);
+        const afterAttachedPromise = hook.getLastAttached().then(
+            el => assert.strictEqual(el2, el));
+        return Promise.all([
+          beforeAttachedPromise,
+          afterAttachedPromise,
+        ]);
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html
index 49424a1..0928534 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html
@@ -18,7 +18,7 @@
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 
 <dom-module id="gr-endpoint-decorator">
-  <template>
+  <template strip-whitespace>
     <content></content>
   </template>
   <script src="gr-endpoint-decorator.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
index 7e74494..bcd2378 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
@@ -19,6 +19,17 @@
 
     properties: {
       name: String,
+      /** @type {!Map} */
+      _domHooks: {
+        type: Map,
+        value() { return new Map(); },
+      },
+    },
+
+    detached() {
+      for (const [el, domHook] of this._domHooks) {
+        domHook.handleInstanceDetached(el);
+      }
     },
 
     _import(url) {
@@ -31,33 +42,48 @@
       const el = document.createElement(name);
       el.plugin = plugin;
       el.content = this.getContentChildren()[0];
-      return Polymer.dom(this.root).appendChild(el);
+      this._appendChild(el);
+      return el;
     },
 
     _initReplacement(name, plugin) {
       this.getContentChildren().forEach(node => node.remove());
       const el = document.createElement(name);
       el.plugin = plugin;
-      return Polymer.dom(this.root).appendChild(el);
+      this._appendChild(el);
+      return el;
+    },
+
+    _appendChild(el) {
+      Polymer.dom(this.root).appendChild(el);
+    },
+
+    _initModule({moduleName, plugin, type, domHook}) {
+      let el;
+      switch (type) {
+        case 'decorate':
+          el = this._initDecoration(moduleName, plugin);
+          break;
+        case 'replace':
+          el = this._initReplacement(moduleName, plugin);
+          break;
+      }
+      if (el) {
+        domHook.handleInstanceAttached(el);
+      }
+      this._domHooks.set(el, domHook);
     },
 
     ready() {
+      Gerrit._endpoints.onNewEndpoint(this.name, this._initModule.bind(this));
       Gerrit.awaitPluginsLoaded().then(() => Promise.all(
           Gerrit._endpoints.getPlugins(this.name).map(
               pluginUrl => this._import(pluginUrl)))
-      ).then(() => {
-        const modulesData = Gerrit._endpoints.getDetails(this.name);
-        for (const {moduleName, plugin, type} of modulesData) {
-          switch (type) {
-            case 'decorate':
-              this._initDecoration(moduleName, plugin);
-              break;
-            case 'replace':
-              this._initReplacement(moduleName, plugin);
-              break;
-          }
-        }
-      });
+      ).then(() =>
+        Gerrit._endpoints
+            .getDetails(this.name)
+            .forEach(this._initModule, this)
+      );
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
index 8b96dee..e7d1930 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
@@ -34,10 +34,20 @@
     let sandbox;
     let element;
     let plugin;
+    let domHookStub;
 
     setup(done => {
+      Gerrit._endpoints = new GrPluginEndpoints();
+
       sandbox = sinon.sandbox.create();
 
+      domHookStub = {
+        handleInstanceAttached: sandbox.stub(),
+        handleInstanceDetached: sandbox.stub(),
+      };
+      sandbox.stub(
+          GrDomHooksManager.prototype, 'getDomHook').returns(domHookStub);
+
       // NB: Order is important.
       Gerrit.install(p => {
         plugin = p;
@@ -45,11 +55,12 @@
         plugin.registerCustomComponent('foo', 'other-module', {replace: true});
       }, '0.1', 'http://some/plugin/url.html');
 
+      sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
       sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
 
       element = fixture('basic');
-      sandbox.stub(element, '_initDecoration');
-      sandbox.stub(element, '_initReplacement');
+      sandbox.stub(element, '_initDecoration').returns({});
+      sandbox.stub(element, '_initReplacement').returns({});
       sandbox.stub(element, 'importHref', (url, resolve) => resolve());
 
       flush(done);
@@ -65,13 +76,39 @@
     });
 
     test('inits decoration dom hook', () => {
-      assert.isTrue(
-          element._initDecoration.calledWith('some-module', plugin));
+      assert.strictEqual(
+          element._initDecoration.lastCall.args[0], 'some-module');
+      assert.strictEqual(
+          element._initDecoration.lastCall.args[1], plugin);
     });
 
     test('inits replacement dom hook', () => {
-      assert.isTrue(
-          element._initReplacement.calledWith('other-module', plugin));
+      assert.strictEqual(
+          element._initReplacement.lastCall.args[0], 'other-module');
+      assert.strictEqual(
+          element._initReplacement.lastCall.args[1], plugin);
+    });
+
+    test('calls dom hook handleInstanceAttached', () => {
+      assert.equal(domHookStub.handleInstanceAttached.callCount, 2);
+    });
+
+    test('calls dom hook handleInstanceDetached', () => {
+      element.detached();
+      assert.equal(domHookStub.handleInstanceDetached.callCount, 2);
+    });
+
+    test('installs modules on late registration', done => {
+      domHookStub.handleInstanceAttached.reset();
+      plugin.registerCustomComponent('foo', 'noob-noob');
+      flush(() => {
+        assert.equal(domHookStub.handleInstanceAttached.callCount, 1);
+        assert.strictEqual(
+            element._initDecoration.lastCall.args[0], 'noob-noob');
+        assert.strictEqual(
+            element._initDecoration.lastCall.args[1], plugin);
+        done();
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html
new file mode 100644
index 0000000..3ccb3fd
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html
@@ -0,0 +1,28 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+
+<dom-module id="gr-plugin-popup">
+  <template>
+    <style include="shared-styles"></style>
+    <gr-overlay id="overlay" with-backdrop>
+      <content></content>
+    </gr-overlay>
+  </template>
+  <script src="gr-plugin-popup.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
new file mode 100644
index 0000000..8286eae
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
@@ -0,0 +1,28 @@
+// 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.
+(function(window) {
+  'use strict';
+  Polymer({
+    is: 'gr-plugin-popup',
+    get opened() {
+      return this.$.overlay.opened;
+    },
+    open() {
+      return this.$.overlay.open();
+    },
+    close() {
+      this.$.overlay.close();
+    },
+  });
+})(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
new file mode 100644
index 0000000..2dbf96d
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-plugin-popup</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-plugin-popup.html"/>
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-plugin-popup></gr-plugin-popup>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-plugin-popup tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      stub('gr-overlay', {
+        open: sandbox.stub().returns(Promise.resolve()),
+        close: sandbox.stub(),
+      });
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('exists', () => {
+      assert.isOk(element);
+    });
+
+    test('open uses open() from gr-overlay', () => {
+      return element.open().then(() => {
+        assert.isTrue(element.$.overlay.open.called);
+      });
+    });
+
+    test('close uses close() from gr-overlay', () => {
+      element.close();
+      assert.isTrue(element.$.overlay.close.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html
new file mode 100644
index 0000000..6bf37de
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html
@@ -0,0 +1,23 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+<link rel="import" href="gr-plugin-popup.html">
+
+<dom-module id="gr-popup-interface">
+  <script src="gr-popup-interface.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
new file mode 100644
index 0000000..e62e882
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
@@ -0,0 +1,71 @@
+// 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.
+(function(window) {
+  'use strict';
+
+  /**
+   * Plugin popup API.
+   * Provides method for opening and closing popups from plugin.
+   * opt_moduleName is a name of custom element that will be automatically
+   * inserted on popup opening.
+   * @param {!Object} plugin
+   * @param {opt_moduleName=} string
+   */
+  function GrPopupInterface(plugin, opt_moduleName) {
+    this.plugin = plugin;
+    this._openingPromise = null;
+    this._popup = null;
+    this._moduleName = opt_moduleName || null;
+  }
+
+  GrPopupInterface.prototype._getElement = function() {
+    return Polymer.dom(this._popup);
+  };
+
+  /**
+   * 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.
+   * @returns {!Promise<!Object>}
+   */
+  GrPopupInterface.prototype.open = function() {
+    if (!this._openingPromise) {
+      this._openingPromise =
+          this.plugin.hook('plugin-overlay').getLastAttached()
+      .then(hookEl => {
+        const popup = document.createElement('gr-plugin-popup');
+        if (this._moduleName) {
+          const el = Polymer.dom(popup).appendChild(
+              document.createElement(this._moduleName));
+          el.plugin = this.plugin;
+        }
+        this._popup = Polymer.dom(hookEl).appendChild(popup);
+        Polymer.dom.flush();
+        return this._popup.open().then(() => this);
+      });
+    }
+    return this._openingPromise;
+  };
+
+  /**
+   * Hides the popup.
+   */
+  GrPopupInterface.prototype.close = function() {
+    if (!this._popup) { return; }
+    this._popup.close();
+    this._openingPromise = null;
+  };
+
+  window.GrPopupInterface = GrPopupInterface;
+})(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
new file mode 100644
index 0000000..7d9dd28
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
@@ -0,0 +1,112 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-popup-interface</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-popup-interface.html"/>
+
+<script>void(0);</script>
+
+<test-fixture id="container">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
+
+<dom-module id="gr-user-test-popup">
+  <template>
+    <div id="barfoo">some test module</div>
+  </template>
+  <script>Polymer({is: 'gr-user-test-popup'});</script>
+</dom-module>
+
+<script>
+  suite('gr-popup-interface tests', () => {
+    let container;
+    let instance;
+    let plugin;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      container = fixture('container');
+      sandbox.stub(plugin, 'hook').returns({
+        getLastAttached() {
+          return Promise.resolve(container);
+        },
+      });
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('manual', () => {
+      setup(() => {
+        instance = new GrPopupInterface(plugin);
+      });
+
+      test('open', () => {
+        return instance.open().then(api => {
+          assert.strictEqual(api, instance);
+          const manual = document.createElement('div');
+          manual.id = 'foobar';
+          manual.innerHTML = 'manual content';
+          api._getElement().appendChild(manual);
+          flushAsynchronousOperations();
+          assert.equal(
+              container.querySelector('#foobar').textContent, 'manual content');
+        });
+      });
+
+      test('close', () => {
+        return instance.open().then(api => {
+          assert.isTrue(api._getElement().node.opened);
+          api.close();
+          assert.isFalse(api._getElement().node.opened);
+        });
+      });
+    });
+
+    suite('components', () => {
+      setup(() => {
+        instance = new GrPopupInterface(plugin, 'gr-user-test-popup');
+      });
+
+      test('open', () => {
+        return instance.open().then(api => {
+          assert.isNotNull(
+              Polymer.dom(container).querySelector('gr-user-test-popup'));
+        });
+      });
+
+      test('close', () => {
+        return instance.open().then(api => {
+          assert.isTrue(api._getElement().node.opened);
+          api.close();
+          assert.isFalse(api._getElement().node.opened);
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
index e91ab0a..d57b301 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
@@ -22,7 +22,7 @@
   }
 
   GrThemeApi.prototype.setHeaderLogoAndTitle = function(logoUrl, title) {
-    this.plugin.getDomHook('header-title', {replace: true}).onAttached(
+    this.plugin.hook('header-title', {replace: true}).onAttached(
         element => {
           const customHeader =
                 document.createElement('gr-custom-plugin-header');
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
index a66ab2f..ec8f04c 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -14,6 +14,9 @@
 (function() {
   'use strict';
 
+  const REL_NOOPENER = 'noopener';
+  const REL_EXTERNAL = 'external';
+
   Polymer({
     is: 'gr-dropdown',
 
@@ -83,6 +86,10 @@
       'up': '_handleUp',
     },
 
+    /**
+     * Handle the up key.
+     * @param {!Event} e
+     */
     _handleUp(e) {
       if (this.$.dropdown.opened) {
         e.preventDefault();
@@ -93,6 +100,10 @@
       }
     },
 
+    /**
+     * Handle the down key.
+     * @param {!Event} e
+     */
     _handleDown(e) {
       if (this.$.dropdown.opened) {
         e.preventDefault();
@@ -103,6 +114,10 @@
       }
     },
 
+    /**
+     * Handle the tab key.
+     * @param {!Event} e
+     */
     _handleTab(e) {
       if (this.$.dropdown.opened) {
         // Tab in a native select is a no-op. Emulate this.
@@ -111,6 +126,10 @@
       }
     },
 
+    /**
+     * Handle the enter key.
+     * @param {!Event} e
+     */
     _handleEnter(e) {
       e.preventDefault();
       e.stopPropagation();
@@ -125,6 +144,10 @@
       }
     },
 
+    /**
+     * Handle a click on the iron-dropdown element.
+     * @param {!Event} e
+     */
     _handleDropdownTap(e) {
       // async is needed so that that the click event is fired before the
       // dropdown closes (This was a bug for touch devices).
@@ -133,10 +156,17 @@
       }, 1);
     },
 
+    /**
+     * Hanlde a click on the button to open the dropdown.
+     * @param {!Event} e
+     */
     _showDropdownTapHandler(e) {
       this._open();
     },
 
+    /**
+     * Open the dropdown and initialize the cursor.
+     */
     _open() {
       this.$.dropdown.open();
       this.$.cursor.setCursorAtIndex(0);
@@ -144,19 +174,43 @@
       this.$.cursor.target.focus();
     },
 
+    /**
+     * Get the class for a top-content item based on the given boolean.
+     * @param {boolean} bold Whether the item is bold.
+     * @return {string} The class for the top-content item.
+     */
     _getClassIfBold(bold) {
       return bold ? 'bold-text' : '';
     },
 
+    /**
+     * Build a URL for the given host and path. If there is a base URL, it will
+     * be included between the host and the path.
+     * @param {!string} host
+     * @param {!string} path
+     * @return {!string} The scheme-relative URL.
+     */
     _computeURLHelper(host, path) {
       return '//' + host + this.getBaseUrl() + path;
     },
 
+    /**
+     * Build a scheme-relative URL for the current host. Will include the base
+     * URL if one is present. Note: the URL will be scheme-relative but absolute
+     * with regard to the host.
+     * @param {!string} path The path for the URL.
+     * @return {!string} The scheme-relative URL.
+     */
     _computeRelativeURL(path) {
       const host = window.location.host;
       return this._computeURLHelper(host, path);
     },
 
+    /**
+     * Compute the URL for a link object.
+     * @param {!Object} link The object describing the link.
+     * @return {!string} The URL.
+     */
     _computeLinkURL(link) {
       if (typeof link.url === 'undefined') {
         return '';
@@ -167,10 +221,24 @@
       return this._computeRelativeURL(link.url);
     },
 
+    /**
+     * Compute the value for the rel attribute of an anchor for the given link
+     * object. If the link has a target value, then the rel must be "noopener"
+     * for security reasons.
+     * @param {!Object} link The object describing the link.
+     * @return {?string} The rel value for the link.
+     */
     _computeLinkRel(link) {
-      return link.target ? 'noopener' : null;
+      // Note: noopener takes precedence over external.
+      if (link.target) { return REL_NOOPENER; }
+      if (link.external) { return REL_EXTERNAL; }
+      return null;
     },
 
+    /**
+     * Handle a click on an item of the dropdown.
+     * @param {!Event} e
+     */
     _handleItemTap(e) {
       const id = e.target.getAttribute('data-id');
       const item = this.items.find(item => item.id === id);
@@ -182,10 +250,20 @@
       }
     },
 
+    /**
+     * If a dropdown item is shown as a button, get the class for the button.
+     * @param {string} id
+     * @param {!Object} disabledIdsRecord The change record for the disabled IDs
+     *     list.
+     * @return {!string} The class for the item button.
+     */
     _computeDisabledClass(id, disabledIdsRecord) {
       return disabledIdsRecord.base.includes(id) ? 'disabled' : '';
     },
 
+    /**
+     * Recompute the stops for the dropdown item cursor.
+     */
     _resetCursorStops() {
       Polymer.dom.flush();
       this._listElements = Polymer.dom(this.root).querySelectorAll('li');
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
index 8654ac8..ab31f7c 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
@@ -72,10 +72,17 @@
     });
 
     test('link rel', () => {
-      assert.isNull(element._computeLinkRel({url: '/test'}));
-      assert.equal(
-          element._computeLinkRel({url: '/test', target: '_blank'}),
-          'noopener');
+      let link = {url: '/test'};
+      assert.isNull(element._computeLinkRel(link));
+
+      link = {url: '/test', target: '_blank'};
+      assert.equal(element._computeLinkRel(link), 'noopener');
+
+      link = {url: '/test', external: true};
+      assert.equal(element._computeLinkRel(link), 'external');
+
+      link = {url: '/test', target: '_blank', external: true};
+      assert.equal(element._computeLinkRel(link), 'noopener');
     });
 
     test('_getClassIfBold', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
index df407a9..65aa364 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
@@ -49,7 +49,7 @@
 
   GrChangeReplyInterface.prototype.addReplyTextChangedCallback =
     function(handler) {
-      this.plugin.getDomHook('reply-text').onAttached(el => {
+      this.plugin.hook('reply-text').onAttached(el => {
         if (!el.content) { return; }
         el.content.addEventListener('value-changed', e => {
           handler(e.detail.value);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
index f6e2b64..53f889f 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
@@ -19,6 +19,7 @@
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../plugins/gr-attribute-helper/gr-attribute-helper.html">
 <link rel="import" href="../../plugins/gr-dom-hooks/gr-dom-hooks.html">
+<link rel="import" href="../../plugins/gr-popup-interface/gr-popup-interface.html">
 <link rel="import" href="../../plugins/gr-theme-api/gr-theme-api.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
index ca0f372..dec2dc3d 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
@@ -120,6 +120,26 @@
       });
     });
 
+    test('delete works', () => {
+      const response = {status: 204};
+      sendStub.returns(Promise.resolve(response));
+      return plugin.delete('/url', r => {
+        assert.isTrue(sendStub.calledWith('DELETE', '/url'));
+        assert.strictEqual(r, response);
+      });
+    });
+
+    test('delete fails', () => {
+      sendStub.returns(Promise.resolve(
+          {status: 400, text() { return Promise.resolve('text'); }}));
+      return plugin.delete('/url', r => {
+        throw new Error('Should not resolve');
+      }).catch(err => {
+        assert.isTrue(sendStub.calledWith('DELETE', '/url'));
+        assert.equal('text', err);
+      });
+    });
+
     test('history event', done => {
       plugin.on(element.EventType.HISTORY, throwErrFn);
       plugin.on(element.EventType.HISTORY, path => {
@@ -338,5 +358,35 @@
             'http://test.com/r/plugins/testplugin/static/test.js');
       });
     });
+
+    suite('popup', () => {
+      test('popup(element) is deprecated', () => {
+        assert.throws(() => {
+          plugin.popup(document.createElement('div'));
+        });
+      });
+
+      test('popup(moduleName) creates popup with component', () => {
+        const openStub = sandbox.stub();
+        sandbox.stub(window, 'GrPopupInterface').returns({
+          open: openStub,
+        });
+        plugin.popup('some-name');
+        assert.isTrue(openStub.calledOnce);
+        assert.isTrue(GrPopupInterface.calledWith(plugin, 'some-name'));
+      });
+
+      test('deprecated.popup(element) creates popup with element', () => {
+        const el = document.createElement('div');
+        el.textContent = 'some text here';
+        const openStub = sandbox.stub(GrPopupInterface.prototype, 'open');
+        openStub.returns(Promise.resolve({
+          _getElement() {
+            return document.createElement('div');
+          }}));
+        plugin.deprecated.popup(el);
+        assert.isTrue(openStub.calledOnce);
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
index 52b1fb7..1ee9eec 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
@@ -16,19 +16,32 @@
 
   function GrPluginEndpoints() {
     this._endpoints = {};
+    this._callbacks = {};
   }
 
+  GrPluginEndpoints.prototype.onNewEndpoint = function(endpoint, callback) {
+    if (!this._callbacks[endpoint]) {
+      this._callbacks[endpoint] = [];
+    }
+    this._callbacks[endpoint].push(callback);
+  };
+
   GrPluginEndpoints.prototype.registerModule = function(plugin, endpoint, type,
-      moduleName) {
+      moduleName, domHook) {
     if (!this._endpoints[endpoint]) {
       this._endpoints[endpoint] = [];
     }
-    this._endpoints[endpoint].push({
+    const moduleInfo = {
       moduleName,
       plugin,
       pluginUrl: plugin._url,
       type,
-    });
+      domHook,
+    };
+    this._endpoints[endpoint].push(moduleInfo);
+    if (Gerrit._arePluginsLoaded() && this._callbacks[endpoint]) {
+      this._callbacks[endpoint].forEach(callback => callback(moduleInfo));
+    }
   };
 
   /**
@@ -44,6 +57,7 @@
    *   plugin: Plugin,
    *   pluginUrl: String,
    *   type: EndpointType,
+   *   domHook: !Object
    * }>}
    */
   GrPluginEndpoints.prototype.getDetails = function(name, opt_options) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
index 2c1f4e9..a61cdc8 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
@@ -29,16 +29,21 @@
     let instance;
     let pluginFoo;
     let pluginBar;
+    let domHook;
 
     setup(() => {
       sandbox = sinon.sandbox.create();
+      domHook = {};
       instance = new GrPluginEndpoints();
       Gerrit.install(p => { pluginFoo = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/foo.html');
-      instance.registerModule(pluginFoo, 'a-place', 'decorate', 'foo-module');
+      instance.registerModule(
+          pluginFoo, 'a-place', 'decorate', 'foo-module', domHook);
       Gerrit.install(p => { pluginBar = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/bar.html');
-      instance.registerModule(pluginBar, 'a-place', 'style', 'bar-module');
+      instance.registerModule(
+          pluginBar, 'a-place', 'style', 'bar-module', domHook);
+      sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
     });
 
     teardown(() => {
@@ -52,12 +57,14 @@
           plugin: pluginFoo,
           pluginUrl: pluginFoo._url,
           type: 'decorate',
+          domHook,
         },
         {
           moduleName: 'bar-module',
           plugin: pluginBar,
           pluginUrl: pluginBar._url,
           type: 'style',
+          domHook,
         },
       ]);
     });
@@ -69,6 +76,7 @@
           plugin: pluginBar,
           pluginUrl: pluginBar._url,
           type: 'style',
+          domHook,
         },
       ]);
     });
@@ -82,6 +90,7 @@
               plugin: pluginFoo,
               pluginUrl: pluginFoo._url,
               type: 'decorate',
+              domHook,
             },
           ]);
     });
@@ -95,5 +104,19 @@
       assert.deepEqual(
           instance.getPlugins('a-place'), [pluginFoo._url, pluginBar._url]);
     });
+
+    test('onNewEndpoint', () => {
+      const newModuleStub = sandbox.stub();
+      instance.onNewEndpoint('a-place', newModuleStub);
+      instance.registerModule(
+          pluginFoo, 'a-place', 'replace', 'zaz-module', domHook);
+      assert.deepEqual(newModuleStub.lastCall.args[0], {
+        moduleName: 'zaz-module',
+        plugin: pluginFoo,
+        pluginUrl: pluginFoo._url,
+        type: 'replace',
+        domHook,
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index d1b9417..52db873 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -55,8 +55,7 @@
   window.$wnd = window;
 
   function Plugin(opt_url) {
-    this._generatedHookNames = [];
-    this._domHooks = new GrDomHooks(this);
+    this._domHooks = new GrDomHooksManager(this);
 
     if (!opt_url) {
       console.warn('Plugin not being loaded from /plugins base path.',
@@ -77,6 +76,10 @@
       return;
     }
     this._name = pathname.split('/')[2];
+
+    this.deprecated = {
+      popup: deprecatedAPI.popup.bind(this),
+    };
   }
 
   Plugin._sharedAPIElement = document.createElement('gr-js-api-interface');
@@ -93,11 +96,22 @@
   };
 
   Plugin.prototype.registerCustomComponent = function(
-      endpointName, moduleName, opt_options) {
+      endpointName, opt_moduleName, opt_options) {
     const type = opt_options && opt_options.replace ?
           EndpointType.REPLACE : EndpointType.DECORATE;
+    const hook = this._domHooks.getDomHook(endpointName, opt_moduleName);
+    const moduleName = opt_moduleName || hook.getModuleName();
     Gerrit._endpoints.registerModule(
-        this, endpointName, type, moduleName);
+        this, endpointName, type, moduleName, hook);
+    return hook.getPublicAPI();
+  };
+
+  /**
+   * Returns instance of DOM hook API for endpoint. Creates a placeholder
+   * element for the first call.
+   */
+  Plugin.prototype.hook = function(endpointName, opt_options) {
+    return this.registerCustomComponent(endpointName, undefined, opt_options);
   };
 
   Plugin.prototype.getServerInfo = function() {
@@ -143,6 +157,24 @@
     return this._send('POST', url, opt_callback, payload);
   },
 
+  Plugin.prototype.delete = function(url, opt_callback) {
+    return getRestAPI().send('DELETE', url, opt_callback).then(response => {
+      if (response.status !== 204) {
+        return response.text().then(text => {
+          if (text) {
+            return Promise.reject(text);
+          } else {
+            return Promise.reject(response.status);
+          }
+        });
+      }
+      if (opt_callback) {
+        opt_callback(response);
+      }
+      return response;
+    });
+  },
+
   Plugin.prototype.changeActions = function() {
     return new GrChangeActionsInterface(Plugin._sharedAPIElement.getElement(
         Plugin._sharedAPIElement.Element.CHANGE_ACTIONS));
@@ -166,12 +198,22 @@
     return new GrAttributeHelper(element);
   };
 
-  Plugin.prototype.getDomHook = function(endpointName, opt_options) {
-    const hook = this._domHooks.getDomHook(endpointName);
-    const moduleName = hook.getModuleName();
-    const type = opt_options && opt_options.type || EndpointType.DECORATE;
-    Gerrit._endpoints.registerModule(this, endpointName, type, moduleName);
-    return hook;
+  Plugin.prototype.popup = function(moduleName) {
+    if (typeof moduleName !== 'string') {
+      throw new Error('deprecated, use deprecated.popup');
+    }
+    const api = new GrPopupInterface(this, moduleName);
+    return api.open();
+  };
+
+  const deprecatedAPI = {};
+  deprecatedAPI.popup = function(el) {
+    console.warn('plugin.deprecated.popup() is deprecated!');
+    if (!el) {
+      throw new Error('Popup contents not found');
+    }
+    const api = new GrPopupInterface(this);
+    api.open().then(api => api._getElement().appendChild(el));
   };
 
   const Gerrit = window.Gerrit || {};
diff --git a/polygerrit-ui/app/index.html b/polygerrit-ui/app/index.html
index 46fc46b..080c345 100644
--- a/polygerrit-ui/app/index.html
+++ b/polygerrit-ui/app/index.html
@@ -26,6 +26,10 @@
 -->
 <link rel="preload" href="/fonts/RobotoMono-Regular.woff2" as="font" type="font/woff2" crossorigin>
 <link rel="preload" href="/fonts/RobotoMono-Regular.woff" as="font" type="font/woff" crossorigin>
+<link rel="preload" href="/fonts/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
+<link rel="preload" href="/fonts/Roboto-Regular.woff" as="font" type="font/woff" crossorigin>
+<link rel="preload" href="/fonts/Roboto-Medium.woff2" as="font" type="font/woff2" crossorigin>
+<link rel="preload" href="/fonts/Roboto-Medium.woff" as="font" type="font/woff" crossorigin>
 <link rel="stylesheet" href="/styles/fonts.css">
 <link rel="stylesheet" href="/styles/main.css">
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index be80c13..a77ad50 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -72,7 +72,7 @@
       name + "_app_sources",
       name + "_css_sources",
       name + "_top_sources",
-      "//lib/fonts:robotomono",
+      "//lib/fonts:robotofonts",
       "//lib/js:highlightjs_files",
       # we extract from the zip, but depend on the component for license checking.
       "@webcomponentsjs//:zipfile",
@@ -82,7 +82,7 @@
     cmd = " && ".join([
       "mkdir -p $$TMP/polygerrit_ui/{styles,fonts,bower_components/{highlightjs,webcomponentsjs},elements}",
       "for f in $(locations " + name + "_app_sources); do ext=$${f##*.}; cp -p $$f $$TMP/polygerrit_ui/elements/"  + appName + ".$$ext; done",
-      "cp $(locations //lib/fonts:robotomono) $$TMP/polygerrit_ui/fonts/",
+      "cp $(locations //lib/fonts:robotofonts) $$TMP/polygerrit_ui/fonts/",
       "for f in $(locations " + name + "_top_sources); do cp $$f $$TMP/polygerrit_ui/; done",
       "for f in $(locations "+ name + "_css_sources); do cp $$f $$TMP/polygerrit_ui/styles; done",
       "for f in $(locations //lib/js:highlightjs_files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
diff --git a/polygerrit-ui/app/styles/app-theme.html b/polygerrit-ui/app/styles/app-theme.html
index 4728fe6..db83b0c 100644
--- a/polygerrit-ui/app/styles/app-theme.html
+++ b/polygerrit-ui/app/styles/app-theme.html
@@ -29,11 +29,11 @@
   --default-text-color: #000;
   --view-background-color: #fff;
   --default-horizontal-margin: 1rem;
-  --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+  --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
   --monospace-font-family: 'Roboto Mono', Menlo, 'Lucida Console', Monaco, monospace;
   --iron-overlay-backdrop: {
     transition: none;
-  };
+  }
 }
 @media screen and (max-width: 50em) {
   :root {
diff --git a/polygerrit-ui/app/styles/fonts.css b/polygerrit-ui/app/styles/fonts.css
index d339e16..6a5da44 100644
--- a/polygerrit-ui/app/styles/fonts.css
+++ b/polygerrit-ui/app/styles/fonts.css
@@ -17,4 +17,46 @@
        url('../fonts/RobotoMono-Regular.woff2') format('woff2'),
        url('../fonts/RobotoMono-Regular.woff') format('woff');
   unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
+}
+
+/* latin-ext */
+@font-face {
+  font-family: 'Roboto';
+  font-style: normal;
+  font-weight: 400;
+  src: local('Roboto'), local('Roboto-Regular'),
+       url('../fonts/Roboto-Regular.woff2') format('woff2'),
+       url('../fonts/Roboto-Regular.woff') format('woff');
+  unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Roboto';
+  font-style: normal;
+  font-weight: 400;
+  src: local('Roboto'), local('RobotoMono-Regular'),
+       url('../fonts/Roboto-Regular.woff2') format('woff2'),
+       url('../fonts/Roboto-Regular.woff') format('woff');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
+}
+
+/* latin-ext */
+@font-face {
+  font-family: 'Roboto Medium';
+  font-style: normal;
+  font-weight: 400;
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+       url('../fonts/Roboto-Medium.woff2') format('woff2'),
+       url('../fonts/Roboto-Medium.woff') format('woff');
+  unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Roboto Medium';
+  font-style: normal;
+  font-weight: 400;
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+       url('../fonts/Roboto-Medium.woff2') format('woff2'),
+       url('../fonts/Roboto-Medium.woff') format('woff');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
 }
\ No newline at end of file
diff --git a/polygerrit-ui/app/styles/main.css b/polygerrit-ui/app/styles/main.css
index b18543a..045821c 100644
--- a/polygerrit-ui/app/styles/main.css
+++ b/polygerrit-ui/app/styles/main.css
@@ -37,6 +37,6 @@
    */
   -webkit-text-size-adjust: none;
   font-size: 13px;
-  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+  font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
   line-height: 1.4;
 }
diff --git a/polygerrit-ui/app/styles/shared-styles.html b/polygerrit-ui/app/styles/shared-styles.html
index cc1dabd..9725251 100644
--- a/polygerrit-ui/app/styles/shared-styles.html
+++ b/polygerrit-ui/app/styles/shared-styles.html
@@ -38,6 +38,12 @@
         margin: 0;
         padding: 0;
       }
+      input,
+      textarea,
+      select,
+      button {
+        font: inherit;
+      }
       body {
         line-height: 1;
       }
diff --git a/polygerrit-ui/app/template_test_srcs/template_test.js b/polygerrit-ui/app/template_test_srcs/template_test.js
index 138d0ea..dffcaf9 100644
--- a/polygerrit-ui/app/template_test_srcs/template_test.js
+++ b/polygerrit-ui/app/template_test_srcs/template_test.js
@@ -27,13 +27,15 @@
   'GrGerritAuth',
   'GrLinkTextParser',
   'GrPluginEndpoints',
+  'GrPopupInterface',
   'GrRangeNormalizer',
   'GrReporting',
   'GrReviewerUpdatesParser',
   'GrThemeApi',
   'moment',
   'page',
-  'util'];
+  'util',
+];
 
 fs.readdir('./polygerrit-ui/temp/behaviors/', (err, data) => {
   if (err) {
@@ -102,4 +104,4 @@
       process.exit(1);
     }
   });
-});
\ No newline at end of file
+});
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 41f5fc6..912b0ff 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -103,6 +103,8 @@
     'plugins/gr-attribute-helper/gr-attribute-helper_test.html',
     'plugins/gr-external-style/gr-external-style_test.html',
     'plugins/gr-plugin-host/gr-plugin-host_test.html',
+    'plugins/gr-popup-interface/gr-plugin-popup_test.html',
+    'plugins/gr-popup-interface/gr-popup-interface_test.html',
     'settings/gr-account-info/gr-account-info_test.html',
     'settings/gr-change-table-editor/gr-change-table-editor_test.html',
     'settings/gr-email-editor/gr-email-editor_test.html',