 core/modules/outside_in/css/outside_in.motion.css  |  4 +-
 core/modules/outside_in/css/outside_in.theme.css   |  6 +-
 core/modules/outside_in/js/outside_in.es6.js       |  6 +-
 core/modules/outside_in/js/outside_in.js           |  6 +-
 core/modules/outside_in/outside_in.module          | 51 ++++++++++++----
 core/modules/outside_in/outside_in.routing.yml     |  1 +
 core/modules/outside_in/outside_in.services.yml    |  5 ++
 .../BlockPluginHasOffCanvasFormAccessCheck.php     | 29 +++++++++
 .../BlockPluginHasOffCanvasFormAccessCheckTest.php | 69 ++++++++++++++++++++++
 9 files changed, 156 insertions(+), 21 deletions(-)

diff --git a/core/modules/outside_in/css/outside_in.motion.css b/core/modules/outside_in/css/outside_in.motion.css
index 5fae723..1a92c91 100644
--- a/core/modules/outside_in/css/outside_in.motion.css
+++ b/core/modules/outside_in/css/outside_in.motion.css
@@ -19,8 +19,8 @@
 
 /* Transition the editables on the page, their contextual links and their hover states. */
 .dialog-off-canvas__main-canvas .contextual,
-.dialog-off-canvas__main-canvas .js-outside-in-edit-mode .outside-in-editable,
-.dialog-off-canvas__main-canvas.js-tray-open .js-outside-in-edit-mode .outside-in-editable {
+.dialog-off-canvas__main-canvas .js-outside-in-edit-mode .contextual-region.outside-in-editable,
+.dialog-off-canvas__main-canvas.js-tray-open .js-outside-in-edit-mode .contextual-region.outside-in-editable {
   -webkit-transition: all .7s ease;
   -moz-transition: all .7s ease;
   transition: all .7s ease;
diff --git a/core/modules/outside_in/css/outside_in.theme.css b/core/modules/outside_in/css/outside_in.theme.css
index 0db7a3c..01c974b 100644
--- a/core/modules/outside_in/css/outside_in.theme.css
+++ b/core/modules/outside_in/css/outside_in.theme.css
@@ -60,12 +60,12 @@
 }
 
 /* Style the editables while in edit mode. */
-.dialog-off-canvas__main-canvas.js-outside-in-edit-mode .outside-in-editable {
+.dialog-off-canvas__main-canvas.js-outside-in-edit-mode .contextual-region.outside-in-editable {
   outline: 1px dashed rgba(0,0,0,0.5);
   box-shadow: 0 0 0 1px rgba(255,255,255,0.7);
 }
-.dialog-off-canvas__main-canvas.js-outside-in-edit-mode .outside-in-editable:hover,
-.dialog-off-canvas__main-canvas.js-outside-in-edit-mode .outside-in-editable.outside-in-active-editable {
+.dialog-off-canvas__main-canvas.js-outside-in-edit-mode .contextual-region.outside-in-editable:hover,
+.dialog-off-canvas__main-canvas.js-outside-in-edit-mode .contextual-region.outside-in-editable.outside-in-active-editable {
   background-color: rgba(0,0,0,0.2);
 }
 
diff --git a/core/modules/outside_in/js/outside_in.es6.js b/core/modules/outside_in/js/outside_in.es6.js
index 76bb872..5112eac 100644
--- a/core/modules/outside_in/js/outside_in.es6.js
+++ b/core/modules/outside_in/js/outside_in.es6.js
@@ -6,7 +6,7 @@
 (function ($, Drupal) {
   const blockConfigureSelector = '[data-outside-in-edit]';
   const toggleEditSelector = '[data-drupal-outsidein="toggle"]';
-  const itemsToToggleSelector = '[data-off-canvas-main-canvas], #toolbar-bar, [data-drupal-outsidein="editable"] a, [data-drupal-outsidein="editable"] button';
+  const itemsToToggleSelector = '[data-off-canvas-main-canvas], #toolbar-bar, .contextual-region[data-drupal-outsidein="editable"] a, .contextual-region[data-drupal-outsidein="editable"] button';
   const contextualItemsSelector = '[data-contextual-id] a, [data-contextual-id] button';
   const quickEditItemSelector = '[data-quickedit-entity-id]';
 
@@ -132,7 +132,7 @@
       $editButton.text(Drupal.t('Editing'));
       closeToolbarTrays();
 
-      $editables = $('[data-drupal-outsidein="editable"]').once('outsidein');
+      $editables = $('.contextual-region[data-drupal-outsidein="editable"]').once('outsidein');
       if ($editables.length) {
         // Use event capture to prevent clicks on links.
         document.querySelector('[data-off-canvas-main-canvas]').addEventListener('click', preventClick, true);
@@ -166,7 +166,7 @@
     }
     // Disable edit mode.
     else {
-      $editables = $('[data-drupal-outsidein="editable"]').removeOnce('outsidein');
+      $editables = $('.contextual-region[data-drupal-outsidein="editable"]').removeOnce('outsidein');
       if ($editables.length) {
         document.querySelector('[data-off-canvas-main-canvas]').removeEventListener('click', preventClick, true);
         $editables.off('.outsidein');
diff --git a/core/modules/outside_in/js/outside_in.js b/core/modules/outside_in/js/outside_in.js
index 95cf90e..350ded0 100644
--- a/core/modules/outside_in/js/outside_in.js
+++ b/core/modules/outside_in/js/outside_in.js
@@ -8,7 +8,7 @@
 (function ($, Drupal) {
   var blockConfigureSelector = '[data-outside-in-edit]';
   var toggleEditSelector = '[data-drupal-outsidein="toggle"]';
-  var itemsToToggleSelector = '[data-off-canvas-main-canvas], #toolbar-bar, [data-drupal-outsidein="editable"] a, [data-drupal-outsidein="editable"] button';
+  var itemsToToggleSelector = '[data-off-canvas-main-canvas], #toolbar-bar, .contextual-region[data-drupal-outsidein="editable"] a, .contextual-region[data-drupal-outsidein="editable"] button';
   var contextualItemsSelector = '[data-contextual-id] a, [data-contextual-id] button';
   var quickEditItemSelector = '[data-quickedit-entity-id]';
 
@@ -74,7 +74,7 @@
       $editButton.text(Drupal.t('Editing'));
       closeToolbarTrays();
 
-      $editables = $('[data-drupal-outsidein="editable"]').once('outsidein');
+      $editables = $('.contextual-region[data-drupal-outsidein="editable"]').once('outsidein');
       if ($editables.length) {
         document.querySelector('[data-off-canvas-main-canvas]').addEventListener('click', preventClick, true);
 
@@ -97,7 +97,7 @@
         });
       }
     } else {
-        $editables = $('[data-drupal-outsidein="editable"]').removeOnce('outsidein');
+        $editables = $('.contextual-region[data-drupal-outsidein="editable"]').removeOnce('outsidein');
         if ($editables.length) {
           document.querySelector('[data-off-canvas-main-canvas]').removeEventListener('click', preventClick, true);
           $editables.off('.outsidein');
diff --git a/core/modules/outside_in/outside_in.module b/core/modules/outside_in/outside_in.module
index 69517b9..665468a 100644
--- a/core/modules/outside_in/outside_in.module
+++ b/core/modules/outside_in/outside_in.module
@@ -100,8 +100,16 @@ function outside_in_entity_type_build(array &$entity_types) {
  * Implements hook_preprocess_HOOK() for block templates.
  */
 function outside_in_preprocess_block(&$variables) {
-  // The main system block does not contain the block contextual links.
-  if ($variables['plugin_id'] !== 'system_main_block') {
+  // Only blocks that have an off_canvas form will have a "Quick Edit" link. We
+  // could wait for the contextual links to be initialized on the client side,
+  // and then add the class and data- attribute below there (via JavaScript).
+  // But that means that it will be impossible to show Settings Tray's clickable
+  // regions immediately when the page loads. When latency is high, this will
+  // cause flicker. Therefore, for now, we choose to duplicate some logic to
+  // guarantee a smooth experience.
+  // This is an implementation detail that may change in the future.
+  // @see \Drupal\outside_in\Access\BlockPluginHasOffCanvasFormAccessCheck
+  if (\Drupal::service('plugin.manager.block')->createInstance($variables['plugin_id'])->hasFormClass('off_canvas')) {
     // Add class and attributes to all blocks to allow Javascript to target.
     $variables['attributes']['class'][] = 'outside-in-editable';
     $variables['attributes']['data-drupal-outsidein'] = 'editable';
@@ -139,17 +147,40 @@ function outside_in_toolbar_alter(&$items) {
 
 /**
  * Implements hook_block_alter().
+ *
+ * Ensures every block plugin definition has an 'off_canvas' form specified.
+ *
+ * @see \Drupal\outside_in\Access\BlockPluginHasOffCanvasFormAccessCheck
  */
 function outside_in_block_alter(&$definitions) {
-  if (!empty($definitions['system_branding_block'])) {
-    $definitions['system_branding_block']['forms']['off_canvas'] = SystemBrandingOffCanvasForm::class;
-  }
-
-  // Since menu blocks use derivatives, check the definition ID instead of
-  // relying on the plugin ID.
   foreach ($definitions as &$definition) {
-    if ($definition['id'] === 'system_menu_block') {
-      $definition['forms']['off_canvas'] = SystemMenuOffCanvasForm::class;
+    // If the block plugin already defines an 'off_canvas' form, there's nothing
+    // to do.
+    if (isset($definition['forms']['off_canvas'])) {
+      continue;
+    }
+
+    switch ($definition['id']) {
+      // Use specialized off-canvas forms when they're available.
+      // @todo move these into the corresponding block plugin annotations when Settings Tray becomes stable.
+      case 'system_menu_block':
+        $definition['forms']['off_canvas'] = SystemMenuOffCanvasForm::class;
+        break;
+      case 'system_branding_block':
+        $definition['forms']['off_canvas'] = SystemBrandingOffCanvasForm::class;
+        break;
+
+      // No off-canvas form for the page title block, despite it having
+      // contextual links: it's too confusing that you're editing configuration,
+      // not content, so the title itself cannot actually be changed.
+      case 'page_title_block':
+        $definition['forms']['off_canvas'] = FALSE;
+        break;
+
+      // Otherwise fall back to the built-in form for the block plugin.
+      default:
+        $definition['forms']['off_canvas'] = $definition['class'];
+        break;
     }
   }
 }
diff --git a/core/modules/outside_in/outside_in.routing.yml b/core/modules/outside_in/outside_in.routing.yml
index 18a564c..a778727 100644
--- a/core/modules/outside_in/outside_in.routing.yml
+++ b/core/modules/outside_in/outside_in.routing.yml
@@ -5,3 +5,4 @@ entity.block.off_canvas_form:
     _title_callback: '\Drupal\outside_in\Block\BlockEntityOffCanvasForm::title'
   requirements:
     _permission: 'administer blocks'
+    _access_block_plugin_has_offcanvas_form: 'TRUE'
diff --git a/core/modules/outside_in/outside_in.services.yml b/core/modules/outside_in/outside_in.services.yml
index ce82146..5d95c1e 100644
--- a/core/modules/outside_in/outside_in.services.yml
+++ b/core/modules/outside_in/outside_in.services.yml
@@ -4,3 +4,8 @@ services:
     arguments: ['@title_resolver', '@renderer']
     tags:
       - { name: render.main_content_renderer, format: drupal_dialog.off_canvas }
+
+  access_check.outside_in.block.off_canvas_form:
+    class: Drupal\outside_in\Access\BlockPluginHasOffCanvasFormAccessCheck
+    tags:
+      - { name: access_check, applies_to: _access_block_plugin_has_offcanvas_form }
diff --git a/core/modules/outside_in/src/Access/BlockPluginHasOffCanvasFormAccessCheck.php b/core/modules/outside_in/src/Access/BlockPluginHasOffCanvasFormAccessCheck.php
new file mode 100644
index 0000000..e8536f7
--- /dev/null
+++ b/core/modules/outside_in/src/Access/BlockPluginHasOffCanvasFormAccessCheck.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Drupal\outside_in\Access;
+
+use Drupal\block\BlockInterface;
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Routing\Access\AccessInterface;
+
+/**
+ * Determines whether the requested block has an 'off_canvas' form.
+ */
+class BlockPluginHasOffCanvasFormAccessCheck implements AccessInterface {
+
+  /**
+   * Checks access for accessing a block's 'off_canvas' form.
+   *
+   * @param \Drupal\block\BlockInterface $block
+   *   The block whose 'off_canvas' form is being accessed.
+   *
+   * @return \Drupal\Core\Access\AccessResultInterface
+   *   The access result.
+   */
+  public function access(BlockInterface $block) {
+    /** @var \Drupal\Core\Block\BlockPluginInterface $block_plugin */
+    $block_plugin = $block->getPlugin();
+    return AccessResult::allowedIf($block_plugin->hasFormClass('off_canvas'));
+  }
+
+}
diff --git a/core/modules/outside_in/tests/src/Unit/Access/BlockPluginHasOffCanvasFormAccessCheckTest.php b/core/modules/outside_in/tests/src/Unit/Access/BlockPluginHasOffCanvasFormAccessCheckTest.php
new file mode 100644
index 0000000..0643688
--- /dev/null
+++ b/core/modules/outside_in/tests/src/Unit/Access/BlockPluginHasOffCanvasFormAccessCheckTest.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Drupal\Tests\outside_in\Unit\Access;
+
+use Drupal\block\BlockInterface;
+use Drupal\Core\Access\AccessResultAllowed;
+use Drupal\Core\Access\AccessResultInterface;
+use Drupal\Core\Access\AccessResultNeutral;
+use Drupal\Core\Block\BlockBase;
+use Drupal\outside_in\Access\BlockPluginHasOffCanvasFormAccessCheck;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\outside_in\Access\BlockPluginHasOffCanvasFormAccessCheck
+ * @group outside_in
+ */
+class BlockPluginHasOffCanvasFormAccessCheckTest extends UnitTestCase {
+
+  /**
+   * @covers ::access
+   * @dataProvider providerTestAccess
+   */
+  public function testAccess(array $plugin_definition, AccessResultInterface $expected_access_result) {
+    $block_plugin = new TestBlockClass([], $this->randomMachineName(), $plugin_definition);
+    $block = $this->prophesize(BlockInterface::class);
+    $block->getPlugin()->willReturn($block_plugin);
+
+    $access_check = new BlockPluginHasOffCanvasFormAccessCheck();
+    $this->assertEquals($expected_access_result, $access_check->access($block->reveal()));
+  }
+
+  public function providerTestAccess() {
+    return [
+      'set to class' => [
+        [
+          'provider' => 'block_test',
+          'forms' => [
+            'off_canvas' => $this->randomMachineName(),
+          ],
+        ],
+        new AccessResultAllowed(),
+      ],
+      'not set' => [
+        [
+          'provider' => 'block_test',
+        ],
+        new AccessResultNeutral(),
+      ],
+      'set to FALSE' => [
+        [
+          'provider' => 'block_test',
+          'forms' => [
+            'off_canvas' => FALSE,
+          ],
+        ],
+        new AccessResultNeutral(),
+      ],
+    ];
+  }
+
+}
+
+class TestBlockClass extends BlockBase {
+
+  public function build() {
+    return [];
+  }
+
+}
