Repository: jupyterlab-contrib/jupyterlab-tour (language: python)

Issue:
Build tours in Advanced Settings/overrides.json?
A common thing I end up wanting is a specific tour for just one repo deployed on binder, as part of a constructor build, or soon, https://github.com/jtpio/jupyterlite/issues/31.

From a binder/lite perspective, it seems like being able to use the declarative schema in `overrides.json` might be the lowest-touch way to get something out the door for an end user. This would be a natural place to get at least initial support for schema validation, even though there's no autocomplete, etc.

Additionally, if I know my audience already knows JupyterLab, having a "big hammer" `"dont-show-any-damned-toasts": true` might be handy, both at the per-tour level, as well as a global setting.

I haven't poked around the codebase in a while, maybe I'll freshen up and see if i can get a PR together...


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 671a060..7afcd26 100644
--- a/.gitignore
+++ b/.gitignore
@@ -111,3 +111,6 @@ dmypy.json
 # OSX files
 .DS_Store
 coverage/
+
+# logs
+*.log
diff --git a/README.md b/README.md
index 4511582..5cbbe04 100644
--- a/README.md
+++ b/README.md
@@ -10,11 +10,26 @@ A JupyterLab UI Tour based on [react-joyride](https://docs.react-joyride.com).
 
 ![demo](https://raw.githubusercontent.com/jupyterlab-contrib/jupyterlab-tour/master/doc/tourDemo.gif)
 
+## Install
+
+```bash
+pip install jupyterlab-tour
+```
+
+or
+
+```bash
+conda install -c conda-forge jupyterlab-tour
+```
+
+## Features
+
 This extension has the following features:
 
 - Default tours:
   - Welcome tour
   - Notebook tour
+  - User-defined features in Settings
 - Toast proposing to start a tour - to experienced users the need to exit each time the tour.
 - If a tour has already be seen by the user, this is saved in the state database. So you can start tour on event only if the user have not seen it; e.g. the welcome tour is launched at JupyterLab start except if the user have seen it.
 
@@ -36,9 +51,70 @@ For JupyterLab 2.x, have look [there](https://github.com/jupyterlab-contrib/jupy
 
 > For developers, the API has changed between v3 (for JupyterLab 3) and v2 (for JupyterLab 2).
 
+## How to add a tour with Advanced Settings
+
+As a user of JupyterLab, after you've installed `jupyterlab-tour`, you can create
+your own _Tours_ as data.
+
+- Open the JupyterLab _Advanced Settings_ panel <kbd>Ctrl+,</kbd>
+- Select _Tours_ from list of settings groups
+- In the editor, create JSON(5) compatible with the
+  [react-joyride data model](https://docs.react-joyride.com/props)
+- The _Tour_ will be available from the _Help Menu_, as well as the _Command Palette_
+
+### A simple Tour
+
+For example, to show a glowing button on the Jupyter logo, which reveals an orange
+overlay when pressed:
+
+```json5
+// json5 can have comments
+{
+  "tours": [
+    {
+      "id": "my-tour",
+      "label": "My First Tour",
+      // steps are required, and have many, many options
+      "steps": [
+        {"target": "#jp-MainLogo", "content": "Look at this!"}
+      ],
+      // below here not required!
+      "options": {
+        "styles": {
+          "options": {
+            // you can use jupyterlab theme variables
+            "backgroundColor": "var(--jp-warn-color0)"
+          }
+        }
+      }
+    }
+  ]
+}
+```
+
+### Shipping a Tour to Binder
+
+On Binder, and elsewhere, you can store the above (_without_ comments) in
+an [overrides.json] file and put it in the _right place_,
+e.g. `{sys.prefix}/share/jupyter/lab/settings/overrides.json`. When JupyterLab is
+next opened, those overrides will become the defaults, and your tour will be available.
+
+An example `overrides.json` might look like:
+```json5
+{
+  "jupyterlab-tour:user-tours": {
+    "tours": [
+      // that tour up there!
+    ]
+  }
+}
+```
+
+[overrides.json]: https://jupyterlab.readthedocs.io/en/stable/user/directories.html#overrides-json
+
 ## How to add tour for my JupyterLab extension
 
-There are two methods to add a tour: the easiest is to use JupyterLab commands and the advanced version is to request this
+As an extension developer, there are two methods to add a tour: the easiest is to use JupyterLab commands and the advanced version is to request this
 extension token `ITourManager`.
 
 ### Add easily a tour
@@ -49,7 +125,7 @@ const { commands } = app;
 const tour = (await app.commands.execute('jupyterlab-tour:add', {
   tour: { // Tour must be of type ITour - see src/tokens.ts
     id: 'test-jupyterlab-tour:welcome',
-    label: 'Welcome Tour',    
+    label: 'Welcome Tour',
     hasHelpEntry: true,
     steps: [  // Step must be of type IStep - see src/tokens.ts
       {
@@ -66,7 +142,8 @@ const tour = (await app.commands.execute('jupyterlab-tour:add', {
         target: '#jp-main-dock-panel',
         title: 'Main Content'
       }
-    ]
+    ],
+    // can also define `options`
   }
 })) as ITour;
 if ( tour ) {
@@ -80,18 +157,41 @@ if ( tour ) {
 > One example is available on [Mamba navigator](https://github.com/mamba-org/gator/blob/master/packages/labextension/src/index.ts#L76).
 > Test it on [binder](https://mybinder.org/v2/gh/mamba-org/gator/master?urlpath=lab).
 
-## Install
+## Disabling the User and Default Tours
+
+If you _only_ wish to see the default _Welcome_ and _Notebook_ tours, or ones
+defined by users, they can be disabled via the command line or a well-known file.
+
+The examples below disable all tours not provided by third-party extensions.
+Adding `jupyterlab-tour:plugin` to either of these will disable tours altogether!
+
+### Disabling Tours from the Command Line
+
+From the command line, run:
 
 ```bash
-pip install jupyterlab-tour
+jupyter labextension disable "jupyterlab-tour:user-tours"
+jupyter labextension disable "jupyterlab-tour:default-tours"
 ```
 
-or
+### Disabling Tours with `pageConfig.json`
 
-```bash
-conda install -c conda-forge jupyterlab-tour
+Create a [pageConfig.json] and put it in _the right place_, e.g.
+`{sys.prefix}/etc/jupyter/labconfig/pageconfig.json` and add the plugin IDs to
+`disabledExtensions`.
+
+```json
+{
+  "disabledExtensions": {
+    "jupyterlab-tour:user-tours": true,
+    "jupyterlab-tour:default-tours": true,
+  }
+}
 ```
 
+[pageConfig.json]: https://jupyterlab.readthedocs.io/en/stable/user/directories.html#labconfig-directories
+
+
 ## Uninstall
 
 ```bash
diff --git a/package.json b/package.json
index a2bad13..c3004cb 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,8 @@
   "files": [
     "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}",
     "style/**/*.{css,eot,gif,html,jpg,json,png,svg,woff2,ttf}",
-    "style/index.js"
+    "style/index.js",
+    "schema/*.json"
   ],
   "main": "lib/index.js",
   "types": "lib/index.d.ts",
@@ -49,6 +50,7 @@
     "@jupyterlab/apputils": "^3.0.0",
     "@jupyterlab/mainmenu": "^3.0.0",
     "@jupyterlab/notebook": "^3.0.0",
+    "@jupyterlab/settingregistry": "^3.0.0",
     "@jupyterlab/statedb": "^3.0.0",
     "@lumino/coreutils": "^1.5.3",
     "@lumino/disposable": "^1.4.3",
@@ -57,7 +59,7 @@
     "jupyterlab_toastify": "^4.1.2",
     "react": "^17.0.1",
     "react-dom": "^17.0.1",
-    "react-joyride": "^2.2.1"
+    "react-joyride": "^2.3.0"
   },
   "devDependencies": {
     "@babel/core": "^7.11.0",
@@ -67,7 +69,6 @@
     "@types/jest": "^26.0.0",
     "@types/react": "^17.0.0",
     "@types/react-dom": "^17.0.0",
-    "@types/react-joyride": "^2.0.5",
     "@typescript-eslint/eslint-plugin": "^2.27.0",
     "@typescript-eslint/parser": "^2.27.0",
     "eslint": "^7.5.0",
@@ -89,6 +90,7 @@
   ],
   "jupyterlab": {
     "extension": true,
+    "schemaDir": "schema",
     "outputDir": "jupyterlab_tour/labextension",
     "sharedPackages": {
       "jupyterlab_toastify": {
diff --git a/schema/user-tours.json b/schema/user-tours.json
new file mode 100644
index 0000000..cf9ad2b
--- /dev/null
+++ b/schema/user-tours.json
@@ -0,0 +1,856 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema",
+  "description": "Configuration for user-defined tours. This schema is generated from https://github.com/gilbarbara/react-joyride/blob/master/types/index.d.ts",
+  "jupyter.lab.setting-icon-label": "Tours",
+  "title": "Tours",
+  "type": "object",
+  "properties": {
+    "tours": {
+      "type": "array",
+      "description": "An array of a tours. Each requires an `id`, `label` and `steps[]`, and may have `options`, see https://docs.react-joyride.com",
+      "default": [],
+      "items": {
+        "type": "object",
+        "required": ["id", "label", "steps"],
+        "properties": {
+          "id": {
+            "type": "string",
+            "description": "A machine-readble ID to identify this tour, will be prefixed. Should be unique within this document."
+          },
+          "label": {
+            "type": "string",
+            "description": "A human-readable name for the tour"
+          },
+          "hasHelpEntry": {
+            "type": "boolean",
+            "description": "Whether to add a Help Menu item with the label to launch the tour",
+            "default": true
+          },
+          "steps": {
+            "description": "The definition of the steps of a tour",
+            "type": "array",
+            "items": {
+              "$ref": "#/definitions/Step"
+            }
+          },
+          "options": {
+            "description": "Other options for the tour",
+            "$ref": "#/definitions/Props"
+          }
+        }
+      }
+    }
+  },
+  "definitions": {
+    "BeaconRenderProps": {
+      "additionalProperties": false,
+      "properties": {
+        "continuous": {
+          "type": "boolean"
+        },
+        "index": {
+          "type": "number"
+        },
+        "isLastStep": {
+          "type": "boolean"
+        },
+        "size": {
+          "type": "number"
+        },
+        "step": {
+          "$ref": "#/definitions/Step"
+        }
+      },
+      "required": ["continuous", "index", "isLastStep", "size", "step"],
+      "type": "object"
+    },
+    "CallBackProps": {
+      "additionalProperties": false,
+      "properties": {
+        "action": {
+          "type": "string"
+        },
+        "controlled": {
+          "type": "boolean"
+        },
+        "index": {
+          "type": "number"
+        },
+        "lifecycle": {
+          "type": "string"
+        },
+        "size": {
+          "type": "number"
+        },
+        "status": {
+          "$ref": "#/definitions/valueof%3Cstatus%3E"
+        },
+        "step": {
+          "$ref": "#/definitions/Step"
+        },
+        "type": {
+          "type": "string"
+        }
+      },
+      "required": [
+        "action",
+        "controlled",
+        "index",
+        "lifecycle",
+        "size",
+        "status",
+        "step",
+        "type"
+      ],
+      "type": "object"
+    },
+    "CommonProps": {
+      "additionalProperties": false,
+      "properties": {
+        "beaconComponent": {
+          "type": "string"
+        },
+        "disableCloseOnEsc": {
+          "type": "boolean"
+        },
+        "disableOverlay": {
+          "type": "boolean"
+        },
+        "disableOverlayClose": {
+          "type": "boolean"
+        },
+        "disableScrollParentFix": {
+          "type": "boolean"
+        },
+        "disableScrolling": {
+          "type": "boolean"
+        },
+        "floaterProps": {
+          "$ref": "#/definitions/FloaterProps"
+        },
+        "hideBackButton": {
+          "type": "boolean"
+        },
+        "locale": {
+          "$ref": "#/definitions/Locale"
+        },
+        "showProgress": {
+          "type": "boolean"
+        },
+        "showSkipButton": {
+          "type": "boolean"
+        },
+        "spotlightClicks": {
+          "type": "boolean"
+        },
+        "spotlightPadding": {
+          "type": "number"
+        },
+        "styles": {
+          "$ref": "#/definitions/Styles"
+        },
+        "tooltipComponent": {
+          "type": "string"
+        }
+      },
+      "type": "object"
+    },
+    "FloaterProps": {
+      "additionalProperties": false,
+      "properties": {
+        "disableAnimation": {
+          "type": "boolean"
+        },
+        "options": {
+          "$ref": "#/definitions/GenericObject"
+        },
+        "styles": {
+          "$ref": "#/definitions/GenericObject"
+        },
+        "wrapperOptions": {
+          "$ref": "#/definitions/GenericObject"
+        }
+      },
+      "type": "object"
+    },
+    "GenericObject": {
+      "type": "object"
+    },
+    "Locale": {
+      "additionalProperties": false,
+      "properties": {
+        "back": {
+          "type": "string"
+        },
+        "close": {
+          "type": "string"
+        },
+        "last": {
+          "type": "string"
+        },
+        "next": {
+          "type": "string"
+        },
+        "open": {
+          "type": "string"
+        },
+        "skip": {
+          "type": "string"
+        }
+      },
+      "type": "object"
+    },
+    "Placement": {
+      "enum": [
+        "top",
+        "top-start",
+        "top-end",
+        "bottom",
+        "bottom-start",
+        "bottom-end",
+        "left",
+        "left-start",
+        "left-end",
+        "right",
+        "right-start",
+        "right-end",
+        "auto",
+        "center"
+      ],
+      "type": "string"
+    },
+    "PlacementBeacon": {
+      "enum": ["top", "bottom", "left", "right"],
+      "type": "string"
+    },
+    "Props": {
+      "additionalProperties": false,
+      "properties": {
+        "beaconComponent": {
+          "type": "string"
+        },
+        "continuous": {
+          "type": "boolean"
+        },
+        "debug": {
+          "type": "boolean"
+        },
+        "disableCloseOnEsc": {
+          "type": "boolean"
+        },
+        "disableOverlay": {
+          "type": "boolean"
+        },
+        "disableOverlayClose": {
+          "type": "boolean"
+        },
+        "disableScrollParentFix": {
+          "type": "boolean"
+        },
+        "disableScrolling": {
+          "type": "boolean"
+        },
+        "floaterProps": {
+          "$ref": "#/definitions/FloaterProps"
+        },
+        "hideBackButton": {
+          "type": "boolean"
+        },
+        "locale": {
+          "$ref": "#/definitions/Locale"
+        },
+        "run": {
+          "type": "boolean"
+        },
+        "scrollOffset": {
+          "type": "number"
+        },
+        "scrollToFirstStep": {
+          "type": "boolean"
+        },
+        "showProgress": {
+          "type": "boolean"
+        },
+        "showSkipButton": {
+          "type": "boolean"
+        },
+        "spotlightClicks": {
+          "type": "boolean"
+        },
+        "spotlightPadding": {
+          "type": "number"
+        },
+        "stepIndex": {
+          "type": "number"
+        },
+        "steps": {
+          "items": {
+            "$ref": "#/definitions/Step"
+          },
+          "type": "array"
+        },
+        "styles": {
+          "$ref": "#/definitions/Styles"
+        },
+        "tooltipComponent": {
+          "type": "string"
+        }
+      },
+      "type": "object"
+    },
+    "Step": {
+      "additionalProperties": false,
+      "properties": {
+        "beaconComponent": {
+          "type": "string"
+        },
+        "content": {
+          "type": "string"
+        },
+        "disableBeacon": {
+          "type": "boolean"
+        },
+        "disableCloseOnEsc": {
+          "type": "boolean"
+        },
+        "disableOverlay": {
+          "type": "boolean"
+        },
+        "disableOverlayClose": {
+          "type": "boolean"
+        },
+        "disableScrollParentFix": {
+          "type": "boolean"
+        },
+        "disableScrolling": {
+          "type": "boolean"
+        },
+        "event": {
+          "type": "string"
+        },
+        "floaterProps": {
+          "$ref": "#/definitions/FloaterProps"
+        },
+        "hideBackButton": {
+          "type": "boolean"
+        },
+        "hideCloseButton": {
+          "type": "boolean"
+        },
+        "hideFooter": {
+          "type": "boolean"
+        },
+        "isFixed": {
+          "type": "boolean"
+        },
+        "locale": {
+          "$ref": "#/definitions/Locale"
+        },
+        "offset": {
+          "type": "number"
+        },
+        "placement": {
+          "$ref": "#/definitions/Placement"
+        },
+        "placementBeacon": {
+          "$ref": "#/definitions/PlacementBeacon"
+        },
+        "showProgress": {
+          "type": "boolean"
+        },
+        "showSkipButton": {
+          "type": "boolean"
+        },
+        "spotlightClicks": {
+          "type": "boolean"
+        },
+        "spotlightPadding": {
+          "type": "number"
+        },
+        "styles": {
+          "$ref": "#/definitions/Styles"
+        },
+        "target": {
+          "type": "string"
+        },
+        "title": {
+          "type": "string"
+        },
+        "tooltipComponent": {
+          "type": "string"
+        }
+      },
+      "required": ["content", "target"],
+      "type": "object"
+    },
+    "StoreHelpers": {
+      "additionalProperties": false,
+      "type": "object"
+    },
+    "StoreState": {
+      "additionalProperties": false,
+      "properties": {
+        "action": {
+          "type": "string"
+        },
+        "controlled": {
+          "type": "boolean"
+        },
+        "index": {
+          "type": "number"
+        },
+        "lifecycle": {
+          "type": "string"
+        },
+        "size": {
+          "type": "number"
+        },
+        "status": {
+          "type": "string"
+        }
+      },
+      "required": [
+        "action",
+        "controlled",
+        "index",
+        "lifecycle",
+        "size",
+        "status"
+      ],
+      "type": "object"
+    },
+    "Styles": {
+      "additionalProperties": false,
+      "properties": {
+        "beacon": {
+          "type": "object"
+        },
+        "beaconInner": {
+          "type": "object"
+        },
+        "beaconOuter": {
+          "type": "object"
+        },
+        "buttonBack": {
+          "type": "object"
+        },
+        "buttonClose": {
+          "type": "object"
+        },
+        "buttonNext": {
+          "type": "object"
+        },
+        "buttonSkip": {
+          "type": "object"
+        },
+        "options": {
+          "additionalProperties": false,
+          "properties": {
+            "arrowColor": {
+              "type": "string"
+            },
+            "backgroundColor": {
+              "type": "string"
+            },
+            "beaconSize": {
+              "type": "number"
+            },
+            "overlayColor": {
+              "type": "string"
+            },
+            "primaryColor": {
+              "type": "string"
+            },
+            "spotlightShadow": {
+              "type": "string"
+            },
+            "textColor": {
+              "type": "string"
+            },
+            "width": {
+              "type": ["string", "number"]
+            },
+            "zIndex": {
+              "type": "number"
+            }
+          },
+          "type": "object"
+        },
+        "overlay": {
+          "type": "object"
+        },
+        "overlayLegacy": {
+          "type": "object"
+        },
+        "overlayLegacyCenter": {
+          "type": "object"
+        },
+        "spotlight": {
+          "type": "object"
+        },
+        "spotlightLegacy": {
+          "type": "object"
+        },
+        "tooltip": {
+          "type": "object"
+        },
+        "tooltipContainer": {
+          "type": "object"
+        },
+        "tooltipContent": {
+          "type": "object"
+        },
+        "tooltipFooter": {
+          "type": "object"
+        },
+        "tooltipFooterSpacer": {
+          "type": "object"
+        },
+        "tooltipTitle": {
+          "type": "object"
+        }
+      },
+      "type": "object"
+    },
+    "TooltipRenderProps": {
+      "additionalProperties": false,
+      "properties": {
+        "backProps": {
+          "additionalProperties": false,
+          "properties": {
+            "aria-label": {
+              "type": "string"
+            },
+            "data-action": {
+              "type": "string"
+            },
+            "role": {
+              "type": "string"
+            },
+            "title": {
+              "type": "string"
+            }
+          },
+          "required": ["aria-label", "data-action", "role", "title"],
+          "type": "object"
+        },
+        "closeProps": {
+          "additionalProperties": false,
+          "properties": {
+            "aria-label": {
+              "type": "string"
+            },
+            "data-action": {
+              "type": "string"
+            },
+            "role": {
+              "type": "string"
+            },
+            "title": {
+              "type": "string"
+            }
+          },
+          "required": ["aria-label", "data-action", "role", "title"],
+          "type": "object"
+        },
+        "continuous": {
+          "type": "boolean"
+        },
+        "index": {
+          "type": "number"
+        },
+        "isLastStep": {
+          "type": "boolean"
+        },
+        "primaryProps": {
+          "additionalProperties": false,
+          "properties": {
+            "aria-label": {
+              "type": "string"
+            },
+            "data-action": {
+              "type": "string"
+            },
+            "role": {
+              "type": "string"
+            },
+            "title": {
+              "type": "string"
+            }
+          },
+          "required": ["aria-label", "data-action", "role", "title"],
+          "type": "object"
+        },
+        "size": {
+          "type": "number"
+        },
+        "skipProps": {
+          "additionalProperties": false,
+          "properties": {
+            "aria-label": {
+              "type": "string"
+            },
+            "data-action": {
+              "type": "string"
+            },
+            "role": {
+              "type": "string"
+            },
+            "title": {
+              "type": "string"
+            }
+          },
+          "required": ["aria-label", "data-action", "role", "title"],
+          "type": "object"
+        },
+        "step": {
+          "$ref": "#/definitions/Step"
+        },
+        "tooltipProps": {
+          "additionalProperties": false,
+          "properties": {
+            "aria-modal": {
+              "type": "boolean"
+            },
+            "role": {
+              "type": "string"
+            }
+          },
+          "required": ["aria-modal", "role"],
+          "type": "object"
+        }
+      },
+      "required": [
+        "backProps",
+        "closeProps",
+        "continuous",
+        "index",
+        "isLastStep",
+        "primaryProps",
+        "size",
+        "skipProps",
+        "step",
+        "tooltipProps"
+      ],
+      "type": "object"
+    },
+    "actions": {
+      "additionalProperties": false,
+      "properties": {
+        "CLOSE": {
+          "enum": ["close"],
+          "type": "string"
+        },
+        "GO": {
+          "enum": ["go"],
+          "type": "string"
+        },
+        "INDEX": {
+          "enum": ["index"],
+          "type": "string"
+        },
+        "INIT": {
+          "enum": ["init"],
+          "type": "string"
+        },
+        "NEXT": {
+          "enum": ["next"],
+          "type": "string"
+        },
+        "PREV": {
+          "enum": ["prev"],
+          "type": "string"
+        },
+        "RESET": {
+          "enum": ["reset"],
+          "type": "string"
+        },
+        "RESTART": {
+          "enum": ["restart"],
+          "type": "string"
+        },
+        "SKIP": {
+          "enum": ["skip"],
+          "type": "string"
+        },
+        "START": {
+          "enum": ["start"],
+          "type": "string"
+        },
+        "STOP": {
+          "enum": ["stop"],
+          "type": "string"
+        },
+        "UPDATE": {
+          "enum": ["update"],
+          "type": "string"
+        }
+      },
+      "required": [
+        "INIT",
+        "START",
+        "STOP",
+        "RESET",
+        "RESTART",
+        "PREV",
+        "NEXT",
+        "GO",
+        "INDEX",
+        "CLOSE",
+        "SKIP",
+        "UPDATE"
+      ],
+      "type": "object"
+    },
+    "events": {
+      "additionalProperties": false,
+      "properties": {
+        "BEACON": {
+          "enum": ["beacon"],
+          "type": "string"
+        },
+        "ERROR": {
+          "enum": ["error"],
+          "type": "string"
+        },
+        "STEP_AFTER": {
+          "enum": ["step:after"],
+          "type": "string"
+        },
+        "STEP_BEFORE": {
+          "enum": ["step:before"],
+          "type": "string"
+        },
+        "TARGET_NOT_FOUND": {
+          "enum": ["error:target_not_found"],
+          "type": "string"
+        },
+        "TOOLTIP": {
+          "enum": ["tooltip"],
+          "type": "string"
+        },
+        "TOOLTIP_CLOSE": {
+          "enum": ["close"],
+          "type": "string"
+        },
+        "TOUR_END": {
+          "enum": ["tour:end"],
+          "type": "string"
+        },
+        "TOUR_START": {
+          "enum": ["tour:start"],
+          "type": "string"
+        },
+        "TOUR_STATUS": {
+          "enum": ["tour:status"],
+          "type": "string"
+        }
+      },
+      "required": [
+        "TOUR_START",
+        "STEP_BEFORE",
+        "BEACON",
+        "TOOLTIP",
+        "TOOLTIP_CLOSE",
+        "STEP_AFTER",
+        "TOUR_END",
+        "TOUR_STATUS",
+        "TARGET_NOT_FOUND",
+        "ERROR"
+      ],
+      "type": "object"
+    },
+    "lifecycle": {
+      "additionalProperties": false,
+      "properties": {
+        "BEACON": {
+          "enum": ["beacon"],
+          "type": "string"
+        },
+        "COMPLETE": {
+          "enum": ["complete"],
+          "type": "string"
+        },
+        "ERROR": {
+          "enum": ["error"],
+          "type": "string"
+        },
+        "INIT": {
+          "enum": ["init"],
+          "type": "string"
+        },
+        "READY": {
+          "enum": ["ready"],
+          "type": "string"
+        },
+        "TOOLTIP": {
+          "enum": ["tooltip"],
+          "type": "string"
+        }
+      },
+      "required": ["INIT", "READY", "BEACON", "TOOLTIP", "COMPLETE", "ERROR"],
+      "type": "object"
+    },
+    "status": {
+      "additionalProperties": false,
+      "properties": {
+        "ERROR": {
+          "enum": ["error"],
+          "type": "string"
+        },
+        "FINISHED": {
+          "enum": ["finished"],
+          "type": "string"
+        },
+        "IDLE": {
+          "enum": ["idle"],
+          "type": "string"
+        },
+        "PAUSED": {
+          "enum": ["paused"],
+          "type": "string"
+        },
+        "READY": {
+          "enum": ["ready"],
+          "type": "string"
+        },
+        "RUNNING": {
+          "enum": ["running"],
+          "type": "string"
+        },
+        "SKIPPED": {
+          "enum": ["skipped"],
+          "type": "string"
+        },
+        "WAITING": {
+          "enum": ["waiting"],
+          "type": "string"
+        }
+      },
+      "required": [
+        "IDLE",
+        "READY",
+        "WAITING",
+        "RUNNING",
+        "PAUSED",
+        "SKIPPED",
+        "FINISHED",
+        "ERROR"
+      ],
+      "type": "object"
+    },
+    "valueof<status>": {
+      "enum": [
+        "idle",
+        "ready",
+        "waiting",
+        "running",
+        "paused",
+        "skipped",
+        "finished",
+        "error"
+      ],
+      "type": "string"
+    }
+  }
+}
diff --git a/src/constants.ts b/src/constants.ts
index f672653..d4c3810 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -1,19 +1,19 @@
 import { Props as JoyrideProps } from 'react-joyride';
-import { PLUGIN_ID } from './tokens';
+import { NS } from './tokens';
 
 /**
  * Command IDs
  */
 export namespace CommandIDs {
-  export const addTour = `${PLUGIN_ID}:add`;
-  export const launch = `${PLUGIN_ID}:launch`;
+  export const addTour = `${NS}:add`;
+  export const launch = `${NS}:launch`;
 }
 
 /**
  * Default tour IDs
  */
-export const WELCOME_ID = `${PLUGIN_ID}:welcome`;
-export const NOTEBOOK_ID = `${PLUGIN_ID}:notebook`;
+export const WELCOME_ID = `${NS}:welcome`;
+export const NOTEBOOK_ID = `${NS}:notebook`;
 
 /**
  * Default tour options
diff --git a/src/plugin.tsx b/src/plugin.tsx
index 459d22c..82dbb2f 100644
--- a/src/plugin.tsx
+++ b/src/plugin.tsx
@@ -6,25 +6,35 @@ import { ICommandPalette, InputDialog } from '@jupyterlab/apputils';
 import { IMainMenu, MainMenu } from '@jupyterlab/mainmenu';
 import { INotebookTracker } from '@jupyterlab/notebook';
 import { IStateDB } from '@jupyterlab/statedb';
+import { ISettingRegistry } from '@jupyterlab/settingregistry';
+
 import React from 'react';
 import ReactDOM from 'react-dom';
 import { TourContainer } from './components';
 import { CommandIDs, NOTEBOOK_ID, WELCOME_ID } from './constants';
 import { addTours } from './defaults';
-import { ITourHandler, ITourManager, PLUGIN_ID } from './tokens';
+import {
+  DEFAULTS_PLUGIN_ID,
+  ITourHandler,
+  ITourManager,
+  IUserTourManager,
+  PLUGIN_ID,
+  USER_PLUGIN_ID
+} from './tokens';
 import { TourHandler } from './tour';
 import { TourManager } from './tourManager';
+import { UserTourManager } from './userTourManager';
 import { addJSONTour } from './utils';
 
 /**
  * Initialization data for the jupyterlab-tour extension.
  */
-const extension: JupyterFrontEndPlugin<ITourManager> = {
-  id: `${PLUGIN_ID}:plugin`,
+const corePlugin: JupyterFrontEndPlugin<ITourManager> = {
+  id: PLUGIN_ID,
   autoStart: true,
   activate,
   requires: [IStateDB],
-  optional: [ICommandPalette, IMainMenu, INotebookTracker],
+  optional: [ICommandPalette, IMainMenu],
   provides: ITourManager
 };
 
@@ -32,8 +42,7 @@ function activate(
   app: JupyterFrontEnd,
   stateDB: IStateDB,
   palette?: ICommandPalette,
-  menu?: MainMenu,
-  nbTracker?: INotebookTracker
+  menu?: MainMenu
 ): ITourManager {
   const { commands } = app;
 
@@ -90,28 +99,70 @@ function activate(
     });
   }
 
-  addTours(manager, app, nbTracker);
-
   const node = document.createElement('div');
+
   document.body.appendChild(node);
   ReactDOM.render(<TourContainer tourLaunched={manager.tourLaunched} />, node);
 
+  return manager;
+}
+
+/**
+ * Optional plugin for user-defined tours stored in the settings registry
+ */
+const userPlugin: JupyterFrontEndPlugin<IUserTourManager> = {
+  id: USER_PLUGIN_ID,
+  autoStart: true,
+  activate: activateUser,
+  requires: [ISettingRegistry, ITourManager],
+  provides: IUserTourManager
+};
+
+function activateUser(
+  app: JupyterFrontEnd,
+  settings: ISettingRegistry,
+  tourManager: ITourManager
+): IUserTourManager {
+  const manager = new UserTourManager({
+    tourManager,
+    getSettings: (): Promise<ISettingRegistry.ISettings> =>
+      settings.load(USER_PLUGIN_ID)
+  });
+  return manager;
+}
+
+/**
+ * Optional plugin for the curated default tours and default toast behavior
+ */
+const defaultsPlugin: JupyterFrontEndPlugin<void> = {
+  id: DEFAULTS_PLUGIN_ID,
+  autoStart: true,
+  activate: activateDefaults,
+  requires: [ITourManager],
+  optional: [INotebookTracker]
+};
+
+function activateDefaults(
+  app: JupyterFrontEnd,
+  tourManager: ITourManager,
+  nbTracker?: INotebookTracker
+): void {
+  addTours(tourManager, app, nbTracker);
+
   if (nbTracker) {
     nbTracker.widgetAdded.connect(() => {
-      if (manager.tours.has(NOTEBOOK_ID)) {
-        manager.launch([NOTEBOOK_ID], false);
+      if (tourManager.tours.has(NOTEBOOK_ID)) {
+        tourManager.launch([NOTEBOOK_ID], false);
       }
     });
   }
 
   app.restored.then(() => {
-    if (manager.tours.has(WELCOME_ID)) {
+    if (tourManager.tours.has(WELCOME_ID)) {
       // Wait 3s before launching the first tour - to be sure element are loaded
-      setTimeout(() => manager.launch([WELCOME_ID], false), 3000);
+      setTimeout(() => tourManager.launch([WELCOME_ID], false), 3000);
     }
   });
-
-  return manager;
 }
 
-export default extension;
+export default [corePlugin, userPlugin, defaultsPlugin];
diff --git a/src/tokens.ts b/src/tokens.ts
index a15a605..3df4e2b 100644
--- a/src/tokens.ts
+++ b/src/tokens.ts
@@ -1,6 +1,7 @@
 import { Token } from '@lumino/coreutils';
 import { IDisposable } from '@lumino/disposable';
 import { ISignal } from '@lumino/signaling';
+import { ISettingRegistry } from '@jupyterlab/settingregistry';
 import React from 'react';
 import {
   CallBackProps,
@@ -10,17 +11,43 @@ import {
 } from 'react-joyride';
 
 /**
- * Extension ID
+ * Namespace for everything
  */
-export const PLUGIN_ID = 'jupyterlab-tour';
+export const NS = 'jupyterlab-tour';
+
+/**
+ * Core Extension ID
+ */
+export const PLUGIN_ID = `${NS}:plugin`;
+
+/**
+ * User-defined tours extension ID
+ */
+export const USER_PLUGIN_ID = `${NS}:user-tours`;
+
+/**
+ * First-party curated tours, like Notebook and Welcomes
+ */
+export const DEFAULTS_PLUGIN_ID = `${NS}:default-tours`;
 
 /**
  * Token to get a reference to the tours manager
  */
-export const ITourManager = new Token<ITourManager>(
-  `${PLUGIN_ID}:ITourManager`
+export const ITourManager = new Token<ITourManager>(`${NS}:ITourManager`);
+
+/**
+ * Token to get a reference to the user tours manager
+ */
+export const IUserTourManager = new Token<IUserTourManager>(
+  `${NS}:IUserTourManager`
 );
 
+/**
+ * Step placement, as it's mostly used here, can have a few extra values than
+ * other uses.
+ */
+export type StepPlacement = Placement | 'center' | 'auto';
+
 /**
  * Serialized step interface
  */
@@ -36,7 +63,7 @@ export interface IStep {
   /**
    * Pop-up position
    */
-  placement?: Placement;
+  placement?: StepPlacement;
   /**
    * Pop-up title
    */
@@ -56,13 +83,19 @@ export interface ITour {
    */
   label: string;
   /**
-   * Should this tour be added as entry in the Help menu
+   * Should this tour be added as entry in the Help menu. User-added tours always are.
    */
   hasHelpEntry: boolean;
   /**
    * Tour steps
    */
   steps: Array<IStep>;
+  /**
+   * A full tour description
+   *
+   * @see https://docs.react-joyride.com/props
+   */
+  options?: JoyrideProps;
 }
 
 /**
@@ -89,7 +122,7 @@ export interface ITourHandler extends IDisposable {
   createAndAddStep(
     target: string,
     content: React.ReactNode,
-    placement?: Placement,
+    placement?: StepPlacement,
     title?: string
   ): Step;
 
@@ -211,3 +244,21 @@ export interface ITourManager extends IDisposable {
    */
   readonly tours: Map<string, ITourHandler>;
 }
+
+/**
+ * User Tours manager interface
+ */
+export interface IUserTourManager {
+  readonly ready: Promise<void>;
+  readonly tourManager: ITourManager;
+}
+
+/**
+ * Namespace for user tour interfaces
+ */
+export namespace IUserTourManager {
+  export interface IOptions {
+    tourManager: ITourManager;
+    getSettings: () => Promise<ISettingRegistry.ISettings>;
+  }
+}
diff --git a/src/tour.ts b/src/tour.ts
index 662a3fd..ec92379 100644
--- a/src/tour.ts
+++ b/src/tour.ts
@@ -2,7 +2,6 @@ import { JSONExt } from '@lumino/coreutils';
 import { ISignal, Signal } from '@lumino/signaling';
 import {
   CallBackProps,
-  Placement,
   Props as JoyrideProps,
   status,
   STATUS,
@@ -10,7 +9,7 @@ import {
   valueof
 } from 'react-joyride';
 import { TutorialDefaultOptions } from './constants';
-import { ITourHandler } from './tokens';
+import { ITourHandler, StepPlacement } from './tokens';
 
 // TODO should be IDisposable !! handling signal connection clearance
 export class TourHandler implements ITourHandler {
@@ -140,7 +139,7 @@ export class TourHandler implements ITourHandler {
   createAndAddStep(
     target: string,
     content: React.ReactNode,
-    placement?: Placement,
+    placement?: StepPlacement,
     title?: string
   ): Step {
     const newStep: Step = {
diff --git a/src/tourManager.ts b/src/tourManager.ts
index 2ffe0c2..37ba517 100644
--- a/src/tourManager.ts
+++ b/src/tourManager.ts
@@ -1,14 +1,19 @@
+import { Menu } from '@lumino/widgets';
+
 import { MainMenu } from '@jupyterlab/mainmenu';
 import { IStateDB } from '@jupyterlab/statedb';
 import { ISignal, Signal } from '@lumino/signaling';
+
 import { INotification } from 'jupyterlab_toastify';
+
 import { Props as JoyrideProps } from 'react-joyride';
+
 import { CommandIDs } from './constants';
-import { ITourHandler, ITourManager, PLUGIN_ID } from './tokens';
+import { ITourHandler, ITourManager, NS } from './tokens';
 import { TourHandler } from './tour';
 import { version } from './version';
 
-const STATE_ID = `${PLUGIN_ID}:state`;
+const STATE_ID = `${NS}:state`;
 
 /**
  * Manager state saved in the state database
@@ -103,12 +108,13 @@ export class TourManager implements ITourManager {
     // Create tour and add it to help menu if needed
     const newTutorial: TourHandler = new TourHandler(id, label, options);
     if (this._menu && addToHelpMenu) {
-      this._menu.helpMenu.menu.addItem({
+      const menuItem = this._menu.helpMenu.menu.addItem({
         args: {
           id: newTutorial.id
         },
         command: CommandIDs.launch
       });
+      this._menuItems.set(newTutorial.id, menuItem);
     }
 
     // Add tour to current set
@@ -212,6 +218,12 @@ export class TourManager implements ITourManager {
     if (!tour) {
       return;
     }
+    // Remove tour from menu
+    if (this._menu && this._menuItems.has(id)) {
+      const item = this._menuItems.get(id);
+      this._menu.helpMenu.menu.removeItem(item);
+      this._menuItems.delete(id);
+    }
     tour.dispose();
     // Remove tour from the list
     this._tours.delete(id);
@@ -237,6 +249,7 @@ export class TourManager implements ITourManager {
   private _activeTours: TourHandler[] = new Array<TourHandler>();
   private _isDisposed = false;
   private _menu: MainMenu | undefined;
+  private _menuItems: Map<string, Menu.IItem> = new Map();
   private _state: IManagerState = {
     toursDone: new Set<string>(),
     version
diff --git a/src/userTourManager.ts b/src/userTourManager.ts
new file mode 100644
index 0000000..b009883
--- /dev/null
+++ b/src/userTourManager.ts
@@ -0,0 +1,105 @@
+import { ISettingRegistry } from '@jupyterlab/settingregistry';
+import { PromiseDelegate } from '@lumino/coreutils';
+
+import {
+  IUserTourManager,
+  ITour,
+  ITourManager,
+  USER_PLUGIN_ID
+} from './tokens';
+
+/**
+ * The UserTourManager is needed to manage syncing of user settings with the TourManager
+ */
+export class UserTourManager implements IUserTourManager {
+  constructor(options: IUserTourManager.IOptions) {
+    this._tourManager = options.tourManager;
+    options
+      .getSettings()
+      .then(userTours => {
+        this._userTours = userTours;
+        this._userTours.changed.connect(this._userToursChanged, this);
+        this._userToursChanged();
+        this._ready.resolve();
+      })
+      .catch(reason => {
+        console.warn(reason);
+        this._ready.reject(reason);
+      });
+  }
+
+  /**
+   * A promise that resolves when the settings have been loaded.
+   */
+  get ready(): Promise<void> {
+    return this._ready.promise;
+  }
+
+  get tourManager(): ITourManager {
+    return this._tourManager;
+  }
+
+  /**
+   * The user changed their tours, remove and re-add all of them.
+   */
+  private _userToursChanged(): void {
+    const tours: ITour[] = [...(this._userTours?.composite?.tours as any)];
+
+    if (!tours) {
+      return;
+    }
+
+    for (const id of this._tourManager.tours.keys()) {
+      if (id.startsWith(USER_PLUGIN_ID)) {
+        this._tourManager.removeTour(id);
+      }
+    }
+
+    tours.sort(this._compareTours);
+
+    for (const tour of tours) {
+      try {
+        this._addUserTour(tour);
+        this._tourManager.launch([tour.id], false);
+      } catch (error) {
+        console.groupCollapsed(
+          `Error encountered adding user tour ${tour.label} (${tour.id})`,
+          error
+        );
+        console.table(tour.steps);
+        console.log(tour.options || {});
+        console.groupEnd();
+      }
+    }
+  }
+
+  /**
+   * Actually create a tour from JSON
+   */
+  private _addUserTour(tour: ITour): void {
+    const handler = this._tourManager.createTour(
+      `${USER_PLUGIN_ID}:${tour.id}`,
+      tour.label,
+      tour.hasHelpEntry === false ? false : true,
+      tour.options
+    );
+
+    for (const step of tour.steps) {
+      handler.addStep(step);
+    }
+  }
+
+  /**
+   * Helper to sort user tours by label, if possible, falling back to unique id
+   */
+  private _compareTours(a: ITour, b: ITour): number {
+    return (
+      a.label.toLocaleLowerCase().localeCompare(b.label.toLocaleLowerCase()) ||
+      a.id.localeCompare(b.id)
+    );
+  }
+
+  private _tourManager: ITourManager;
+  private _userTours: ISettingRegistry.ISettings;
+  private _ready = new PromiseDelegate<void>();
+}
diff --git a/yarn.lock b/yarn.lock
index 4ccf293..84d245d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1545,7 +1545,7 @@
     node-fetch "^2.6.0"
     ws "^7.2.0"
 
-"@jupyterlab/settingregistry@^3.0.5":
+"@jupyterlab/settingregistry@^3.0.0", "@jupyterlab/settingregistry@^3.0.5":
   version "3.0.5"
   resolved "https://registry.yarnpkg.com/@jupyterlab/settingregistry/-/settingregistry-3.0.5.tgz#d36efd1d5a67cd4c89d88159ffa21eaa03a9bf1f"
   integrity sha512-H86y+CKUxObPA/g8kafmoK23rRsKaXnYCkp4qDx5Vl932/rN6wJUezQ1POe4iFW8MHiniUJpKB8WCknVGc/AUg==
@@ -1969,13 +1969,6 @@
   dependencies:
     "@types/react" "*"
 
-"@types/react-joyride@^2.0.5":
-  version "2.0.5"
-  resolved "https://registry.yarnpkg.com/@types/react-joyride/-/react-joyride-2.0.5.tgz#621fb90418a392ac009ee6b75c3d1850a88ea92b"
-  integrity sha512-N3bd0w3D42gZRpXy/wYsSZ8JvhyuyN0qAFTkg2iNsJ1NueMqfI0TJL+BZhhFkAqNhEETJEpDc8t4bj3DEJyLoQ==
-  dependencies:
-    react-joyride "*"
-
 "@types/react@*", "@types/react@^17.0.0":
   version "17.0.3"
   resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.3.tgz#ba6e215368501ac3826951eef2904574c262cc79"
@@ -6325,7 +6318,7 @@ react-is@^17.0.1:
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
   integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
 
-react-joyride@*, react-joyride@^2.2.1:
+react-joyride@^2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/react-joyride/-/react-joyride-2.3.0.tgz#d18f7af8f6e7d4c7409426d43e5f6513d70b3e00"
   integrity sha512-aY7+dgmBKbgGoMjN828qXnMAqeA6QvwvSWxj/fyvxIIIx0iu3wNVT/A5NZ0wHxiVDav+Df9YZuL412Q6C0l7gw==


Test patch:
diff --git a/src/__tests__/plugin.spec.ts b/src/__tests__/plugin.spec.ts
index 168b566..f6d4a90 100644
--- a/src/__tests__/plugin.spec.ts
+++ b/src/__tests__/plugin.spec.ts
@@ -1,21 +1,72 @@
+import { JupyterFrontEnd } from '@jupyterlab/application';
+import { ISettingRegistry } from '@jupyterlab/settingregistry';
 import { StateDB } from '@jupyterlab/statedb';
 import { CommandRegistry } from '@lumino/commands';
+import { ReadonlyJSONObject } from '@lumino/coreutils';
+import { Signal } from '@lumino/signaling';
 import 'jest';
 import { CommandIDs } from '../constants';
 import plugin from '../index';
-import { ITourHandler, ITourManager } from '../tokens';
+import { ITour, ITourManager, IUserTourManager } from '../tokens';
 
 const DEFAULT_TOURS_SIZE = 2;
 
-describe('plugin', () => {
-  describe('#activate', () => {
-    it('it should create add-tour command', () => {
-      const app = {
-        commands: new CommandRegistry(),
-        restored: Promise.resolve()
-      };
+const [corePlugin, userPlugin, defaultsPlugin] = plugin;
+
+function mockApp(): Partial<JupyterFrontEnd> {
+  return {
+    commands: new CommandRegistry(),
+    restored: Promise.resolve()
+  };
+}
+
+function aTour(): ITour {
+  return {
+    id: 'test-jupyterlab-tour:welcome',
+    label: 'Welcome Tour',
+    hasHelpEntry: true,
+    steps: [
+      {
+        content: 'Tours of Jupyter',
+        placement: 'auto',
+        target: '#jp-MainLogo',
+        title: 'Jupyter'
+      },
+      {
+        content:
+          'The following tour will point out some of the main UI components within JupyterLab.',
+        placement: 'right',
+        target: '#jp-main-dock-panel',
+        title: 'Welcome to Jupyter Lab!'
+      },
+      {
+        content:
+          'This is the main content area where notebooks and other content can be viewed and edited.',
+        placement: 'center',
+        target: '#jp-main-dock-panel',
+        title: 'Main Content'
+      }
+    ]
+  };
+}
+
+function mockSettingRegistry(): Partial<ISettingRegistry> {
+  const settings: Partial<ISettingRegistry.ISettings> = {
+    composite: { tours: [(aTour() as any) as ReadonlyJSONObject] }
+  };
+  (settings as any)['changed'] = new Signal<any, any>(settings);
+
+  return {
+    load: async (): Promise<any> => settings as any
+  };
+}
+
+describe(corePlugin.id, () => {
+  describe('activation', () => {
+    it('should create add-tour command', () => {
+      const app = mockApp();
       const stateDB = new StateDB();
-      plugin.activate(app as any, stateDB);
+      corePlugin.activate(app as any, stateDB);
 
       expect(app.commands.hasCommand(CommandIDs.addTour)).toEqual(true);
     });
@@ -23,42 +74,72 @@ describe('plugin', () => {
 
   describe('commands', () => {
     describe(`${CommandIDs.addTour}`, () => {
-      it('it should add a tour command', async () => {
-        const app = {
-          commands: new CommandRegistry(),
-          restored: Promise.resolve()
-        };
+      it('should add a tour command', async () => {
+        const app = mockApp();
         const stateDB = new StateDB();
-        const manager = plugin.activate(app as any, stateDB) as ITourManager;
-        expect(manager.tours.size).toEqual(DEFAULT_TOURS_SIZE);
+        const manager = corePlugin.activate(
+          app as any,
+          stateDB
+        ) as ITourManager;
+        expect(manager.tours.size).toEqual(0);
 
-        const tour = (await app.commands.execute(CommandIDs.addTour, {
-          tour: {
-            id: 'test-jupyterlab-tour:welcome',
-            label: 'Welcome Tour',
-            steps: [
-              {
-                content:
-                  'The following tour will point out some of the main UI components within JupyterLab.',
-                placement: 'center',
-                target: '#jp-main-dock-panel',
-                title: 'Welcome to Jupyter Lab!'
-              },
-              {
-                content:
-                  'This is the main content area where notebooks and other content can be viewed and edited.',
-                placement: 'left-end',
-                target: '#jp-main-dock-panel',
-                title: 'Main Content'
-              }
-            ]
-          }
-        })) as ITourHandler;
+        const tour = await app.commands.execute(CommandIDs.addTour, {
+          tour: (aTour() as any) as ReadonlyJSONObject
+        });
 
-        expect(manager.tours.size).toEqual(DEFAULT_TOURS_SIZE + 1);
+        expect(manager.tours.size).toEqual(1);
         expect(tour).toBeTruthy();
         expect(manager.tours.get(tour.id)).toBeTruthy();
       });
     });
   });
 });
+
+describe(userPlugin.id, () => {
+  describe('activation', () => {
+    it('should have userTours', async () => {
+      const app = mockApp();
+      const stateDB = new StateDB();
+      const manager = corePlugin.activate(app as any, stateDB) as ITourManager;
+      const settings = mockSettingRegistry();
+      const userManager = userPlugin.activate(
+        app as any,
+        settings,
+        manager
+      ) as IUserTourManager;
+      await userManager.ready;
+      expect(userManager.tourManager.tours.size).toBe(1);
+    });
+  });
+
+  describe('settings', () => {
+    it('should react to settings', async () => {
+      const app = mockApp();
+      const stateDB = new StateDB();
+      const manager = corePlugin.activate(app as any, stateDB) as ITourManager;
+      const settingsRegistry = mockSettingRegistry();
+      const userManager = userPlugin.activate(
+        app as any,
+        settingsRegistry,
+        manager
+      ) as IUserTourManager;
+      await userManager.ready;
+      const settings = await settingsRegistry.load('whatever');
+      (settings as any).composite = { tours: [] };
+      (settings as any).changed.emit(void 0);
+      expect(userManager.tourManager.tours.size).toBe(0);
+    });
+  });
+});
+
+describe(defaultsPlugin.id, () => {
+  describe('activation', () => {
+    it('should activate', () => {
+      const app = mockApp();
+      const stateDB = new StateDB();
+      const manager = corePlugin.activate(app as any, stateDB) as ITourManager;
+      defaultsPlugin.activate(app as any, manager);
+      expect(manager.tours.size).toEqual(DEFAULT_TOURS_SIZE);
+    });
+  });
+});