LinkedIn Tool

Minor enhancements to LinkedIn. Mostly just hotkeys.

目前为 2023-11-22 提交的版本。查看 最新版本

// ==UserScript==
// @name        LinkedIn Tool
// @namespace   [email protected]
// @match       https://www.linkedin.com/*
// @noframes
// @version     5.41
// @author      Mike Castle
// @description Minor enhancements to LinkedIn. Mostly just hotkeys.
// @license     GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0-standalone.html
// @supportURL  https://github.com/nexushoratio/userscripts/blob/main/linkedin-tool.md
// @require     https://cdn.jsdelivr.net/npm/@violentmonkey/shortcut@1
// @require     https://update.greasyfork.org/scripts/478188/1281409/NH_xunit.js
// @require     https://update.greasyfork.org/scripts/477290/1281408/NH_base.js
// @require     https://update.greasyfork.org/scripts/478349/1275719/NH_userscript.js
// @require     https://update.greasyfork.org/scripts/478440/1276563/NH_web.js
// @require     https://update.greasyfork.org/scripts/478676/1281410/NH_widget.js
// @grant       GM.getValue
// @grant       GM.setValue
// @grant       window.onurlchange
// ==/UserScript==

/* global VM */

// eslint-disable-next-line max-lines-per-function
(async () => {
  'use strict';

  const NH = window.NexusHoratio.base.ensure([
    {name: 'xunit', minVersion: 24},
    {name: 'base', minVersion: 33},
    {name: 'userscript', minVersion: 4},
    {name: 'web', minVersion: 2},
    {name: 'widget', minVersion: 8},
  ]);

  /**
   * Load options from storage.
   *
   * TODO: Over engineer this into having a schema that could be used for
   * building an edit widget.
   *
   * Saved options will be augmented by any new defaults and resaved.
   * @returns {object} - Options key/value pairs.
   */
  async function loadOptions() {
    const defaultOptions = {
      enableDevMode: false,
    };
    const options = {
      ...defaultOptions,
      ...await NH.userscript.getValue('Options', {}),
    };
    NH.userscript.setValue('Options', options);
    return options;
  }

  const litOptions = await loadOptions();
  NH.xunit.testing.enabled = litOptions.enableDevMode;

  /* eslint-disable require-atomic-updates */
  NH.base.Logger.configs = await NH.userscript.getValue('Logger');
  document.addEventListener('visibilitychange', async () => {
    if (document.visibilityState === 'hidden') {
      await NH.userscript.setValue('Logger', NH.base.Logger.configs);
    }
    if (document.visibilityState === 'visible') {
      NH.base.Logger.configs = await NH.userscript.getValue('Logger');
    }
  });
  /* eslint-enable */

  // TODO(#145): The if test is just here while developing.
  if (!litOptions.enableDevMode) {
    NH.base.Logger.config('Default').enabled = true;
  }

  const log = new NH.base.Logger('Default');

  const globalKnownIssues = [
    ['Bob', 'Bob has no issues'],
    ['', 'Minor internal improvement'],
    ['#106', 'info view: more tabs: News, License'],
    ['#130', 'Factor hotkey handling out of SPA'],
    ['#140', 'Self registering keyboard shortcuts'],
    ['#142', 'Support My Network view'],
    ['#143', 'Support jobs search view'],
    ['#144', 'Support Messaging view'],
    ['#145', '<code>Logger</code>: improved controls'],
    ['#149', '<code>Scroller</code>: support onclick natively'],
    ['#150', '<code>Scroller</code>: handle page reloads better'],
    [
      '#151', '<code>MyNetwork</code>: card scrolling does not work ' +
     'after return',
    ],
    ['#153', 'Support Invitation manager view'],
    [
      '#154', '<code>Notifications</code>: <kbd><kbd>X</kbd></kbd> no ' +
        'longer works to dismiss',
    ],
    [
      '#155', '<code>Jobs</code>: The <em>More jobs few you</code> ' +
        'section no longer navigates jobs',
    ],
    [
      '#157', '<code>InvitationManager</code>: Invite not scrolling into ' +
        'view upon refresh',
    ],
    [
      '#159', '<code>MyNetwork</code>: Trying to ' +
     '<kbd><kbd>E</kbd></kbd>ngage does not always work',
    ],
    [
      '#161',
      'Clicking on <em>Load more comments</em> does not select the post',
    ],
    ['#165', '<code>Scroller</code>: Wait until base shows up'],
    ['#167', 'Refactor into libraries'],
    [
      '#168', '<code>JobCollections</code>: <kbd><kbd>X</kbd></kbd> will ' +
     'not recover a dismissed job card',
    ],
    [
      '#170', 'Enable <em>GM.setValue</em> and <em>GM.getValue</em> ' +
     'functions',
    ],
    [
      '#177', '<code>JobCollections</code> Results pages: Convert to ' +
     '<em>containerItems</em>',
    ],
    [
      '#178', 'Migrate all <code>Scrollers</code> from <em>_refresh()</em> ' +
     'to <em>#onScrollerActivate</em>',
    ],
    ['#187', 'Issues on <b>Greasemonkey</b> userscript manager'],
    ['#198', 'Add default options'],
    ['#200', 'Implement <code>TestResult.summary()</code>'],
  ];

  const globalNewsContent = [
    {
      date: '2023-11-21',
      issues: ['#144'],
      subject: 'Move control of the tablist monitor into ' +
        '<code>Scroller</code> activation',
    },
    {
      date: '2023-11-19',
      issues: ['#144'],
      subject: 'Add <code>Scroller</code> for <code>Messaging</code> view',
    },
    {
      date: '2023-11-18',
      issues: ['#144'],
      subject: 'Implement moving to the <kbd><kbd>m</kbd></kbd>essage ' +
        'tablist',
    },
    {
      date: '2023-11-16',
      issues: ['#200'],
      subject: 'Bump all libraries to the current version',
    },
    {
      date: '2023-11-16',
      issues: ['#170'],
      subject: 'Enable <em>GM.setValue()</em> and <em>GM.getValue()</em>',
    },
    {
      date: '2023-11-13',
      issues: [''],
      subject: 'Tweak HTML in news content',
    },
    {
      date: '2023-11-12',
      issues: ['#177'],
      subject: 'Switch the <em>paginationScroller</em> to using ' +
        '<em>containeritems</em>',
    },
    {
      date: '2023-11-12',
      issues: ['#165'],
      subject: 'Support an optional timeout on containers in ' +
        '<code>Scroller</code>',
    },
    {
      date: '2023-11-12',
      issues: ['#198'],
      subject: 'Implement a simple (for now!) <em>loadOptions()</em>',
    },
    {
      date: '2023-11-11',
      issues: ['#165'],
      subject: 'Verify a container exists before looking for items',
    },
    {
      date: '2023-11-09',
      issues: ['#161'],
      subject: 'Listen for clicks during the capture phase in ' +
        '<code>Scroller</code>',
    },
    {
      date: '2023-11-06',
      issues: ['#150'],
      subject: 'Make item being viewable a criteria for reactivation',
    },
    {
      date: '2023-11-06',
      issues: ['#150'],
      subject: 'Simplify <code>Feed.#onPostActivate()</code>',
    },
    {
      date: '2023-11-06',
      issues: ['#170'],
      subject: 'Bump versions of many libraries',
    },
    {
      date: '2023-11-05',
      issues: ['#150'],
      subject: 'Wait for current item to reappear when ' +
        '<code>Scroller</code> is reactivated',
    },
    {
      date: '2023-11-04',
      issues: ['#187'],
      subject: 'Bump lib/userscript to version 3',
    },
    {
      date: '2023-11-03',
      issues: ['#130'],
      subject: 'Bump lib/base to version 22',
    },
    {
      date: '2023-11-02',
      issues: ['#178'],
      subject: 'Migrate <code>Notifications</code> from <em>_refresh</em> ' +
        'to <em>onScrollerActivate</em>',
    },
    {
      date: '2023-11-01',
      issues: ['#178'],
      subject: 'Migrate <code>InvitationManager</code> from ' +
        '<em>refresh</em> to <em>onScrollerActivate</em>',
    },
    {
      date: '2023-11-01',
      issues: ['#144'],
      subject: 'Implement <kbd><kbd>s</kbd></kbd> to focus on the ' +
        '<em>Search messages</em> input field',
    },
    {
      date: '2023-10-30',
      issues: ['#167'],
      subject: 'Depend on lib/widget version 0',
    },
    {
      date: '2023-10-30',
      issues: ['#144'],
      subject: 'Implement <kbd><kbd>N</kbd></kbd> to compose a New message',
    },
    {
      date: '2023-10-30',
      issues: ['#178'],
      subject: 'Migrate <code>Feed</code> from using <em>refresh</em> to ' +
        '<em>onScrollerActivate</em>',
    },
    {
      date: '2023-10-30',
      issues: ['#144'],
      subject: 'Implement <kbd><kbd>l</kbd></kbd> to load more ' +
        'conversations',
    },
    {
      date: '2023-10-27',
      issues: ['#165'],
      subject: 'Switch <code>MyNetwork</code> to using ' +
        '<em>containerItems</em> for primary <code>Scroller</code>',
    },
    {
      date: '2023-10-27',
      issues: ['#165'],
      subject: 'Switch <code>Feed</code> to using <em>containerItems</em> ' +
        'primary <code>Scroller</code>',
    },
    {
      date: '2023-10-22',
      issues: ['#144'],
      subject: 'Initial support for the <code>Messaging</code> page',
    },
  ];

  /**
   * Implement HTML for a tabbed user interface.
   *
   * This version uses radio button/label pairs to select the active panel.
   *
   * @example
   * const tabby = new TabbedUI('Tabby Cat');
   * document.body.append(tabby.container);
   * tabby.addTab(helpTabDefinition);
   * tabby.addTab(docTabDefinition);
   * tabby.addTab(contactTabDefinition);
   * tabby.goto(helpTabDefinition.name);  // Set initial tab
   * tabby.next();
   * const entry = tabby.tabs.get(contactTabDefinition.name);
   * entry.classList.add('random-css');
   * entry.innerHTML += '<p>More contact info.</p>';
   */
  class TabbedUI {

    /**
     * @param {string} name - Used to distinguish HTML elements and CSS
     * classes.
     */
    constructor(name) {
      this.#log = new NH.base.Logger(`TabbedUI ${name}`);
      this.#name = name;
      this.#idName = NH.base.safeId(name);
      this.#id = NH.base.uuId(this.#idName);
      this.#container = document.createElement('section');
      this.#container.id = `${this.#id}-container`;
      this.#installControls();
      this.#container.append(this.#nav);
      this.#installStyle();
      this.#log.log(`${this.#name} constructed`);
    }

    /** @type {Element} */
    get container() {
      return this.#container;
    }

    /**
     * @typedef {object} TabEntry
     * @property {string} name - Tab name.
     * @property {Element} label - Tab label, so CSS can be applied.
     * @property {Element} panel - Tab panel, so content can be updated.
     */

    /** @type {Map<string,TabEntry>} */
    get tabs() {
      const entries = new Map();
      for (const label of this.#nav.querySelectorAll(
        ':scope > label[data-tabbed-name]'
      )) {
        entries.set(label.dataset.tabbedName, {label: label});
      }
      for (const panel of this.container.querySelectorAll(
        `:scope > .${this.#idName}-panel`
      )) {
        entries.get(panel.dataset.tabbedName).panel = panel;
      }
      return entries;
    }

    /**
     * A string of HTML or a prebuilt Element.
     * @typedef {(string|Element)} TabContent
     */

    /**
     * @typedef {object} TabDefinition
     * @property {string} name - Tab name.
     * @property {TabContent} content - Initial content.
     */

    /** @param {TabDefinition} tab - The new tab. */
    addTab(tab) {
      const me = 'addTab';
      this.#log.entered(me, tab);
      const {
        name,
        content,
      } = tab;
      const idName = NH.base.safeId(name);
      const input = this.#createInput(name, idName);
      const label = this.#createLabel(name, input, idName);
      const panel = this.#createPanel(name, idName, content);
      input.addEventListener('change', this.#onChange.bind(this, panel));
      this.#nav.before(input);
      this.#navSpacer.before(label);
      this.container.append(panel);

      const inputChecked =
            `#${this.container.id} > ` +
            `input[data-tabbed-name="${name}"]:checked`;
      this.#style.textContent +=
        `${inputChecked} ~ nav > [data-tabbed-name="${name}"] {` +
        ' border-bottom: 3px solid black;' +
        '}\n';
      this.#style.textContent +=
        `${inputChecked} ~ div[data-tabbed-name="${name}"] {` +
        ' display: flex;' +
        '}\n';

      this.#log.leaving(me);
    }

    /** Activate the next tab. */
    next() {
      const me = 'next';
      this.#log.entered(me);
      this.#switchTab(1);
      this.#log.leaving(me);
    }

    /** Activate the previous tab. */
    prev() {
      const me = 'prev';
      this.#log.entered(me);
      this.#switchTab(-1);
      this.#log.leaving(me);
    }

    /** @param {string} name - Name of the tab to activate. */
    goto(name) {
      const me = 'goto';
      this.#log.entered(me, name);
      const controls = this.#getTabControls();
      const control = controls.find(item => item.dataset.tabbedName === name);
      control.click();
      this.#log.leaving(me);
    }

    #container
    #id
    #idName
    #log
    #name
    #nav
    #navSpacer
    #nextButton
    #prevButton
    #style

    /** Installs basic CSS styles for the UI. */
    #installStyle = () => {
      this.#style = document.createElement('style');
      this.#style.id = `${this.#id}-style`;
      const styles = [
        `#${this.container.id} {` +
          ' flex-grow: 1; overflow-y: hidden; display: flex;' +
          ' flex-direction: column;' +
          '}',
        `#${this.container.id} > input { display: none; }`,
        `#${this.container.id} > nav { display: flex; flex-direction: row; }`,
        `#${this.container.id} > nav button { border-radius: 50%; }`,
        `#${this.container.id} > nav > label {` +
          ' cursor: pointer;' +
          ' margin-top: 1ex; margin-left: 1px; margin-right: 1px;' +
          ' padding: unset; color: unset !important;' +
          '}',
        `#${this.container.id} > nav > .spacer {` +
          ' margin-left: auto; margin-right: auto;' +
          ' border-right: 1px solid black;' +
          '}',
        `#${this.container.id} label::before { all: unset; }`,
        `#${this.container.id} label::after { all: unset; }`,
        // Panels are both flex items AND flex containers.
        `#${this.container.id} .${this.#idName}-panel {` +
          ' display: none; overflow-y: auto; flex-grow: 1;' +
          ' flex-direction: column;' +
          '}',
        '',
      ];
      this.#style.textContent = styles.join('\n');
      document.head.prepend(this.#style);
    }

    /**
     * Get the tab controls currently in the container.
     * @returns {Element[]} - Control elements for the tabs.
     */
    #getTabControls = () => {
      const controls = Array.from(this.container.querySelectorAll(
        ':scope > input'
      ));
      return controls;
    }

    /**
     * Switch to an adjacent tab.
     * @param {number} direction - Either 1 or -1.
     * @fires Event#change
     */
    #switchTab = (direction) => {
      const me = 'switchTab';
      this.#log.entered(me, direction);
      const controls = this.#getTabControls();
      this.#log.log('controls:', controls);
      let idx = controls.findIndex(item => item.checked);
      if (idx === NH.base.NOT_FOUND) {
        idx = 0;
      } else {
        idx = (idx + direction + controls.length) % controls.length;
      }
      controls[idx].click();
      this.#log.leaving(me);
    }

    /**
     * @param {string} name - Human readable name for tab.
     * @param {string} idName - Normalized to be CSS class friendly.
     * @returns {Element} - Input portion of the tab.
     */
    #createInput = (name, idName) => {
      const me = 'createInput';
      this.#log.entered(me);
      const input = document.createElement('input');
      input.id = `${this.#idName}-input-${idName}`;
      input.name = `${this.#idName}`;
      input.dataset.tabbedId = `${this.#idName}-input-${idName}`;
      input.dataset.tabbedName = name;
      input.type = 'radio';
      this.#log.leaving(me, input);
      return input;
    }

    /**
     * @param {string} name - Human readable name for tab.
     * @param {Element} input - Input element associated with this label.
     * @param {string} idName - Normalized to be CSS class friendly.
     * @returns {Element} - Label portion of the tab.
     */
    #createLabel = (name, input, idName) => {
      const me = 'createLabel';
      this.#log.entered(me);
      const label = document.createElement('label');
      label.dataset.tabbedId = `${this.#idName}-label-${idName}`;
      label.dataset.tabbedName = name;
      label.htmlFor = input.id;
      label.innerText = `[${name}]`;
      this.#log.leaving(me, label);
      return label;
    }

    /**
     * @param {string} name - Human readable name for tab.
     * @param {string} idName - Normalized to be CSS class friendly.
     * @param {TabContent} content - Initial content.
     * @returns {Element} - Panel portion of the tab.
     */
    #createPanel = (name, idName, content) => {
      const me = 'createPanel';
      this.#log.entered(me);
      const panel = document.createElement('div');
      panel.dataset.tabbedId = `${this.#idName}-panel-${idName}`;
      panel.dataset.tabbedName = name;
      panel.classList.add(`${this.#idName}-panel`);
      if (content instanceof Element) {
        panel.append(content);
      } else {
        panel.innerHTML = content;
      }
      this.#log.leaving(me, panel);
      return panel;
    }

    /**
     * Event handler for change events.  When the active tab changes, this
     * will resend an 'expose' event to the associated panel.
     * @param {Element} panel - The panel associated with this tab.
     * @param {Event} evt - The original change event.
     * @fires Event#expose
     */
    #onChange = (panel, evt) => {
      const me = 'onChange';
      this.#log.entered(me, evt, panel);
      panel.dispatchEvent(new Event('expose'));
      this.#log.leaving(me);
    }

    /** Installs navigational control elements. */
    #installControls = () => {
      this.#nav = document.createElement('nav');
      this.#nav.id = `${this.#id}-controls`;
      this.#navSpacer = document.createElement('span');
      this.#navSpacer.classList.add('spacer');
      this.#prevButton = document.createElement('button');
      this.#nextButton = document.createElement('button');
      this.#prevButton.innerText = '←';
      this.#nextButton.innerText = '→';
      this.#prevButton.dataset.name = 'prev';
      this.#nextButton.dataset.name = 'next';
      this.#prevButton.addEventListener('click', () => this.prev());
      this.#nextButton.addEventListener('click', () => this.next());
      // XXX: Cannot get 'button' elements to style nicely, so cheating by
      // wrapping them in a label.
      const prevLabel = document.createElement('label');
      const nextLabel = document.createElement('label');
      prevLabel.append(this.#prevButton);
      nextLabel.append(this.#nextButton);
      this.#nav.append(this.#navSpacer, prevLabel, nextLabel);
    }

  }

  /**
   * An ordered collection of HTMLElements for a user to continuously scroll
   * through.
   *
   * The dispatcher can be used the handle the following events:
   * - 'out-of-range' - Scrolling went past one end of the collection.  This
   *   is NOT an error condition, but rather a design feature.
   * - 'change' - The value of item has changed.
   * - 'activate' - The Scroller was activated.
   * - 'deactivate' - The Scroller was deactivated.
   */
  class Scroller {

    /**
     * Function that generates a, preferably, reproducible unique identifier
     * for an Element.
     * @callback uidCallback
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */

    /**
     * Contains CSS selectors to first find a base element, then items that it
     * contains.
     * @typedef {object} ContainerItemsSelector
     * @property {string} container - CSS selector to find the container
     * element.
     * @property {string} items - CSS selector to find the items inside the
     * container.
     */

    /**
     * There are two ways to describe what elements go into a Scroller:
     * 1. An explicit container (base) element and selectors stemming from it.
     * 2. An array of ContainerItemsSelector that can allow for multiple
     *   containers with items.  This approach will also allow the Scroller to
     *   automatically wait for all container elements to exist during
     *   activation.
     * @typedef {object} What
     * @property {string} name - Name for this scroller, used for logging.
     * @property {Element} base - The container to use as a base for selecting
     * elements.
     * @property {string[]} selectors - Array of CSS selectors to find
     * elements to collect, calling base.querySelectorAll().
     * @property {ContainerItemsSelector[]} containerItems - Array of
     * ContainerItemsSelectors.
     */

    /**
     * @typedef {object} How
     * @property {uidCallback} uidCallback - Callback to generate a uid.
     * @property {string[]} [classes=[]] - Array of CSS classes to add/remove
     * from an element as it becomes current.
     * @property {boolean} [handleClicks=true] - Whether the scroller should
     * watch for clicks and if one is inside an item, select it.
     * @property {boolean} [autoActivate=false] - Whether to call the activate
     * method at the end of construction.
     * @property {boolean} [snapToTop=false] - Whether items should snap to
     * the top of the window when coming into view.
     * @property {number} [topMarginPixels=0] - Used to determine if scrolling
     * should happen when {snapToTop} is false.
     * @property {number} [bottomMarginPixels=0] - Used to determin if
     * scrolling should happen when {snapToTop} is false.
     * @property {string} [topMarginCSS='0'] - CSS applied to
     * `scrollMarginTop`.
     * @property {string} [bottomMarginCSS='0'] - CSS applied to
     * `scrollMarginBottom`.
     * @property {number} [waitForItemTimeout=3000] - Time to wait, in
     * milliseconds, for existing item to reappear upon reactivation.
     * @property {number} [containerTimeout=0] - Time to wait, in
     * milliseconds, for a {ContainerItemsSelector.container} to show up.
     * Some pages may not always provide all identified containers.  The
     * default of 0 disables timing out.  NB: Any containers that timeout will
     * not handle further activate() processing, such as handleClicks.
     */

    /**
     * @param {What} what - What we want to scroll.
     * @param {How} how - How we want to scroll.
     * @throws {Scroller.Error} - On many construction problems.
     */
    constructor(what, how) {
      const WAIT_FOR_ITEM = 3000;

      ({
        name: this.#name = 'Unnamed scroller',
        base: this.#base,
        selectors: this.#selectors,
        containerItems: this.#containerItems = [],
      } = what);
      ({
        uidCallback: this.#uidCallback,
        classes: this.#classes = [],
        handleClicks: this.#handleClicks = true,
        autoActivate: this.#autoActivate = false,
        snapToTop: this.#snapToTop = false,
        topMarginPixels: this.#topMarginPixels = 0,
        bottomMarginPixels: this.#bottomMarginPixels = 0,
        topMarginCSS: this.#topMarginCSS = '0',
        bottomMarginCSS: this.#bottomMarginCSS = '0',
        waitForItemTimeout: this.#waitForItemTimeout = WAIT_FOR_ITEM,
        containerTimeout: this.#containerTimeout = 0,
      } = how);

      this.#validateInstance();

      this.#mutationObserver = new MutationObserver(this.#mutationHandler);

      this.#logger = new NH.base.Logger(`{${this.#name}}`);
      this.logger.log('Scroller constructed', this);

      if (this.#autoActivate) {
        this.activate();
      }
    }

    static Error = class extends Error {

      /** @inheritdoc */
      constructor(...rest) {
        super(...rest);
        this.name = this.constructor.name;
      }

    };

    /** @type {NH.base.Dispatcher} */
    get dispatcher() {
      return this.#dispatcher;
    }

    /** @type {Element} - Represents the current item. */
    get item() {
      const me = 'get item';
      this.logger.entered(me);
      if (this.#destroyed) {
        const msg = `Tried to work with destroyed ${Scroller.name} ` +
              `on ${this.#base}`;
        this.logger.log(msg);
        throw new Error(msg);
      }
      const items = this.#getItems();
      let item = items.find(this.#matchItem);
      if (!item) {
        // We couldn't find the old id, so maybe it was rebuilt.  Make a guess
        // by trying the old index.
        const idx = this.#historicalIdToIndex.get(this.#currentItemId);
        if (typeof idx === 'number' && (0 <= idx && idx < items.length)) {
          item = items[idx];
          this.#bottomHalf(item);
        }
      }
      this.logger.leaving(me, item);
      return item;
    }

    /** @param {Element} val - Set the current item. */
    set item(val) {
      const me = 'set item';
      this.logger.entered(me, val);
      this.dull();
      this.#bottomHalf(val);
      this.logger.leaving(me);
    }

    /** @type {string} - Current item's uid. */
    get itemUid() {
      return this.#currentItemId;
    }

    /** @type {NH.base.Logger} */
    get logger() {
      return this.#logger;
    }

    /** Move to the next item in the collection. */
    next() {
      this.#scrollBy(1);
    }

    /** Move to the previous item in the collection. */
    prev() {
      this.#scrollBy(-1);
    }

    /** Jump to the first item in the collection. */
    first() {
      this.#jumpToEndItem(true);
    }

    /** Jump to last item in the collection. */
    last() {
      this.#jumpToEndItem(false);
    }

    /**
     * Move to a specific item if possible.
     * @param {Element} item - Item to go to.
     */
    goto(item) {
      this.item = item;
    }

    /**
     * Move to a specific item if possible, by uid.
     * @param {string} uid - The uid of a specific item.
     * @returns {boolean} - Was able to goto the item.
     */
    gotoUid(uid) {
      const me = 'gotoUid';
      this.logger.entered(me, uid);
      const items = this.#getItems();
      const item = items.find(el => uid === this.#uid(el));
      let success = false;
      if (item) {
        this.item = item;
        success = true;
      }
      this.logger.leaving(me, success, item);
      return success;
    }

    /** Adds the registered CSS classes to the current element. */
    shine() {
      this.item?.classList.add(...this.#classes);
    }

    /** Removes the registered CSS classes from the current element. */
    dull() {
      this.item?.classList.remove(...this.#classes);
    }

    /** Bring current item back into view. */
    show() {
      this.#scrollToCurrentItem();
    }

    /**
     * Activate the scroller.
     * @fires 'out-of-range'
     */
    async activate() {
      const me = 'activate';
      this.logger.entered(me);

      const containers = new Set(
        Array.from(await this.#waitForContainers()).filter(x => x)
      );
      if (this.#base) {
        containers.add(this.#base);
      }

      const watcher = this.#currentItemWatcher();

      for (const container of containers) {
        if (this.#handleClicks) {
          this.#onClickElements.add(container);
          container.addEventListener('click',
            this.#onClick,
            this.#clickOptions);
        }
        this.#mutationObserver.observe(container,
          {childList: true, subtree: true});
      }

      this.logger.log('watcher:', await watcher);

      this.dispatcher.fire('activate', null);

      this.logger.leaving(me);
    }

    /**
     * Deactivate the scroller (but do not destroy it).
     * @fires 'out-of-range'
     */
    deactivate() {
      this.#mutationObserver.disconnect();
      for (const container of this.#onClickElements) {
        container.removeEventListener('click',
          this.#onClick,
          this.#clickOptions);
      }
      this.#onClickElements.clear();
      this.dispatcher.fire('deactivate', null);
    }

    /** Mark instance as inactive and do any internal cleanup. */
    destroy() {
      const me = 'destroy';
      this.logger.entered(me);
      this.deactivate();
      this.item = null;
      this.#destroyed = true;
      this.logger.leaving(me);
    }

    /**
     * Determines if the item can be viewed.  Usually this means the content
     * is being loaded lazily and is not ready yet.
     * @param {Element} item - The item to inspect.
     * @returns {boolean} - Whether the item has viewable content.
     */
    static #isItemViewable(item) {
      return item.clientHeight && item.innerText.length;
    }

    #autoActivate
    #base
    #bottomMarginCSS
    #bottomMarginPixels
    #classes
    #clickOptions = {capture: true};
    #containerItems
    #containerTimeout
    #currentItemId = null;
    #destroyed = false;

    #dispatcher = new NH.base.Dispatcher(
      'change', 'out-of-range', 'activate', 'deactivate'
    );

    #handleClicks
    #historicalIdToIndex = new Map();
    #logger
    #mutationDispatcher = new NH.base.Dispatcher('records');
    #mutationObserver
    #name
    #onClickElements = new Set();
    #selectors
    #snapToTop
    #stackTrace
    #topMarginCSS
    #topMarginPixels
    #uidCallback
    #waitForItemTimeout

    /**
     * If an item is clicked, switch to it.
     * @param {Event} evt - Standard 'click' event.
     */
    #onClick = (evt) => {
      const me = 'onClick';
      this.logger.entered(me, evt);
      for (const item of this.#getItems()) {
        if (item.contains(evt.target)) {
          this.logger.log('found:', item);
          if (item !== this.item) {
            this.item = item;
          }
        }
      }
      this.logger.leaving(me);
    }

    /** @param {MutationRecord[]} records - Standard mutation records. */
    #mutationHandler = (records) => {
      const me = 'mutationHandler';
      this.logger.entered(
        me, `records: ${records.length} type: ${records[0].type}`
      );
      this.#mutationDispatcher.fire('records', null);
      for (const record of records) {
        if (record.type === 'childList') {
          this.logger.log('childList record');
        } else if (record.type === 'attributes') {
          this.logger.log('attribute records');
        }
      }
      this.logger.leaving(me);
    }

    /**
     * Since the getter will try to validate the current item (since it could
     * have changed out from under us), it too can update information.
     * @param {Element} val - Element to make current.
     */
    #bottomHalf = (val) => {
      const me = 'bottomHalf';
      this.logger.entered(me, val);
      this.#currentItemId = this.#uid(val);
      const idx = this.#getItems().indexOf(val);
      this.#historicalIdToIndex.set(this.#currentItemId, idx);
      this.shine();
      this.#scrollToCurrentItem();
      this.dispatcher.fire('change', {});
      this.logger.leaving(me);
    }

    /**
     * Builds the list of elements using the registered CSS selectors.
     * @returns {Elements[]} - Items to scroll through.
     */
    #getItems = () => {
      const me = 'getItems';
      this.logger.entered(me);
      const items = [];
      if (this.#base) {
        for (const selector of this.#selectors) {
          this.logger.log(`considering ${selector}`);
          items.push(...this.#base.querySelectorAll(selector));
        }
      } else {
        for (const {container, items: selector} of this.#containerItems) {
          this.logger.log(`considering ${container} with ${selector}`);
          const base = document.querySelector(container);
          if (base) {
            items.push(...base.querySelectorAll(selector));
          }
        }
      }
      this.logger.starting('items');
      for (const item of items) {
        this.logger.log('item:', item);
      }
      this.logger.finished('items');

      this.logger.leaving(me, `${items.length} items`);
      return items;
    }

    /**
     * Returns the uid for the current element.  Will use the registered
     * uidCallback function for this.
     * @param {Element} element - Element to identify.
     * @returns {string} - Computed uid for element.
     */
    #uid = (element) => {
      const me = 'uid';
      this.logger.entered(me, element);
      let uid = null;
      if (element) {
        if (!element.dataset.scrollerId) {
          element.dataset.scrollerId = this.#uidCallback(element);
        }
        uid = element.dataset.scrollerId;
      }
      this.logger.leaving(me, uid);
      return uid;
    }

    /**
     * Checks if the element is the current one.  Useful as a callback to
     * Array.find.
     * @param {Element} element - Element to check.
     * @returns {boolean} - Whether or not element is the current one.
     */
    #matchItem = (element) => {
      const me = 'matchItem';
      this.logger.entered(me);
      const res = this.#currentItemId === this.#uid(element);
      this.logger.leaving(me, res);
      return res;
    }

    /**
     * Scroll the current item into the view port.  Depending on the instance
     * configuration, this could snap to the top, snap to the bottom, or be a
     * no-op.
     */
    #scrollToCurrentItem = () => {
      const me = 'scrollToCurrentItem';
      this.logger.entered(me, `snaptoTop: ${this.#snapToTop}`);
      const {item} = this;
      if (item) {
        item.style.scrollMarginTop = this.#topMarginCSS;
        if (this.#snapToTop) {
          this.logger.log('snapping to top');
          item.scrollIntoView(true);
        } else {
          this.logger.log('not snapping to top');
          item.style.scrollMarginBottom = this.#bottomMarginCSS;
          const rect = item.getBoundingClientRect();
          // If both scrolling happens, it means the item is too tall to fit
          // on the page, so the top is preferred.
          const allowedBottom = document.documentElement.clientHeight -
                this.#bottomMarginPixels;
          if (rect.bottom > allowedBottom) {
            this.logger.log('scrolling up onto page');
            item.scrollIntoView(false);
          }
          if (rect.top < this.#topMarginPixels) {
            this.logger.log('scrolling down onto page');
            item.scrollIntoView(true);
          }
          // XXX: The following was added to support horizontal scrolling in
          // carousels.  Nothing seemed to break.  TODO(#132): Did find a side
          // effect though: it can cause an item being *left* to shift up if
          // the scrollMarginBottom has been set.
          item.scrollIntoView({block: 'nearest', inline: 'nearest'});
        }
      }
      this.logger.leaving(me);
    }

    /**
     * Jump an item on an end of the collection.
     * @param {boolean} first - If true, the first item in the collection,
     * else, the last.
     */
    #jumpToEndItem = (first) => {
      const me = 'jumpToEndItem';
      this.logger.entered(me, `first=${first}`);
      // Reset in case item was heavily modified
      this.item = this.item;

      const items = this.#getItems();
      if (items.length) {
        // eslint-disable-next-line no-extra-parens
        let idx = first ? 0 : (items.length - 1);
        let item = items[idx];

        // Content of items is sometimes loaded lazily and can be detected by
        // having no innerText yet.  So start at the end and work our way up
        // to the last one loaded.
        if (!first) {
          while (!Scroller.#isItemViewable(item)) {
            this.logger.log('skipping item', item);
            idx -= 1;
            item = items[idx];
          }
        }
        this.item = item;
      }
      this.logger.leaving(me);
    }

    /**
     * Move forward or backwards in the collection by at least n.
     * @param {number} n - How many items to move and the intended direction.
     * @fires 'out-of-range'
     */
    #scrollBy = (n) => {  // eslint-disable-line max-statements
      const me = 'scrollBy';
      this.logger.entered(me, n);
      // Reset in case item was heavily modified
      this.item = this.item;

      const items = this.#getItems();
      if (items.length) {
        let idx = items.findIndex(this.#matchItem);
        this.logger.log('initial idx', idx);
        idx += n;
        if (idx < NH.base.NOT_FOUND) {
          idx = items.length - 1;
        }
        if (idx === NH.base.NOT_FOUND || idx >= items.length) {
          this.item = null;
          this.dispatcher.fire('out-of-range', null);
        } else {
          // Skip over empty items
          let item = items[idx];
          while (!Scroller.#isItemViewable(item)) {
            this.logger.log('skipping item', item);
            idx += n;
            item = items[idx];
          }
          this.logger.log('final idx', idx);
          this.item = item;
        }
      }
      this.logger.leaving(me);
    }

    /** @throws {Scroller.Error} - On many validation issues. */
    #validateInstance = () => {

      if (this.#base && this.#containerItems.length) {
        throw new Scroller.Error(
          `Cannot have both base AND containerItems: ${this.#name} has both`
        );
      }

      if (!this.#base && !this.#containerItems.length) {
        throw new Scroller.Error(
          `Needs either base OR containerItems: ${this.#name} has neither`
        );
      }

      if (this.#base && !(this.#base instanceof Element)) {
        throw new Scroller.Error(
          `Not an element: base ${this.#base} given for ${this.#name}`
        );
      }

      if (this.#base && !this.#selectors) {
        throw new Scroller.Error(
          `No selectors: ${this.#name} is missing selectors`
        );
      }

      if (this.#selectors && !this.#base) {
        throw new Scroller.Error(
          `No base: ${this.#name} is using selectors and so needs a base`
        );
      }

      if (!this.#uidCallback) {
        throw new Scroller.Error(
          `Missing uidCallback: ${this.#name} has no uidCallback defined`
        );
      }

      if (!(this.#uidCallback instanceof Function)) {
        throw new Scroller.Error(
          `Invalid uidCallback: ${this.#name} uidCallback is not a function`
        );
      }

    }

    /**
     * The page may still be loading, so wait for many things to settle.
     * @returns {Promise<Element[]>} - All the new base elements.
     */
    #waitForContainers = () => {
      const me = 'waitForContainers';
      this.logger.entered(me);

      const results = [];

      /**
       * Simply eats any exception throw by the Promise.
       * @param {Promise} prom - Whatever Promise we are wrapping.
       * @param {string} note - Put into log on error.
       * @returns {Promise} - Resolved promise.
       */
      const wrapper = async (prom, note) => {
        this.logger.log('wrapping', prom);
        try {
          return await prom;
        } catch (e) {
          this.logger.log(`wrapper ate error (${note}):`, e);
          return Promise.resolve();
        }
      };

      for (const {container} of this.#containerItems) {
        results.push(wrapper(NH.web.waitForSelector(container,
          this.#containerTimeout), container));
      }

      this.logger.leaving(me, results);
      return Promise.all(results);
    }

    /**
     * Watches for the current item, if there was one, to return.
     *
     * Used during activation to deal with items still being loaded.
     *
     * TODO(#150): This is a good start but needs more work.  Hooking into the
     * MutationObserver seemed like a good idea, but in practice, we only get
     * invoked once, then time out.  Likely the observe options need some
     * tweaking.  Will need to balance between what we do on activation as
     * well as long term monitoring (which is not being done yet anyway).
     * Also note the call to Scroller.#isItemViewable, a direct nod to what
     * Feed needs to do.
     *
     * @returns {Promise<string>} - Wait on this to finish with something
     * useful to log.
     */
    #currentItemWatcher = () => {
      const me = 'currentItemWatcher';
      this.logger.entered(me);

      const uid = this.itemUid;
      let prom = Promise.resolve('nothing to watch for');

      if (uid) {
        this.logger.log('reactivation with', uid);
        let timeoutID = null;

        prom = new Promise((resolve) => {

          /** Dispatcher monitor. */
          const moCallback = () => {
            this.logger.log('moCallback');
            if (this.gotoUid(uid)) {
              this.logger.log('item is present', this.item);
              if (Scroller.#isItemViewable(this.item)) {
                this.logger.log('and viewable');
                this.#mutationDispatcher.off('records', moCallback);
                clearTimeout(timeoutID);
                resolve('looks good');
              } else {
                this.logger.log('but not yet viewable');
              }
            } else {
              this.logger.log('not ready yet');
            }
          };

          /** Standard setTimeout callback. */
          const toCallback = () => {
            this.#mutationDispatcher.off('records', moCallback);
            this.logger.log('one last try...');
            moCallback();
            resolve('we tried...');
          };

          this.#mutationDispatcher.on('records', moCallback);
          timeoutID = setTimeout(toCallback, this.#waitForItemTimeout);
          moCallback();
        });
      }

      this.logger.leaving(me, prom);
      return prom;
    }

  }

  /* eslint-disable class-methods-use-this */
  /* eslint-disable no-empty-function */
  /* eslint-disable no-new */
  /* eslint-disable require-jsdoc */
  class ScrollerTestCase extends NH.xunit.TestCase {

    testNeedsBaseOrContainerItems() {
      const what = {
        name: this.id,
      };
      const how = {
      };

      this.assertRaisesRegExp(
        Scroller.Error,
        /Needs either base OR containerItems:/u,
        () => {
          new Scroller(what, how);
        }
      );
    }

    testNotBaseAndContainerItems() {
      const what = {
        name: this.id,
        base: document.body,
        containerItems: [{}],
      };
      const how = {
      };

      this.assertRaisesRegExp(
        Scroller.Error,
        /Cannot have both base AND containerItems:/u,
        () => {
          new Scroller(what, how);
        }
      );
    }

    testBaseIsElement() {
      const what = {
        name: this.id,
        base: document,
      };
      const how = {
      };

      this.assertRaisesRegExp(
        Scroller.Error,
        /Not an element:/u,
        () => {
          new Scroller(what, how);
        }
      );
    }

    testBaseNeedsSelector() {
      const what = {
        name: this.id,
        base: document.body,
      };
      const how = {
      };

      this.assertRaisesRegExp(
        Scroller.Error,
        /No selectors:/u,
        () => {
          new Scroller(what, how);
        }
      );
    }

    testSelectorNeedsBase() {
      const what = {
        name: this.id,
        selectors: [],
        containerItems: [{}],
      };
      const how = {
      };

      this.assertRaisesRegExp(
        Scroller.Error,
        /No base:/u,
        () => {
          new Scroller(what, how);
        }
      );
    }

    testBaseWithSelectorIsFine() {
      const what = {
        name: this.id,
        base: document.body,
        selectors: [],
      };
      const how = {
        uidCallback: () => {},
      };

      new Scroller(what, how);
    }

    testValidUidCallback() {
      const what = {
        name: this.id,
        base: document.body,
        selectors: [],
      };
      const how = {
      };

      this.assertRaisesRegExp(
        Scroller.Error,
        /Missing uidCallback:/u,
        () => {
          new Scroller(what, how);
        },
        'missing',
      );

      how.uidCallback = {};

      this.assertRaisesRegExp(
        Scroller.Error,
        /Invalid uidCallback:/u,
        () => {
          new Scroller(what, how);
        },
        'invalid',
      );

      how.uidCallback = () => {};

      // And finally, good.
      new Scroller(what, how);
    }

  }
  /* eslint-enable */

  NH.xunit.testing.testCases.push(ScrollerTestCase);

  /**
   * This class exists solely to avoid some `no-use-before-define` linter
   * issues.
   */
  class LinkedInGlobals {

    /** @type {string} - LinkedIn's common aside used in many layouts. */
    static get asideSelector() {
      return this.#asideSelector;
    }

    /** @type {string} - LinkedIn's common sidebar used in many layouts. */
    static get sidebarSelector() {
      return this.#sidebarSelector;
    }

    /** @type {string} - The height of the navbar as CSS string. */
    get navBarHeightCSS() {
      return `${this.#navBarHeightPixels}px`;
    }

    /** @type {number} - The height of the navbar in pixels. */
    get navBarHeightPixels() {
      return this.#navBarHeightPixels;
    }

    /** @param {number} val - Set height of the navbar in pixels. */
    set navBarHeightPixels(val) {
      this.#navBarHeightPixels = val;
    }

    /** Scroll common sidebar into view and move focus to it. */
    focusOnSidebar = () => {
      const sidebar = document.querySelector(LinkedInGlobals.sidebarSelector);
      if (sidebar) {
        sidebar.style.scrollMarginTop = this.navBarHeightCSS;
        sidebar.scrollIntoView();
        NH.web.focusOnElement(sidebar);
      }
    }

    /**
     * Scroll common aside (right-hand sidebar) into view and move focus to
     * it.
     */
    focusOnAside = () => {
      const aside = document.querySelector(LinkedInGlobals.asideSelector);
      if (aside) {
        aside.style.scrollMarginTop = this.navBarHeightCSS;
        aside.scrollIntoView();
        NH.web.focusOnElement(aside);
      }
    }

    /**
     * Create a Greasy Fork project URL.
     * @param {string} path - Portion of the URL.
     * @returns {string} - Full URL.
     */
    gfUrl = (path) => {
      const base = 'https://greasyfork.org/en/scripts/472097-linkedin-tool';
      const url = `${base}/${path}`;
      return url;
    }

    /**
     * Create a GitHub project URL.
     * @param {string} path - Portion of the URL.
     * @returns {string} - Full URL.
     */
    ghUrl = (path) => {
      const base = 'https://github.com/nexushoratio/userscripts';
      const url = `${base}/${path}`;
      return url;
    }

    static #asideSelector = 'aside.scaffold-layout__aside';
    static #sidebarSelector = 'div.scaffold-layout__sidebar';

    #navBarHeightPixels = 0;

  }

  /** A table with collapsible sections. */
  class AccordionTableWidget extends NH.widget.Widget {

    /** @param {string} name - Name for this instance. */
    constructor(name) {
      super(name, 'table');
      this.logger.log(`${this.name} constructed`);
    }

    /**
     * This becomes the current section.
     * @param {string} name - Name of the new section.
     * @returns {Element} - The new section.
     */
    addSection(name) {
      this.#currentSection = document.createElement('tbody');
      this.#currentSection.id = NH.base.safeId(`${this.id}-${name}`);
      this.container.append(this.#currentSection);
      return this.#currentSection;
    }

    /**
     * Add a row of header cells to the current section.
     * @param {...string} items - To make up the row cells.
     */
    addHeader(...items) {
      this.#addRow('th', ...items);
    }

    /**
     * Add a row of data cells to the current section.
     * @param {...string} items - To make up the row cells.
     */
    addData(...items) {
      this.#addRow('td', ...items);
    }

    #currentSection

    /**
     * Add a row to the current section.
     * @param {string} type - Cell type, typically 'td' or 'th'.
     * @param {...string} items - To make up the row cells.
     */
    #addRow = (type, ...items) => {
      const tr = document.createElement('tr');
      for (const item of items) {
        const cell = document.createElement(type);
        cell.innerHTML = item;
        tr.append(cell);
      }
      this.container.append(tr);
    }

  }

  /**
   * Self-decorating class useful for integrating with a hotkey service.
   *
   * @example
   * // Wrap an arrow function:
   * foo = new Shortcut('c-c', 'Clear the console.', () => {
   *   console.clear();
   *   console.log('I did it!', this);
   * });
   *
   * // Search for instances:
   * const keys = [];
   * for (const prop of Object.values(this)) {
   *   if (prop instanceof Shortcut) {
   *     keys.push({seq: prop.seq, desc: prop.seq, func: prop});
   *   }
   * }
   * ... Send keys off to service ...
   */
  class Shortcut extends Function {

    /**
     * Wrap a function.
     * @param {string} seq - Key sequence to activate this function.
     * @param {string} desc - Human readable documenation about this function.
     * @param {SimpleFunction} func - Function to wrap, usually in the form of
     * an arrow function.  Keep JS `this` magic in mind!
     */
    constructor(seq, desc, func) {
      super('return this.func();');
      const self = this.bind(this);
      self.seq = seq;
      self.desc = desc;
      this.func = func;
      return self;
    }

  }

  /**
   * Base class for building services to go with {@link SPA}.
   *
   * This should be subclassed to implement services that instances of {@link
   * Page} will instantiate, initialize, active and deactivate at appropriate
   * times.
   *
   * It is expected that each {Page} subclass will have individual instances
   * of the services, though nothing will enforce that.
   *
   * @example
   * class DummyService extends Service {
   * ... implement methods ...
   * }
   *
   * class CustomPage extends Page {
   *   constructor() {
   *     this.addService(DummyService);
   *   }
   * }
   */
  class Service {

    /** @param {string} name - Custom portion of this instance. */
    constructor(name) {
      if (new.target === Service) {
        throw new TypeError('Abstract class; do not instantiate directly.');
      }
      this.#name = `${this.constructor.name}: ${name}`;
      this.#shortName = name;
      this.#logger = new NH.base.Logger(this.#name);
    }

    /** @type {NH.base.Logger} - NH.base.Logger instance. */
    get logger() {
      return this.#logger;
    }

    /** @type {string} - Instance name. */
    get name() {
      return this.#name;
    }

    /** @type {string} - Shorter instance name. */
    get shortName() {
      return this.#shortName;
    }

    /** Called each time service is activated. */
    activate() {
      this.#notImplemented('activate');
    }

    /** Called each time service is deactivated. */
    deactivate() {
      this.#notImplemented('deactivate');
    }

    #logger
    #name
    #shortName

    /** @param {string} name - Name of method that was not implemented. */
    #notImplemented(name) {
      const msg = `Class ${this.constructor.name} did not implement ` +
            `method "${name}".`;
      this.logger.log(msg);
      throw new Error(msg);
    }

  }

  /** Manage a {Scroller} via {Service}. */
  class ScrollerService extends Service {

    /**
     * @param {string} name - Custom portion of this instance.
     * @param {Scroller} scroller - Scroller instance to manage.
     */
    constructor(name, scroller) {
      super(name);
      this.#scroller = scroller;
    }

    /** @inheritdoc */
    activate() {
      this.#scroller.activate();
    }

    /** @inheritdoc */
    deactivate() {
      this.#scroller.deactivate();
    }

    #scroller

  }

  /**
   * @external VMShortcuts
   * @see {@link https://violentmonkey.github.io/guide/keyboard-shortcuts/}
   */

  /**
   * Integrates {@link external:VMShortcuts} with {@link Shortcut}s.
   *
   * NB {Shortcut} was designed to work natively with {external:VMShortcuts},
   * but there should be no known technical reason preventing other
   * implementations from being used, would have have to write a different
   * service.
   *
   * Instances of classes that have {@link Shortcut} properties on them can be
   * added and removed to each instance of this service.  The shortcuts will
   * be enabled and disabled as the service is activated/deactived.  This can
   * allow each service to have different groups of shortcuts present.
   *
   * All Shortcuts can react to VM.shortcut style conditions.  These
   * conditions are added once during each call to addService(), and default
   * to '!inputFocus'.
   *
   * The built in handler for 'inputFocus' can be enabled by executing:
   *
   * @example
   * VMKeyboardService.start();
   */
  class VMKeyboardService extends Service {

    /** @inheritdoc */
    constructor(name) {
      super(name);
      VMKeyboardService.#services.add(this);
    }

    static keyMap = new Map([
      ['LEFT', '←'],
      ['UP', '↑'],
      ['RIGHT', '→'],
      ['DOWN', '↓'],
    ]);

    /** @param {string} val - New condition. */
    static set condition(val) {
      this.#navOption.condition = val;
    }

    /** @type {Set<VMKeyboardService>} - Instantiated services. */
    static get services() {
      return new Set(this.#services.values());
    }

    /** Add listener. */
    static start() {
      document.addEventListener('focus', this.#onFocus, this.#focusOption);
    }

    /** Remove listener. */
    static stop() {
      document.removeEventListener('focus', this.#onFocus, this.#focusOption);
    }

    /**
     * Set the keyboard context to a specific value.
     * @param {string} context - The name of the context.
     * @param {object} state - What the value should be.
     */
    static setKeyboardContext(context, state) {
      for (const service of this.#services) {
        for (const keyboard of service.#keyboards.values()) {
          keyboard.setContext(context, state);
        }
      }
    }

    /**
     * Parse a {@link Shortcut.seq} and wrap it in HTML.
     * @example
     * 'a c-b' ->
     *   '<kbd><kbd>a</kbd> then <kbd>Ctrl</kbd> + <kbd>b</kbd></kbd>'
     * @param {Shortcut.seq} seq - Keystroke sequence.
     * @returns {string} - Appropriately wrapped HTML.
     */
    static parseSeq(seq) {

      /**
       * Convert a VM.shortcut style into an HTML snippet.
       * @param {IShortcutKey} key - A particular key press.
       * @returns {string} - HTML snippet.
       */
      function reprKey(key) {
        if (key.base.length === 1) {
          if ((/\p{Uppercase_Letter}/u).test(key.base)) {
            key.base = key.base.toLowerCase();
            key.modifierState.s = true;
          }
        } else {
          key.base = key.base.toUpperCase();
          const mapped = VMKeyboardService.keyMap.get(key.base);
          if (mapped) {
            key.base = mapped;
          }
        }
        const sequence = [];
        if (key.modifierState.c) {
          sequence.push('Ctrl');
        }
        if (key.modifierState.a) {
          sequence.push('Alt');
        }
        if (key.modifierState.s) {
          sequence.push('Shift');
        }
        sequence.push(key.base);
        return sequence.map(c => `<kbd>${c}</kbd>`).join('+');
      }
      const res = VM.shortcut.normalizeSequence(seq, true)
        .map(key => reprKey(key))
        .join(' then ');
      return `<kbd>${res}</kbd>`;
    }

    /** @type {boolean} */
    get active() {
      return this.#active;
    }

    /** @type {Shortcut[]} - Well, seq and desc properties only. */
    get shortcuts() {
      return this.#shortcuts;
    }

    /** @inheritdoc */
    activate() {
      for (const keyboard of this.#keyboards.values()) {
        this.logger.log('would enable keyboard', keyboard);
        // TODO: keyboard.enable();
      }
      this.#active = true;
    }

    /** @inheritdoc */
    deactivate() {
      for (const keyboard of this.#keyboards.values()) {
        this.logger.log('would disable keyboard', keyboard);
        // TODO: keyboard.disable();
      }
      this.#active = false;
    }

    /** @param {*} instance - Object with {Shortcut} properties. */
    addInstance(instance) {
      const me = 'addInstance';
      this.logger.entered(me, instance);
      if (this.#keyboards.has(instance)) {
        this.logger.log('Already registered');
      } else {
        const keyboard = new VM.shortcut.KeyboardService();
        for (const prop of Object.values(instance)) {
          if (prop instanceof Shortcut) {
            // While we are here, give the function a name.
            Object.defineProperty(prop, 'name', {value: name});
            keyboard.register(prop.seq, prop, VMKeyboardService.#navOption);
          }
        }
        this.#keyboards.set(instance, keyboard);
        this.#rebuildShortcuts();
      }
      this.logger.leaving(me);
    }

    /** @param {*} instance - Object with {Shortcut} properties. */
    removeInstance(instance) {
      const me = 'removeInstance';
      this.logger.entered(me, instance);
      if (this.#keyboards.has(instance)) {
        const keyboard = this.#keyboards.get(instance);
        keyboard.disable();
        this.#keyboards.delete(instance);
        this.#rebuildShortcuts();
      } else {
        this.logger.log('Was not registered');
      }
      this.logger.leaving(me);
    }

    static #focusOption = {
      capture: true,
    };

    static #lastFocusedElement = null

    /**
     * @type {VM.shortcut.IShortcutOptions} - Disables keys when focus is on
     * an element or info view.
     */
    static #navOption = {
      condition: '!inputFocus',
      caseSensitive: true,
    };

    static #services = new Set();

    /**
     * Handle focus event to determine if shortcuts should be disabled.
     * @param {Event} evt - Standard 'focus' event.
     */
    static #onFocus = (evt) => {
      if (this.#lastFocusedElement &&
          evt.target !== this.#lastFocusedElement) {
        this.#lastFocusedElement = null;
        this.setKeyboardContext('inputFocus', false);
      }
      if (NH.web.isInput(evt.target)) {
        this.setKeyboardContext('inputFocus', true);
        this.#lastFocusedElement = evt.target;
      }
    }

    #active = false;
    #keyboards = new Map();
    #shortcuts = [];

    #rebuildShortcuts = () => {
      this.#shortcuts = [];
      for (const instance of this.#keyboards.keys()) {
        for (const prop of Object.values(instance)) {
          if (prop instanceof Shortcut) {
            this.#shortcuts.push({seq: prop.seq, desc: prop.desc});
          }
        }
      }
    }

  }

  /* eslint-disable require-jsdoc */
  class ParseSeqTestCase extends NH.xunit.TestCase {

    testNormalInputs() {
      const tests = [
        {text: 'q', expected: '<kbd><kbd>q</kbd></kbd>'},
        {text: 's-q', expected: '<kbd><kbd>Shift</kbd>+<kbd>q</kbd></kbd>'},
        {text: 'Q', expected: '<kbd><kbd>Shift</kbd>+<kbd>q</kbd></kbd>'},
        {text: 'a b', expected: '<kbd><kbd>a</kbd> then <kbd>b</kbd></kbd>'},
        {text: '<', expected: '<kbd><kbd><</kbd></kbd>'},
        {text: 'C-q', expected: '<kbd><kbd>Ctrl</kbd>+<kbd>q</kbd></kbd>'},
        {text: 'c-q', expected: '<kbd><kbd>Ctrl</kbd>+<kbd>q</kbd></kbd>'},
        {text: 'c-a-t',
          expected: '<kbd><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+' +
       '<kbd>t</kbd></kbd>'},
        {text: 'a-c-T',
          expected: '<kbd><kbd>Ctrl</kbd>+<kbd>Alt</kbd>+' +
       '<kbd>Shift</kbd>+<kbd>t</kbd></kbd>'},
        {text: 'c-down esc',
          expected: '<kbd><kbd>Ctrl</kbd>+<kbd>↓</kbd> ' +
       'then <kbd>ESC</kbd></kbd>'},
        {text: 'alt-up tab',
          expected: '<kbd><kbd>Alt</kbd>+<kbd>↑</kbd> ' +
       'then <kbd>TAB</kbd></kbd>'},
        {text: 'shift-X control-alt-del',
          expected: '<kbd><kbd>Shift</kbd>+<kbd>x</kbd> ' +
       'then <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>DEL</kbd></kbd>'},
        {text: 'c-x c-v',
          expected: '<kbd><kbd>Ctrl</kbd>+<kbd>x</kbd> ' +
       'then <kbd>Ctrl</kbd>+<kbd>v</kbd></kbd>'},
        {text: 'a-x enter',
          expected: '<kbd><kbd>Alt</kbd>+<kbd>x</kbd> ' +
       'then <kbd>ENTER</kbd></kbd>'},
      ];
      for (const {text, expected} of tests) {
        this.assertEqual(VMKeyboardService.parseSeq(text), expected);
      }
    }

    testKonamiCode() {
      this.assertEqual(VMKeyboardService.parseSeq(
        'up up down down left right left right b shift-a enter'
      ),
      '<kbd><kbd>↑</kbd> then <kbd>↑</kbd> then <kbd>↓</kbd> ' +
      'then <kbd>↓</kbd> then <kbd>←</kbd> then <kbd>→</kbd> ' +
      'then <kbd>←</kbd> then <kbd>→</kbd> then <kbd>b</kbd> ' +
      'then <kbd>Shift</kbd>+<kbd>a</kbd> then <kbd>ENTER</kbd></kbd>');
    }

  }
  /* eslint-enable */

  NH.xunit.testing.testCases.push(ParseSeqTestCase);

  /**
   * Base class for handling various views of a single-page application.
   *
   * Generally, new classes should subclass this, override a few properties
   * and methods, and then register themselves with an instance of the {@link
   * SPA} class.
   */
  class Page {

    /**
     * @typedef {object} PageDetails
     * @property {SPA} spa - SPA instance that manages this Page.
     * @property {string|RegExp} [pathname=RegExp(.*)] - Pathname portion of
     * the URL this page should handle.
     * @property {string} [pageReadySelector='body'] - CSS selector that is
     * used to detect that the page is loaded enough to activate.
     */

    /** @param {PageDetails} details - Details about the instance. */
    constructor(details = {}) {
      if (new.target === Page) {
        throw new TypeError('Abstract class; do not instantiate directly.');
      }
      this.#spa = details.spa;
      this.#logger = new NH.base.Logger(this.constructor.name);
      this.#pathnameRE = this.#computePathname(details.pathname);
      ({
        pageReadySelector: this.#pageReadySelector = 'body',
      } = details);
      this.#logger.log('Base page constructed', this);
    }

    /** @type {Shortcut[]} - List of {@link Shortcut}s to register. */
    get allShortcuts() {
      const shortcuts = [];
      for (const prop of Object.values(this)) {
        if (prop instanceof Shortcut) {
          shortcuts.push(prop);
          // While we are here, give the function a name.
          Object.defineProperty(prop, 'name', {value: name});
        }
      }
      return shortcuts;
    }

    /** @type {string} - Describes what the header should be. */
    get infoHeader() {
      return this.constructor.name;
    }

    /** @type {KeyboardService} */
    get keyboard() {
      return this.#keyboard;
    }

    /** @type {NH.base.Logger} */
    get logger() {
      return this.#logger;
    }

    /** @type {RegExp} */
    get pathname() {
      return this.#pathnameRE;
    }

    /** @type {SPA} */
    get spa() {
      return this.#spa;
    }

    /**
     * Register a new {@link Service}.
     * @param {function(): Service} Klass - A service class to instantiate.
     * @param {...*} rest - Arbitrary objects to pass to constructor.
     * @returns {Service} - Instance of Klass.
     */
    addService(Klass, ...rest) {
      const me = 'addService';
      let instance = null;
      this.logger.entered(me, Klass, ...rest);
      if (Klass.prototype instanceof Service) {
        instance = new Klass(this.constructor.name, ...rest);
        this.#services.add(instance);
      } else {
        this.logger.log('Bad class was passed.');
        throw new Error(`${Klass.name} is not a Service`);
      }
      this.logger.leaving(me, instance);
      return instance;
    }

    /**
     * Called when registered via {@link SPA}.
     */
    start() {
      for (const shortcut of this.allShortcuts) {
        this.#addKey(shortcut);
      }
    }

    /**
     * Turns on this Page's features.  Called by {@link SPA} when this becomes
     * the current view.
     */
    async activate() {
      this.#keyboard.enable();
      await this.#waitUntilReady();
      for (const service of this.#services) {
        service.activate();
      }
    }

    /**
     * Turns off this Page's features.  Called by {@link SPA} when this is no
     * longer the current view.
     */
    deactivate() {
      this.#keyboard.disable();
      for (const service of this.#services) {
        service.deactivate();
      }
    }

    /**
     * @type {IShortcutOptions} - Disables keys when focus is on an element or
     * info view.
     */
    static #navOption = {
      caseSensitive: true,
      condition: '!inputFocus && !inDialog',
    };

    /** @type {KeyboardService} */
    #keyboard = new VM.shortcut.KeyboardService();

    /** @type {NH.base.Logger} - NH.base.Logger instance. */
    #logger

    #pageReadySelector

    /** @type {RegExp} - Computed RegExp version of details.pathname. */
    #pathnameRE

    #services = new Set();

    /** @type {SPA} - SPA instance managing this instance. */
    #spa

    /**
     * Turn a pathname into a RegExp.
     * @param {string|RegExp} pathname - A pathname to convert.
     * @returns {RegExp} - A converted pathname.
     */
    #computePathname = (pathname) => {
      const me = 'computePath';
      this.logger.entered(me, pathname);
      let pathnameRE = /.*/u;
      if (pathname instanceof RegExp) {
        pathnameRE = pathname;
      } else if (pathname) {
        pathnameRE = RegExp(`^${pathname}$`, 'u');
      }
      this.logger.leaving(me, pathnameRE);
      return pathnameRE;
    }

    /**
     * Wait until the page has loaded enough to continue.
     * @returns {Element} - The element matched by #pageReadySelector.
     */
    #waitUntilReady = async () => {
      const me = 'waitUntilReady';
      this.logger.entered(me);

      this.logger.log('pageReadySelector:', this.#pageReadySelector);
      const element = await NH.web.waitForSelector(
        this.#pageReadySelector, 0
      );
      this.logger.leaving(me, element);

      return element;
    }

    /**
     * Registers a specific key sequence with a function with VM.shortcut.
     * @param {Shortcut} shortcut - Shortcut to register.
     */
    #addKey = (shortcut) => {
      this.#keyboard.register(shortcut.seq, shortcut, Page.#navOption);
    }

  }

  /** Class for holding keystrokes that simplify debugging. */
  class DebugKeys {

    clearConsole = new Shortcut('c-c c-c', 'Clear the debug console', () => {
      NH.base.Logger.clear();
    });

  }

  const linkedInGlobals = new LinkedInGlobals();

  /**
   * Class for handling aspects common across LinkedIn.
   *
   * This includes things like the global nav bar, information view, etc.
   */
  class Global extends Page {

    /**
     * Create a Global instance.
     * @param {SPA} spa - SPA instance that manages this Page.
     */
    constructor(spa) {
      super({spa: spa});

      this.#keyboardService = this.addService(VMKeyboardService);
      this.#keyboardService.addInstance(this);
      if (litOptions.enableDevMode) {
        this.#keyboardService.addInstance(new DebugKeys());
      }
    }

    info = new Shortcut('?', 'Show this information view', () => {
      Global.#gotoNavButton('Tool');
    });

    gotoSearch = new Shortcut('/', 'Go to Search box', () => {
      NH.web.clickElement(document, ['#global-nav-search button']);
    });

    goHome = new Shortcut('g h', 'Go Home (aka, Feed)', () => {
      Global.#gotoNavLink('feed');
    });

    gotoMyNetwork = new Shortcut('g m', 'Go to My Network', () => {
      Global.#gotoNavLink('mynetwork');
    });

    gotoJobs = new Shortcut('g j', 'Go to Jobs', () => {
      Global.#gotoNavLink('jobs');
    });

    gotoMessaging = new Shortcut('g g', 'Go to Messaging', () => {
      Global.#gotoNavLink('messaging');
    });

    gotoNotifications = new Shortcut('g n', 'Go to Notifications', () => {
      Global.#gotoNavLink('notifications');
    });

    gotoProfile = new Shortcut('g p', 'Go to Profile (aka, Me)', () => {
      Global.#gotoNavButton('Me');
    });

    gotoBusiness = new Shortcut('g b', 'Go to Business', () => {
      Global.#gotoNavButton('Business');
    });

    gotoLearning = new Shortcut('g l', 'Go to Learning', () => {
      Global.#gotoNavLink('learning');
    });

    focusOnSidebar = new Shortcut(
      ',', 'Focus on the left/top sidebar (not always present)', () => {
        linkedInGlobals.focusOnSidebar();
      }
    );

    focusOnAside = new Shortcut(
      '.', 'Focus on the right/bottom sidebar (not always present)', () => {
        linkedInGlobals.focusOnAside();
      }
    );

    /**
     * Click on the requested link in the global nav bar.
     * @param {string} item - Portion of the link to match.
     */
    static #gotoNavLink = (item) => {
      NH.web.clickElement(document, [`#global-nav a[href*="/${item}"`]);
    }

    /**
     * Click on the requested button in the global nav bar.
     * @param {string} item - Text on the button to look for.
     */
    static #gotoNavButton = (item) => {
      const buttons = Array.from(
        document.querySelectorAll('#global-nav button')
      );
      const button = buttons.find(el => el.textContent.includes(item));
      button?.click();
    }

    #keyboardService

  }

  /** Class for handling the Posts feed. */
  class Feed extends Page {

    /**
     * Create a Feed instance.
     * @param {SPA} spa - SPA instance that manages this Page.
     */
    constructor(spa) {
      super({spa: spa, ...Feed.#details});

      this.#keyboardService = this.addService(VMKeyboardService);
      this.#keyboardService.addInstance(this);

      spa.details.navBarScrollerFixup(Feed.#postsHow);
      spa.details.navBarScrollerFixup(Feed.#commentsHow);

      this.#postScroller = new Scroller(Feed.#postsWhat, Feed.#postsHow);
      this.addService(ScrollerService, this.#postScroller);
      this.#postScroller.dispatcher.on(
        'out-of-range', linkedInGlobals.focusOnSidebar
      );
      this.#postScroller.dispatcher.on('activate', this.#onPostActivate);
      this.#postScroller.dispatcher.on('change', this.#onPostChange);

      this.#lastScroller = this.#postScroller;
    }

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueIdentifier(element) {
      if (element) {
        return element.dataset.id;
      }
      return null;
    }

    /** @type {Scroller} */
    get comments() {
      const me = 'get comments';
      this.logger.entered(me, this.#commentScroller, this.posts.item);

      if (!this.#commentScroller && this.posts.item) {
        this.#commentScroller = new Scroller(
          {base: this.posts.item, ...Feed.#commentsWhat}, Feed.#commentsHow
        );
        this.#commentScroller.dispatcher.on(
          'out-of-range', this.#returnToPost
        );
        this.#commentScroller.dispatcher.on('change', this.#onCommentChange);
      }

      this.logger.leaving(me, this.#commentScroller);
      return this.#commentScroller;
    }

    /** @type {Scroller} */
    get posts() {
      return this.#postScroller;
    }

    nextPost = new Shortcut('j', 'Next post', () => {
      this.posts.next();
    });

    prevPost = new Shortcut('k', 'Previous post', () => {
      this.posts.prev();
    });

    nextComment = new Shortcut('n', 'Next comment', () => {
      this.comments.next();
    });

    prevComment = new Shortcut('p', 'Previous comment', () => {
      this.comments.prev();
    });

    firstItem = new Shortcut('<', 'Go to first post or comment', () => {
      this.#lastScroller.first();
    });

    lastItem = new Shortcut(
      '>', 'Go to last post or comment currently loaded', () => {
        this.#lastScroller.last();
      }
    );

    focusBrowser = new Shortcut(
      'f', 'Change browser focus to current item', () => {
        const el = this.#lastScroller.item;
        this.posts.show();
        this.comments?.show();
        NH.web.focusOnElement(el);
      }
    );

    showComments = new Shortcut('c', 'Show comments', () => {
      if (!NH.web.clickElement(this.comments.item,
        ['button.show-prev-replies'])) {
        NH.web.clickElement(this.posts.item,
          ['button[aria-label*="comment"]']);
      }
    });

    seeMore = new Shortcut(
      'm', 'Show more of current post or comment', () => {
        const el = this.#lastScroller.item;
        NH.web.clickElement(el, ['button[aria-label^="see more"]']);
      }
    );

    loadMorePosts = new Shortcut(
      'l',
      'Load more posts (if the <button>New Posts</button> button ' +
        'is available, load those)', () => {
        const savedScrollTop = document.documentElement.scrollTop;
        let first = false;
        const posts = this.posts;

        /** Trigger function for {@link NH.web.otrot2}. */
        function trigger() {
          // The topButton only shows up when the app detects new posts.  In
          // that case, going back to the first post is appropriate.
          const topButton = 'main div.feed-new-update-pill button';
          // If there is not top button, there should always be a button at
          // the bottom the click.
          const botButton = 'main button.scaffold-finite-scroll__load-button';
          if (NH.web.clickElement(document, [topButton])) {
            first = true;
          } else {
            NH.web.clickElement(document, [botButton]);
          }
        }

        /** Action function for {@link NH.web.otrot2}. */
        function action() {
          if (first) {
            if (posts.item) {
              posts.first();
            }
          } else {
            document.documentElement.scrollTop = savedScrollTop;
          }
        }

        const what = {
          name: 'loadMorePosts',
          base: document.querySelector('div.scaffold-finite-scroll__content'),
        };
        const how = {
          trigger: trigger,
          action: action,
          duration: 2000,
        };
        NH.web.otrot2(what, how);
      }
    );

    viewPost = new Shortcut('v p', 'View current post directly', () => {
      const post = this.posts.item;
      if (post) {
        const urn = post.dataset.id;
        const id = `lt-${urn.replaceAll(':', '-')}`;
        let a = post.querySelector(`#${id}`);
        if (!a) {
          a = document.createElement('a');
          a.href = `/feed/update/${urn}/`;
          a.id = id;
          post.append(a);
        }
        a.click();
      }
    });

    viewReactions = new Shortcut(
      'v r', 'View reactions on current post or comment', () => {
        const el = this.#lastScroller.item;
        const selector = [
          // Button on a comment
          'button.comments-comment-social-bar__reactions-count',
          // Original button on a post
          'button.feed-shared-social-action-bar-counts',
          // Possibly new button on a post
          'button.social-details-social-counts__count-value',
        ].join(',');
        NH.web.clickElement(el, [selector]);
      }
    );

    viewReposts = new Shortcut(
      'v R', 'View reposts of current post', () => {
        NH.web.clickElement(this.posts.item,
          ['button[aria-label*="repost"]']);
      }
    );

    openMeatballMenu = new Shortcut(
      '=',
      'Open closest <button class="spa-meatball">⋯</button> menu',
      () => {
        // XXX: In this case, the identifier is on an svg element, not the
        // button, so use the parentElement.  When Firefox [fully
        // supports](https://bugzilla.mozilla.org/show_bug.cgi?id=418039) the
        // `:has()` pseudo-selector, we can probably use that and use
        // `NH.web.clickElement()`.
        const el = this.#lastScroller.item;
        const selector = [
          // Comment variant
          '[aria-label^="Open options"]',
          // Original post variant
          '[aria-label^="Open control menu"]',
          // Maybe new post variant
          '[a11y-text^="Open control menu"]',
        ].join(',');
        const button = el.querySelector(selector).parentElement;
        button?.click();
      }
    );

    likeItem = new Shortcut('L', 'Like current post or comment', () => {
      const el = this.#lastScroller.item;
      NH.web.clickElement(el, ['button[aria-label^="Open reactions menu"]']);
    });

    commentOnItem = new Shortcut(
      'C', 'Comment on current post or comment', () => {
        // Order of the queries matters here.  If a post has visible comments,
        // the wrong button could be selected.
        NH.web.clickElement(this.#lastScroller.item, [
          'button[aria-label^="Comment"]',
          'button[aria-label^="Reply"]',
        ]);
      }
    );

    repost = new Shortcut('R', 'Repost current post', () => {
      const el = this.posts.item;
      NH.web.clickElement(el, ['button.social-reshare-button']);
    });

    sendPost = new Shortcut('S', 'Send current post privately', () => {
      const el = this.posts.item;
      NH.web.clickElement(el, ['button.send-privately-button']);
    });

    gotoShare = new Shortcut(
      'P',
      `Go to the share box to start a post or ${Feed.#tabSnippet} ` +
        'to the other creator options',
      () => {
        const share = document.querySelector(
          'div.share-box-feed-entry__top-bar'
        ).parentElement;
        share.style.scrollMarginTop = linkedInGlobals.navBarHeightCSS;
        share.scrollIntoView();
        share.querySelector('button').focus();
      }
    );

    togglePost = new Shortcut('X', 'Toggle hiding current post', () => {
      NH.web.clickElement(
        this.posts.item,
        [
          'button[aria-label^="Dismiss post"]',
          'button[aria-label^="Undo and show"]',
        ]
      );
    });

    nextPostPlus = new Shortcut(
      'J', 'Toggle hiding then next post', async () => {

        /** Trigger function for {@link NH.web.otrot}. */
        const trigger = () => {
          this.togglePost();
          this.nextPost();
        };
        // XXX: Need to remove the highlights before NH.web.otrot sees it
        // because it affects the .clientHeight.
        this.posts.dull();
        this.comments?.dull();
        if (this.posts.item) {
          const what = {
            name: 'nextPostPlus',
            base: this.posts.item,
          };
          const how = {
            trigger: trigger,
            timeout: 3000,
          };
          await NH.web.otrot(what, how);
          this.posts.show();
        } else {
          trigger();
        }
      }
    );

    prevPostPlus = new Shortcut(
      'K', 'Toggle hiding then previous post', () => {
        this.togglePost();
        this.prevPost();
      }
    );

    /** @type {Scroller~How} */
    static #commentsHow = {
      uidCallback: Feed.uniqueIdentifier,
      classes: ['dick'],
      autoActivate: true,
      snapToTop: false,
    };

    /** @type {Scroller~What} */
    static #commentsWhat = {
      name: 'Feed comments',
      selectors: ['article.comments-comment-item'],
    };

    /** @type {Page~PageDetails} */
    static #details = {
      pathname: '/feed/',
      pageReadySelector: 'main',
    };

    /** @type {Scroller~How} */
    static #postsHow = {
      uidCallback: Feed.uniqueIdentifier,
      classes: ['tom'],
      snapToTop: true,
    };

    /** @type {Scroller~What} */
    static #postsWhat = {
      name: 'Feed posts',
      containerItems: [
        {
          container: 'main div.scaffold-finite-scroll__content',
          items: 'div[data-id]',
        },
      ],
    };

    static #tabSnippet = VMKeyboardService.parseSeq('tab');

    #commentScroller
    #keyboardService
    #lastScroller
    #postScroller

    #onPostActivate = () => {
      const me = 'onPostActivate';
      this.logger.entered(me);

      /**
       * Wait for the post to be reloaded.
       * @implements {Monitor}
       * @returns {Continuation} - Indicate whether done monitoring.
       */
      const monitor = () => {
        this.logger.log('monitor item classes:', this.posts.item.classList);
        return {
          done: !this.posts.item.classList.contains('has-occluded-height'),
        };
      };
      if (this.posts.item) {
        const what = {
          name: 'Feed onPostActivate',
          base: this.posts.item,
        };
        const how = {
          observeOptions: {
            attributeFilter: ['class'],
            attributes: true,
          },
          monitor: monitor,
          timeout: 5000,
        };
        NH.web.otmot(what, how).finally(() => {
          this.posts.shine();
          this.posts.show();
        });
      }

      this.logger.leaving(me);
    }

    /** Reset the comment scroller. */
    #resetComments = () => {
      if (this.#commentScroller) {
        this.#commentScroller.destroy();
        this.#commentScroller = null;
      }
      this.comments;
    }

    #onCommentChange = () => {
      this.#lastScroller = this.comments;
    }

    /**
     * Reselects current post, triggering same actions as initial selection.
     */
    #returnToPost = () => {
      this.posts.item = this.posts.item;
    }

    /** Resets the comments {@link Scroller}. */
    #onPostChange = () => {
      const me = 'onPostChange';
      this.logger.entered(me, this.posts.item);
      this.#resetComments();
      this.#lastScroller = this.posts;
      this.logger.leaving(me);
    }

  }

  /**
   * Class for handling the base MyNetwork page.
   *
   * This page takes 3-4 seconds to load every time.  Revisits are
   * likely to take a while.
   */
  class MyNetwork extends Page {

    /**
     * Create a MyNetwork instance.
     * @param {SPA} spa - SPA instance that manages this Page.
     */
    constructor(spa) {
      super({spa: spa, ...MyNetwork.#details});

      this.#keyboardService = this.addService(VMKeyboardService);
      this.#keyboardService.addInstance(this);

      spa.details.navBarScrollerFixup(MyNetwork.#sectionsHow);
      spa.details.navBarScrollerFixup(MyNetwork.#cardsHow);

      this.#sectionScroller = new Scroller(MyNetwork.#sectionsWhat,
        MyNetwork.#sectionsHow);
      this.addService(ScrollerService, this.#sectionScroller);
      this.#sectionScroller.dispatcher.on('out-of-range',
        linkedInGlobals.focusOnSidebar);
      this.#sectionScroller.dispatcher.on('change', this.#onSectionChange);

      this.#lastScroller = this.#sectionScroller;
    }

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueSectionIdentifier(element) {
      const h2 = element.querySelector('h2');
      let content = element.innerText;
      if (h2?.innerText) {
        content = h2.innerText;
      }
      return NH.base.strHash(content);
    }

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueCardsIdentifier(element) {
      const content = element.innerText;
      return NH.base.strHash(content);
    }

    /** @type {Scroller} */
    get cards() {
      if (!this.#cardScroller && this.sections.item) {
        this.#cardScroller = new Scroller(
          {base: this.sections.item, ...MyNetwork.#cardsWhat},
          MyNetwork.#cardsHow
        );
        this.#cardScroller.dispatcher.on('change', this.#onCardChange);
        this.#cardScroller.dispatcher.on(
          'out-of-range', this.#returnToSection
        );
      }
      return this.#cardScroller;
    }

    /** @type {Scroller} */
    get sections() {
      return this.#sectionScroller;
    }

    nextSection = new Shortcut('j', 'Next section', () => {
      this.sections.next();
    });

    prevSection = new Shortcut('k', 'Previous section', () => {
      this.sections.prev();
    });

    nextCard = new Shortcut('n', 'Next card in section', () => {
      this.cards.next();
    });

    prevCard = new Shortcut('p', 'Previous card in section', () => {
      this.cards.prev();
    });

    firstItem = new Shortcut('<', 'Go to the first section or card', () => {
      this.#lastScroller.first();
    });

    lastItem = new Shortcut('>', 'Go to the last section or card', () => {
      this.#lastScroller.last();
    });

    focusBrowser = new Shortcut(
      'f', 'Change browser focus to current item', () => {
        NH.web.focusOnElement(this.#lastScroller.item);
      }
    );

    viewItem = new Shortcut('Enter', 'View the current item', () => {
      const card = this.cards?.item;
      if (card) {
        if (!NH.web.clickElement(card, ['a', 'button'], true)) {
          this.spa.dumpInfoAboutElement(card, 'network card');
        }
      } else {
        document.activeElement.click();
      }
    });

    enagageCard = new Shortcut(
      'E', 'Engage the card (Connect, Follow, Join, etc)', () => {
        const me = 'enagageCard';
        this.logger.entered(me);
        const selector = [
          // Connect w/ Person, Join Group, View event
          'footer > button',
          // Follow person, Follow page
          'div.discover-entity-type-card__container-bottom > button',
          // Subscribe to newsletter
          'div.p3 > button',
        ].join(',');
        this.logger.log('button?', this.cards.item.querySelector(selector));
        NH.web.clickElement(this.cards?.item, [selector]);
        this.logger.leaving(me);
      }
    );

    dismissCard = new Shortcut('X', 'Dismiss current card', () => {
      NH.web.clickElement(this.cards?.item,
        ['button.artdeco-card__dismiss']);
    });

    /** @type {Scroller~How} */
    static #cardsHow = {
      uidCallback: MyNetwork.uniqueCardsIdentifier,
      classes: ['dick'],
      autoActivate: true,
      snapToTop: false,
    };

    /** @type {Scroller~What} */
    static #cardsWhat = {
      name: 'MyNetwork cards',
      selectors: [
        [
          // Invitations -> See all
          ':scope > header > a',
          // Other sections -> See all
          ':scope > div > button',
          // Most cards
          ':scope > ul > li',
          // More suggestions for you cards
          ':scope > section ul > li section',
        ].join(','),
      ],
    };

    /** @type {Page~PageDetails} */
    static #details = {
      pathname: '/mynetwork/',
      pageReadySelector: 'main > ul',
    };

    /** @type {Scroller~How} */
    static #sectionsHow = {
      uidCallback: MyNetwork.uniqueSectionIdentifier,
      classes: ['tom'],
      snapToTop: true,
    };

    /** @type {Scroller~What} */
    static #sectionsWhat = {
      name: 'MyNetwork sections',
      containerItems: [
        {
          container: 'main',
          items: [
            // Invitations
            ':scope > section',
            // Most sections
            ':scope > ul > li',
            // More suggestions for you section
            ':scope > div > section',
          ].join(','),
        },
      ],
    };

    #cardScroller
    #keyboardService
    #lastScroller
    #sectionScroller

    #resetCards = () => {
      if (this.#cardScroller) {
        this.#cardScroller.destroy();
        this.#cardScroller = null;
      }
      this.cards;
    }

    #onCardChange = () => {
      this.#lastScroller = this.cards;
    }

    #onSectionChange = () => {
      this.#resetCards();
      this.#lastScroller = this.sections;
    }

    #returnToSection = () => {
      this.sections.item = this.sections.item;
    }

  }

  /** Class for handling the Invitation manager page. */
  class InvitationManager extends Page {

    /**
     * Create a InvitationManager instance.
     * @param {SPA} spa - SPA instance that manages this Page.
     */
    constructor(spa) {
      super({spa: spa, ...InvitationManager.#details});

      this.#keyboardService = this.addService(VMKeyboardService);
      this.#keyboardService.addInstance(this);

      spa.details.navBarScrollerFixup(InvitationManager.#invitesHow);

      this.#inviteScroller = new Scroller(
        InvitationManager.#invitesWhat, InvitationManager.#invitesHow
      );
      this.addService(ScrollerService, this.#inviteScroller);
      this.#inviteScroller.dispatcher.on('activate', this.#onActivate);
      this.#inviteScroller.dispatcher.on('change', this.#onChange);
    }

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueIdentifier(element) {
      let content = element.innerText;
      const anchor = element.querySelector('a');
      if (anchor?.href) {
        content = anchor.href;
      }
      return NH.base.strHash(content);
    }

    /** @type {Scroller} */
    get invites() {
      return this.#inviteScroller;
    }

    nextInvite = new Shortcut('j', 'Next invitation', () => {
      this.invites.next();
    });

    prevInvite = new Shortcut('k', 'Previous invitation', () => {
      this.invites.prev();
    });

    firstInvite = new Shortcut('<', 'Go to the first invitation', () => {
      this.invites.first();
    });

    lastInvite = new Shortcut('>', 'Go to the last invitation', () => {
      this.invites.last();
    });

    focusBrowser = new Shortcut(
      'f', 'Change browser focus to current item', () => {
        const item = this.invites.item;
        NH.web.focusOnElement(item);
      }
    );

    seeMore = new Shortcut(
      'm', 'Toggle seeing more of current invite', () => {
        NH.web.clickElement(
          this.invites?.item,
          ['a.lt-line-clamp__more, a.lt-line-clamp__less']
        );
      }
    );

    viewInviter = new Shortcut('i', 'View inviter', () => {
      NH.web.clickElement(this.invites?.item,
        ['a.app-aware-link:not(.invitation-card__picture)']);
    });

    viewTarget = new Shortcut(
      't',
      'View invitation target ' +
        '(may not be the same as inviter, e.g., Newsletter)',
      () => {
        NH.web.clickElement(this.invites?.item,
          ['a.invitation-card__picture']);
      }
    );

    openMeatballMenu = new Shortcut(
      '=', 'Open <button class="spa-meatball">⋯</button> menu', () => {
        this.invites?.item
          .querySelector('svg[aria-label^="Report message"]')
          ?.closest('button')
          ?.click();
      }
    );

    acceptInvite = new Shortcut('A', 'Accept invite', () => {
      NH.web.clickElement(this.invites?.item,
        ['button[aria-label^="Accept"]']);
    });

    ignoreInvite = new Shortcut('I', 'Ignore invite', () => {
      NH.web.clickElement(this.invites?.item,
        ['button[aria-label^="Ignore"]']);
    });

    messageInviter = new Shortcut('M', 'Message inviter', () => {
      NH.web.clickElement(this.invites?.item,
        ['button[aria-label*=" message"]']);
    });

    /** @type {Page~PageDetails} */
    static #details = {
      pathname: '/mynetwork/invitation-manager/',
      pageReadySelector: 'main',
    };

    static #invitesHow = {
      uidCallback: InvitationManager.uniqueIdentifier,
      classes: ['tom'],
    };

    /** @type {Scroller~What} */
    static #invitesWhat = {
      name: 'Invitation cards',
      base: document.body,
      selectors: [
        [
          // Actual invites
          'main > section section > ul > li',
        ].join(','),
      ],
    };

    #currentInviteText
    #inviteScroller
    #keyboardService

    #onActivate = async () => {
      const me = 'onActivate';
      this.logger.entered(me);

      /**
       * Wait for current invitation to show back up.
       * @implements {Monitor}
       * @returns {Continuation} - Indicate whether done monitoring.
       */
      const monitor = () => {
        for (const el of document.body.querySelectorAll(
          'main > section section > ul > li'
        )) {
          const text = el.innerText.trim().split('\n')[0];
          if (text === this.#currentInviteText) {
            return {done: true};
          }
        }
        return {done: false};
      };
      const what = {
        name: 'InviteManager onActivate',
        base: document.body.querySelector('main'),
      };
      const how = {
        observeOptions: {childList: true, subtree: true},
        monitor: monitor,
        timeout: 3000,
      };

      if (this.#currentInviteText) {
        this.logger.log(`We will look for ${this.#currentInviteText}`);
        await NH.web.otmot(what, how);
        this.invites.shine();
        this.invites.show();
      }
      this.logger.leaving(me);
    }

    #onChange = () => {
      const me = 'onChange';
      this.logger.entered(me);
      this.#currentInviteText = this.invites.item?.innerText
        .trim().split('\n')[0];
      this.logger.log('current', this.#currentInviteText);
      this.logger.leaving(me);
    }

  }

  /**
   * Class for handling the base Jobs page.
   *
   * This particular page requires a lot of careful monitoring.  Unlike other
   * pages, this one will destroy and recreate HTML elements, often with the
   * exact same content, every time something interesting happens.  Like
   * loading more sections or jobs, or toggling state of a job.
   */
  class Jobs extends Page {

    /**
     * Create a Jobs instance.
     * @param {SPA} spa - SPA instance that manages this Page.
     */
    constructor(spa) {
      super({spa: spa, ...Jobs.#details});

      this.#keyboardService = this.addService(VMKeyboardService);
      this.#keyboardService.addInstance(this);

      spa.details.navBarScrollerFixup(Jobs.#sectionsHow);
      spa.details.navBarScrollerFixup(Jobs.#jobsHow);

      this.#sectionScroller = new Scroller(Jobs.#sectionsWhat,
        Jobs.#sectionsHow);
      this.addService(ScrollerService, this.#sectionScroller);
      this.#sectionScroller.dispatcher.on('out-of-range',
        linkedInGlobals.focusOnSidebar);
      this.#sectionScroller.dispatcher.on('change', this.#onSectionChange);

      this.#lastScroller = this.#sectionScroller;
    }

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueSectionIdentifier(element) {
      const h2 = element.querySelector('h2');
      let content = element.innerText;
      if (h2?.innerText) {
        content = h2.innerText;
      }
      return NH.base.strHash(content);
    }

    /**
     * Complicated because there are so many variations.
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueJobIdentifier(element) {
      const ONE_ITEM = 1;
      let content = element.innerText;
      let options = element.querySelectorAll('a[data-control-id]');
      if (options.length === ONE_ITEM) {
        content = options[0].dataset.controlId;
      } else {
        options = element.querySelectorAll('a[id]');
        if (options.length === ONE_ITEM) {
          content = options[0].id;
        } else {
          let s = '';
          for (const img of element.querySelectorAll('img[alt]')) {
            s += img.alt;
          }
          if (s) {
            content = s;
          } else {
            options = element
              .querySelectorAll('.jobs-home-upsell-card__container');
            if (options.length === ONE_ITEM) {
              content = options[0].className;
            }
          }
        }
      }
      return NH.base.strHash(content);
    }

    /** @type {Scroller} */
    get jobs() {
      const me = 'get jobs';
      this.logger.entered(me, this.#jobScroller);

      if (!this.#jobScroller && this.sections.item) {
        this.#jobScroller = new Scroller(
          {base: this.sections.item, ...Jobs.#jobsWhat},
          Jobs.#jobsHow
        );
        this.#jobScroller.dispatcher.on('change', this.#onJobChange);
        this.#jobScroller.dispatcher.on('out-of-range',
          this.#returnToSection);
      }

      this.logger.leaving(me, this.#jobScroller);
      return this.#jobScroller;
    }

    /** @type {Scroller} */
    get sections() {
      return this.#sectionScroller;
    }

    nextSection = new Shortcut('j', 'Next section', () => {
      this.sections.next();
    });

    prevSection = new Shortcut('k', 'Previous section', () => {
      this.sections.prev();
    });

    nextJob = new Shortcut('n', 'Next job', () => {
      this.jobs.next();
    });

    prevJob = new Shortcut('p', 'Previous job', () => {
      this.jobs.prev();
    });

    firstSectionOrJob = new Shortcut(
      '<', 'Go to to first section or job', () => {
        this.#lastScroller.first();
      }
    );

    lastSectionOrJob = new Shortcut(
      '>', 'Go to last section or job currently loaded', () => {
        this.#lastScroller.last();
      }
    );

    focusBrowser = new Shortcut(
      'f', 'Change browser focus to current section or job', () => {
        this.sections.show();
        this.jobs?.show();
        NH.web.focusOnElement(this.#lastScroller.item);
      }
    );

    activateJob = new Shortcut(
      'Enter',
      'Activate the current job (click on it)',
      () => {
        const job = this.jobs?.item;
        if (job) {
          if (!NH.web.clickElement(job,
            ['div[data-view-name]', 'a', 'button'])) {
            this.spa.dumpInfoAboutElement(job, 'job');
          }
        } else {
          // Again, because we use Enter as the hotkey for this action.
          document.activeElement.click();
        }
      }
    );

    loadMoreSections = new Shortcut(
      'l',
      'Load more sections (or <i>More jobs for you</i> items)',
      async () => {
        const savedScrollTop = document.documentElement.scrollTop;

        /** Trigger function for {@link NH.web.otrot}. */
        function trigger() {
          NH.web.clickElement(document,
            ['main button.scaffold-finite-scroll__load-button']);
        }
        const what = {
          name: 'loadMoreSections',
          base: document.querySelector('div.scaffold-finite-scroll__content'),
        };
        const how = {
          trigger: trigger,
          timeout: 3000,
        };
        await NH.web.otrot(what, how);
        this.#resetScroll(savedScrollTop);
      }
    );

    toggleSaveJob = new Shortcut('S', 'Toggle saving job', () => {
      const selector = [
        'button[aria-label^="Save job"]',
        'button[aria-label^="Unsave job"]',
      ].join(',');
      NH.web.clickElement(this.jobs?.item, [selector]);
    });

    toggleDismissJob = new Shortcut('X',
      'Toggle dismissing job',
      async () => {
        const savedJob = this.jobs.item;

        /** Trigger function for {@link NH.web.otrot}. */
        function trigger() {
          const selector = [
            'button[aria-label^="Dismiss job"]:not([disabled])',
            'button[aria-label$=" Undo"]',
          ].join(',');
          NH.web.clickElement(savedJob, [selector]);
        }
        if (savedJob) {
          const what = {
            name: 'toggleDismissJob',
            base: savedJob,
          };
          const how = {
            trigger: trigger,
            timeout: 3000,
          };
          await NH.web.otrot(what, how);
          this.jobs.item = savedJob;
        }
      });

    /** @type {Page~PageDetails} */
    static #details = {
      pathname: '/jobs/',
      pageReadySelector: LinkedInGlobals.asideSelector,
    };

    /** @type {Scroller~How} */
    static #jobsHow = {
      uidCallback: Jobs.uniqueJobIdentifier,
      classes: ['dick'],
      autoActivate: true,
      snapToTop: false,
    };

    /** @type {Scroller~What} */
    static #jobsWhat = {
      name: 'Job entries',
      selectors: [
        [
          // Most job entries
          ':scope > ul > li',
          // Show all button
          'div.discovery-templates-vertical-list__footer',
        ].join(','),
      ],
    };

    /** @type {Scroller~How} */
    static #sectionsHow = {
      uidCallback: Jobs.uniqueSectionIdentifier,
      classes: ['tom'],
      snapToTop: true,
    };

    /** @type {Scroller~What} */
    static #sectionsWhat = {
      name: 'Jobs sections',
      containerItems: [{container: 'main', items: 'section'}],
    };

    #jobScroller
    #keyboardService
    #lastScroller
    #sectionScroller

    /** Reset the jobs scroller. */
    #resetJobs = () => {
      const me = 'resetJobs';
      this.logger.entered(me, this.#jobScroller);
      if (this.#jobScroller) {
        this.#jobScroller.destroy();
        this.#jobScroller = null;
      }
      this.jobs;
      this.logger.leaving(me);
    }

    /**
     * Reselects current section, triggering same actions as initial
     * selection.
     */
    #returnToSection = () => {
      this.sections.item = this.sections.item;
    }

    #onJobChange = () => {
      this.#lastScroller = this.jobs;
    }

    /**
     * Updates {@link Jobs} specific watcher data and removes the jobs
     * {@link Scroller}.
     */
    #onSectionChange = () => {
      const me = 'onSectionChange';
      this.logger.entered(me);
      this.#resetJobs();
      this.#lastScroller = this.sections;
      this.logger.leaving(me);
    }

    /**
     * Recover scroll position after elements were recreated.
     * @param {number} topScroll - Where to scroll to.
     */
    #resetScroll = (topScroll) => {
      const me = 'resetScroll';
      this.logger.entered(me, topScroll);
      // Explicitly setting jobs.item below will cause it to scroll to that
      // item.  We do not want to do that if the user is manually scrolling.
      const savedJob = this.jobs?.item;
      this.sections.shine();
      // Section was probably rebuilt, assume jobs scroller is invalid.
      this.#resetJobs();
      if (savedJob) {
        this.jobs.item = savedJob;
      }
      document.documentElement.scrollTop = topScroll;
      this.logger.leaving(me);
    }

  }

  /** Class for handling Job collections. */
  class JobCollections extends Page {

    /**
     * Create a JobCollections instance.
     * @param {SPA} spa - SPA instance that manages this Page.
     */
    constructor(spa) {
      super({spa: spa, ...JobCollections.#details});

      this.#keyboardService = this.addService(VMKeyboardService);
      this.#keyboardService.addInstance(this);

      this.#jobCardScroller = new Scroller(JobCollections.#jobCardsWhat,
        JobCollections.#jobCardsHow);
      this.addService(ScrollerService, this.#jobCardScroller);
      this.#jobCardScroller.dispatcher.on('activate',
        this.#onJobCardActivate);
      this.#jobCardScroller.dispatcher.on('change', this.#onJobCardChange);

      this.#paginationScroller = new Scroller(
        JobCollections.#paginationWhat, JobCollections.#paginationHow
      );
      this.addService(ScrollerService, this.#paginationScroller);
      this.#paginationScroller.dispatcher.on('activate',
        this.#onPaginationActivate);
      this.#paginationScroller.dispatcher.on('change',
        this.#onPaginationChange);

      this.#lastScroller = this.#jobCardScroller;
    }

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueJobIdentifier(element) {
      let content = '';
      if (element) {
        content = element.dataset.occludableJobId;
      }
      return NH.base.strHash(content);
    }

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniquePaginationIdentifier(element) {
      let content = '';
      if (element) {
        content = element.innerText;
        const label = element.getAttribute('aria-label');
        if (label) {
          content = label;
        }
      }
      return NH.base.strHash(content);
    }

    /** @type {Scroller} */
    get jobCards() {
      return this.#jobCardScroller;
    }

    /** @type {Scroller} */
    get paginator() {
      return this.#paginationScroller;
    }

    nextJob = new Shortcut('j', 'Next job card', () => {
      this.jobCards.next();
    });

    prevJob = new Shortcut('k', 'Previous job card', () => {
      this.jobCards.prev();
    });

    nextResultsPage = new Shortcut('n', 'Next results page', () => {
      this.paginator.next();
    });

    prevResultsPage = new Shortcut('p', 'Previous results page', () => {
      this.paginator.prev();
    });

    firstItem = new Shortcut('<', 'Go to first job or results page', () => {
      this.#lastScroller.first();
    });

    lastItem = new Shortcut(
      '>', 'Go to last job currently loaded or results page', () => {
        this.#lastScroller.last();
      }
    );

    focusBrowser = new Shortcut(
      'f', 'Move browser focus to most recently selected item', () => {
        NH.web.focusOnElement(this.#lastScroller.item);
      }
    );

    detailsPane = new Shortcut('d', 'Jump to details pane', () => {
      NH.web.focusOnElement(document.querySelector(
        'div.jobs-search__job-details--container'
      ));
    });

    selectCurrentResultsPage = new Shortcut(
      'c', 'Select current results page', () => {
        NH.web.clickElement(this.paginator.item, ['button']);
      }
    );

    openShareMenu = new Shortcut('s', 'Open share menu', () => {
      NH.web.clickElement(document, ['button[aria-label="Share"]']);
    });

    openMeatballMenu = new Shortcut(
      '=', 'Open the <button class="spa-meatball">⋯</button> menu', () => {
        // XXX: There are TWO buttons.  The *first* one is hidden until the
        // user scrolls down.  This always triggers the first one.
        NH.web.clickElement(document, ['.jobs-options button']);
      }
    );

    applyToJob = new Shortcut(
      'A', 'Apply to job (or previous application)', () => {
        // XXX: There are TWO apply buttons.  The *second* one is hidden until
        // the user scrolls down.  This always triggers the first one.
        const selectors = [
          // Apply and Easy Apply buttons
          'button[aria-label*="Apply to"]',
          // See application link
          'a[href^="/jobs/tracker"]',
        ];
        NH.web.clickElement(document, selectors);
      }
    );

    toggleSaveJob = new Shortcut('S', 'Toggle saving job', () => {
      // XXX: There are TWO buttons.  The *first* one is hidden until the user
      // scrolls down.  This always triggers the first one.
      NH.web.clickElement(document, ['button.jobs-save-button']);
    });

    toggleDismissJob = new Shortcut(
      'X', 'Toggle dismissing job, if available', () => {
        // Currently these two are the same, but one never knows.
        this.toggleThumbsDown();
      }
    );

    toggleFollowCompany = new Shortcut(
      'F', 'Toggle following company', () => {
        // The button toggles between Follow and Following
        NH.web.clickElement(document, ['button[aria-label^="Follow"]']);
      }
    );

    toggleAlert = new Shortcut(
      'L', 'Toggle the job search aLert, if available', () => {
        NH.web.clickElement(document,
          ['main .jobs-search-create-alert__artdeco-toggle']);
      }
    );

    toggleThumbsUp = new Shortcut(
      '+', 'Toggle thumbs up, if available', () => {
        const selector = [
          'button[aria-label="Like job"]',
          'button[aria-label="Job is liked, undo"]',
        ].join(',');
        NH.web.clickElement(this.jobCards.item, [selector]);
      }
    );

    toggleThumbsDown = new Shortcut(
      '-', 'Toggle thumbs down, if available', () => {
        const selector = [
          'button[aria-label="Dismiss job"]',
          'button[aria-label="Job is dismissed, undo"]',
        ].join(',');
        NH.web.clickElement(this.jobCards.item, [selector]);
      }
    );

    /** @type {Page~PageDetails} */
    static #details = {
      // eslint-disable-next-line prefer-regex-literals
      pathname: RegExp('^/jobs/(?:collections|search)/.*', 'u'),
      pageReadySelector: 'footer.global-footer-compact',
    };

    /** @type {Scroller~How} */
    static #jobCardsHow = {
      uidCallback: this.uniqueJobIdentifier,
      classes: ['tom'],
      snapToTop: false,
      bottomMarginCSS: '3em',
    };

    /** @type {Scroller~What} */
    static #jobCardsWhat = {
      name: 'Job cards',
      containerItems: [
        {
          container: 'div.jobs-search-results-list > ul',
          // This selector is also used in #onJobCardActivate.
          items: ':scope > li',
        },
      ],
    };

    /** @type {Scroller~How} */
    static #paginationHow = {
      uidCallback: this.uniquePaginationIdentifier,
      classes: ['dick'],
      snapToTop: false,
      bottomMarginCSS: '3em',
      containerTimeout: 1000,
    };

    /** @type {Scroller~What} */
    static #paginationWhat = {
      name: 'Results pagination',
      containerItems: [
        {
          container: 'div.jobs-search-results-list__pagination > ul',
          // This selector is also used in #onJobCardActivate.
          items: ':scope > li',
        },
      ],
    };

    #jobCardScroller
    #keyboardService
    #lastScroller
    #paginationScroller

    #onJobCardActivate = async () => {
      const me = 'onJobCardActivate';
      this.logger.entered(me);

      const params = new URL(document.location).searchParams;
      const jobId = params.get('currentJobId');
      this.logger.log('Looking for job card for', jobId);

      // Wait some amount of time for a job card to show up, if it ever does.
      // Annoyingly enough, the selection of jobs that shows up on a reload
      // may not include one for the current URL.  Even if the user arrived at
      // the URL moments ago.

      try {
        const timeout = 2000;
        const item = await NH.web.waitForSelector(
          `li[data-occludable-job-id="${jobId}"]`,
          timeout
        );
        this.jobCards.gotoUid(JobCollections.uniqueJobIdentifier(item));
      } catch (e) {
        this.logger.log('Job card matching URL not found, staying put');
      }

      this.logger.leaving(me);
    }

    #onJobCardChange = () => {
      const me = 'onJobCardChange';
      this.logger.entered(me, this.jobCards.item);
      NH.web.clickElement(this.jobCards.item, ['div[data-job-id]']);
      this.#lastScroller = this.jobCards;
      this.logger.leaving(me);
    }

    #onPaginationActivate = async () => {
      const me = 'onPaginationActivate';
      this.logger.entered(me);

      try {
        const timeout = 2000;
        const item = await NH.web.waitForSelector(
          'div.jobs-search-results-list__pagination > ul > li.selected',
          timeout
        );
        this.paginator.goto(item);
      } catch (e) {
        this.logger.log('Results paginator not found, staying put');
      }

      this.logger.leaving(me);
    }

    #onPaginationChange = () => {
      const me = 'onResultsPageChange';
      this.logger.entered(me, this.paginator.item);
      this.#lastScroller = this.paginator;
      this.logger.leaving(me);
    }

  }

  /** Class for handling the Messaging page. */
  class Messaging extends Page {

    /**
     * Create a Messaging instance.
     * @param {SPA} spa - SPA instance that manages this Page.
     */
    constructor(spa) {
      super({spa: spa, ...Messaging.#details});

      this.#keyboardService = this.addService(VMKeyboardService);
      this.#keyboardService.addInstance(this);

      // Focused/Other tab
      this.#messagingTablistObserver =
        new MutationObserver(this.#messagingTablistHandler);

      this.#convoCardScroller = new Scroller(Messaging.#convoCardsWhat,
        Messaging.#convoCardsHow);
      this.addService(ScrollerService, this.#convoCardScroller);
      this.#convoCardScroller.dispatcher.on('activate',
        this.#onConvoCardActivate);
      this.#convoCardScroller.dispatcher.on('deactivate',
        this.#onConvoCardDeactivate);
      this.#convoCardScroller.dispatcher.on('change',
        this.#onConvoCardChange);
    }

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueConvoCardsIdentifier(element) {
      let content = element.innerText;
      const anchor = element.querySelector('a');
      if (anchor?.href) {
        content = anchor.href;
      }
      return NH.base.strHash(content);
    }

    /** @type {Scroller} */
    get convoCards() {
      return this.#convoCardScroller;
    }

    loadMoreConversations = new Shortcut(
      'l', 'Load more conversations', () => {
        const me = 'loadMoreConversations';
        this.logger.entered(me);

        // This button has no distinguishing features, so look for the text
        // the nested span, then click the button.
        const span = Array.from(document.querySelectorAll('button > span'))
          .find(el => el.innerText === 'Load more conversations');
        span?.parentElement?.click();

        this.logger.leaving(me);
      }
    );

    messageTab = new Shortcut('m', 'Go to messaging tablist', () => {
      const me = 'messageTab';
      this.logger.entered(me);

      NH.web.focusOnElement(
        document.querySelector(Messaging.#messagingTabSelectorCurrent)
      );

      this.logger.leaving(me);
    });

    searchMessages = new Shortcut('s', 'Go to Search messages', () => {
      const me = 'searchMessages';
      this.logger.entered(me);

      NH.web.focusOnElement(document.querySelector('#search-conversations'));

      this.logger.leaving(me);
    });

    newMessage = new Shortcut('N', 'Compose a new message', () => {
      const me = 'newMessage';
      this.logger.entered(me);

      NH.web.clickElement(document,
        ['a[aria-label="Compose a new message"]']);

      this.logger.leaving(me);
    });

    /** @type {Scroller~How} */
    static #convoCardsHow = {
      uidCallback: Messaging.uniqueConvoCardsIdentifier,
      classes: ['tom'],
      snapToTop: false,
    };

    /** @type {Scroller~What} */
    static #convoCardsWhat = {
      name: 'Messaging conversations',
      containerItems: [
        {
          container:
          'main ul.msg-conversations-container__conversations-list',
          items: ':scope > li',
        },
      ],
    };

    /** @type {Page~PageDetails} */
    static #details = {
      // eslint-disable-next-line prefer-regex-literals
      pathname: RegExp('^/messaging/.*', 'u'),
      pageReadySelector: LinkedInGlobals.asideSelector,
    };

    static #messagingTabSelector = 'main div.msg-focused-inbox-tabs';
    static #messagingTabSelectorCurrent =
      `${Messaging.#messagingTabSelector} [aria-selected="true"]`;

    #activator
    #convoCardScroller
    #keyboardService
    #messagingTablistObserver

    #onConvoCardActivate = async () => {
      const me = 'onConvoCardActivate';
      this.logger.entered(me);

      await this.#findActiveConvo();

      const tab = document.querySelector(Messaging.#messagingTabSelector);
      this.#messagingTablistObserver.observe(tab,
        {attributes: true, subtree: true});

      this.logger.leaving(me);
    }

    #onConvoCardDeactivate = () => {
      const me = 'onConvoCardDeactivate';
      this.logger.entered(me);

      this.#messagingTablistObserver.disconnect();

      this.logger.leaving(me);
    }

    #onConvoCardChange = () => {
      NH.web.clickElement(this.convoCards.item, ['a']);
    }

    #findActiveConvo = async () => {
      const me = 'findActiveConvo';
      this.logger.entered(me);

      // Look for 'a.active'
      try {
        const timeout = 2000;
        const item = await NH.web.waitForSelector('li a.active', timeout);
        this.convoCards.goto(item.closest('li'));
      } catch (e) {
        this.logger.log('Active conversation card not found, staying put');
      }

      this.logger.leaving(me);
    }

    #messagingTablistHandler = async () => {
      const me = 'messagingTablistHandler';
      this.logger.entered(me);

      await this.#findActiveConvo();

      this.logger.leaving(me);
    }

  }

  /** Class for handling the Notifications page. */
  class Notifications extends Page {

    /**
     * Create a Notifications instance.
     * @param {SPA} spa - SPA instance that manages this Page.
     */
    constructor(spa) {
      super({spa: spa, ...Notifications.#details});

      this.#keyboardService = this.addService(VMKeyboardService);
      this.#keyboardService.addInstance(this);

      spa.details.navBarScrollerFixup(Notifications.#notificationsHow);

      this.#notificationScroller = new Scroller(
        Notifications.#notificationsWhat, Notifications.#notificationsHow
      );
      this.addService(ScrollerService, this.#notificationScroller);
      this.#notificationScroller.dispatcher.on('out-of-range',
        linkedInGlobals.focusOnSidebar);
    }

    /**
     * Complicated because there are so many variations in notification cards.
     * We do not want to use reaction counts because they can change too
     * quickly.
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueIdentifier(element) {
      // All known <articles> have three children: icon/presence indicator,
      // content, and menu/timestamp.
      const MAGIC_COUNT = 3;
      const CONTENT_INDEX = 1;
      let content = element.innerText;
      if (element.childElementCount === MAGIC_COUNT) {
        content = element.children[CONTENT_INDEX].innerText;
        if (content.includes('Reactions')) {
          for (const el of element.children[CONTENT_INDEX]
            .querySelectorAll('*')) {
            if (el.innerText) {
              content = el.innerText;
              break;
            }
          }
        }
      }
      if (content.startsWith('Notification deleted.')) {
        // Mix in something unique from the parent.
        content += element.parentElement.dataset.finiteScrollHotkeyItem;
      }
      return NH.base.strHash(content);
    }

    /** @type {Scroller} */
    get notifications() {
      return this.#notificationScroller;
    }

    nextNotification = new Shortcut('j', 'Next notification', () => {
      this.notifications.next();
    });

    prevNotification = new Shortcut('k', 'Previous notification', () => {
      this.notifications.prev();
    });

    firstNotification = new Shortcut('<', 'Go to first notification', () => {
      this.notifications.first();
    });

    lastNotification = new Shortcut('>', 'Go to last notification', () => {
      this.notifications.last();
    });

    focusBrowser = new Shortcut(
      'f', 'Change browser focus to current notification', () => {
        this.notifications.show();
        NH.web.focusOnElement(this.notifications.item);
      }
    );

    activateNotification = new Shortcut(
      'Enter', 'Activate the current notification (click on it)', () => {
        const ONE_ITEM = 1;
        const notification = this.notifications.item;
        if (notification) {
          // Because we are using Enter as the hotkey here, if the active
          // element is inside the current card, we want that to take
          // precedence.
          if (document.activeElement.closest('article') === notification) {
            return;
          }

          const elements = notification.querySelectorAll(
            '.nt-card__headline'
          );
          if (elements.length === ONE_ITEM) {
            elements[0].click();
          } else {
            const ba = notification.querySelectorAll('button,a');
            if (ba.length === ONE_ITEM) {
              ba[0].click();
            } else {
              this.spa.dumpInfoAboutElement(notification, 'notification');
            }
          }
        } else {
          // Again, because we use Enter as the hotkey for this action.
          document.activeElement.click();
        }
      }
    );

    loadMoreNotifications = new Shortcut(
      'l', 'Load more notifications', () => {
        const savedScrollTop = document.documentElement.scrollTop;
        let first = false;
        const notifications = this.notifications;

        /** Trigger function for {@link NH.web.otrot2}. */
        function trigger() {
          if (NH.web.clickElement(document,
            ['button[aria-label^="Load new notifications"]'])) {
            first = true;
          } else {
            NH.web.clickElement(document,
              ['main button.scaffold-finite-scroll__load-button']);
          }
        }

        /** Action function for {@link NH.web.otrot2}. */
        const action = () => {
          if (first) {
            if (notifications.item) {
              notifications.first();
            }
          } else {
            document.documentElement.scrollTop = savedScrollTop;
            this.notifications.shine();
          }
        };

        const what = {
          name: 'loadMoreNotifications',
          base: document.querySelector('div.scaffold-finite-scroll__content'),
        };
        const how = {
          trigger: trigger,
          action: action,
          duration: 2000,
        };
        NH.web.otrot2(what, how);
      }
    );

    openMeatballMenu = new Shortcut(
      '=', 'Open the <button class="spa-meatball">⋯</button> menu', () => {
        NH.web.clickElement(this.notifications.item,
          ['button[aria-label^="Settings menu"]']);
      }
    );

    deleteNotification = new Shortcut(
      'X', 'Toggle current notification deletion', async () => {
        const notification = this.notifications.item;

        /** Trigger function for {@link NH.web.otrot}. */
        function trigger() {
          // Hah.  Unlike in other places, these buttons already exist, just
          // hidden under the menu.
          const buttons = Array.from(notification.querySelectorAll('button'));
          const button = buttons
            .find(el => (/Delete .*notification/u).test(el.textContent));
          if (button) {
            button.click();
          } else {
            NH.web.clickElement(notification,
              ['button[aria-label^="Undo notification deletion"]']);
          }
        }
        if (notification) {
          const what = {
            name: 'deleteNotification',
            base: document.querySelector(
              'div.scaffold-finite-scroll__content'
            ),
          };
          const how = {
            trigger: trigger,
            timeout: 3000,
          };
          await NH.web.otrot(what, how);
          this.notifications.shine();
        }
      }
    );

    /** @type {Page~PageDetails} */
    static #details = {
      pathname: '/notifications/',
      pageReadySelector: 'main section div.nt-card-list',
    };

    /** @type {Scroller-How} */
    static #notificationsHow = {
      uidCallback: Notifications.uniqueIdentifier,
      classes: ['tom'],
      snapToTop: false,
    };

    /** @type {Scroller~What} */
    static #notificationsWhat = {
      name: 'Notification cards',
      containerItems: [
        {
          container: 'main section div.nt-card-list',
          items: 'article',
        },
      ],
    };

    #keyboardService
    #notificationScroller

  }

  /** Base class for {@link SPA} instance details. */
  class SPADetails {

    /** Create a SPADetails instance. */
    constructor() {
      if (new.target === SPADetails) {
        throw new TypeError('Abstract class; do not instantiate directly.');
      }

      this.#logger = new NH.base.Logger(this.constructor.name);
      this.#id = NH.base.safeId(NH.base.uuId(this.constructor.name));
      this.dispatcher = new NH.base.Dispatcher('errors', 'news');
    }

    /**
     * An issue that happened during construction.  SPA will ask for them and
     * add them to the Errors tab.
     * @typedef {object} SetupIssue
     * @property {string[]} messages - What to pass to {@link SPA.addError}.
     */

    /**
     * @type {string} - CSS selector to monitor if self-managing URL changes.
     * The selector must resolve to an element that, once it exists, will
     * continue to exist for the lifetime of the SPA.
     */
    urlChangeMonitorSelector = 'body';

    /** @type {string} - Unique ID for this instance . */
    get id() {
      return this.#id;
    }

    /** @type {NH.base.Logger} - NH.base.Logger instance. */
    get logger() {
      return this.#logger;
    }

    /** @type {SetupIssue[]} */
    get setupIssues() {
      return this.#setupIssues;
    }

    /** @type {TabbedUI} */
    get ui() {
      return this.#ui;
    }

    /** @param {TabbedUI} val - UI instance. */
    set ui(val) {
      this.#ui = val;
    }

    /**
     * Called by SPA instance during its construction to allow post
     * instantiation stuff to happen.  If overridden in a subclass, this
     * should definitely be called via super.
     */
    init() {
      this.dispatcher.on('errors', this._errors);
      this.dispatcher.on('news', this._news);
    }

    /**
     * Called by SPA instance when initialization is done.  Subclasses should
     * call via super.
     */
    done() {
      const me = 'done (SPADetails)';
      this.logger.entered(me);
      this.logger.leaving(me);
    }

    /**
     * Handles notifications about changes to the {@link SPA} Errors tab
     * content.
     * @implements {NH.base.Dispatcher~Handler}
     * @param {string} eventType - Event type.
     * @param {number} count - Number of errors currently logged.
     */
    _errors = (eventType, count) => {
      this.logger.log('errors:', eventType, count);
    }

    /**
     * Handles notifications about activity on the {@link SPA} News tab.
     * @implements {NH.base.Dispatcher~Handler}
     * @param {string} eventType - Event type.
     * @param {object} data - Undefined at this time.
     */
    _news = (eventType, data) => {
      this.logger.log('news', eventType, data);
    }

    /**
     * Collects {SetupIssue}s for reporting.
     * @param {...string} msgs - Text to report.
     */
    addSetupIssue(...msgs) {
      for (const msg of msgs) {
        this.logger.log('Setup issue:', msg);
      }
      this.#setupIssues.push(msgs);
    }

    /**
     * @implements {SPA~TabGenerator}
     * @returns {TabbedUI~TabDefinition} - Where to find documentation
     * and file bugs.
     */
    docTab() {
      this.logger.log('docTab is not implemented');
      throw new Error('Not implemented.');
      return {  // eslint-disable-line no-unreachable
        name: 'Not implemented.',
        content: 'Not implemented.',
      };
    }

    /**
     * @implements {SPA~TabGenerator}
     * @returns {TabbedUI~TabDefinition} - License information.
     */
    licenseTab() {
      this.logger.log('licenseTab is not implemented');
      throw new Error('Not implemented.');
      return {  // eslint-disable-line no-unreachable
        name: 'Not implemented.',
        content: 'Not implemented.',
      };
    }

    #id
    #logger

    /** @type {SetupIssue[]} */
    #setupIssues = [];

    /** @type {TabbedUI} */
    #ui = null;

  }

  /** LinkedIn specific information. */
  class LinkedIn extends SPADetails {

    /**
     * @param {LinkedInGlobals} globals - Instance of a helper class to avoid
     * circular dependencies.
     */
    constructor(globals) {
      super();
      this.#globals = globals;
      this.ready = this.#waitUntilPageLoadedEnough();
    }

    urlChangeMonitorSelector = 'div.authentication-outlet';

    /** @type {string} - The element.id used to identify the info pop-up. */
    get infoId() {
      return this.#infoId;
    }

    /** @param {string} val - Set the value of the info element.id. */
    set infoId(val) {
      this.#infoId = val;
    }

    /**
     * @typedef {object} LicenseData
     * @property {string} name - Name of the license.
     * @property {string} url - License URL.
     */

    /** @type {LicenseData} */
    get licenseData() {
      const me = 'licenseData';
      this.logger.entered(me);

      if (!this.#licenseData) {
        try {
          this.#licenseData = NH.userscript.licenseData();
        } catch (e) {
          if (e instanceof NH.userscript.UserscriptError) {
            this.logger.log('e:', e);
            this.addSetupIssue(e.message);
            this.#licenseData = {
              name: 'Unable to extract: Please file a bug',
              url: '',
            };
          }
        }
      }

      this.logger.leaving(me, this.#licenseData);
      return this.#licenseData;
    }

    /** @inheritdoc */
    done() {
      super.done();
      const me = 'done';
      this.logger.entered(me);
      const licenseEntry = this.ui.tabs.get('License');
      licenseEntry.panel.addEventListener('expose', this.#licenseHandler);
      VMKeyboardService.condition = '!inputFocus && !inDialog';
      VMKeyboardService.start();
      this.logger.leaving(me);
    }

    /**
     * Many classes have some static {Scroller~How} items that need to be
     * fixed up after the page loads enough that the values are available.
     * They do that by calling this method.
     * @param {Scroller~How} how - Object to be fixed up.
     */
    navBarScrollerFixup(how) {
      const me = 'navBarScrollerFixup';
      this.logger.entered(me, how);

      how.topMarginPixels = this.#globals.navBarHeightPixels;
      how.topMarginCSS = this.#globals.navBarHeightCSS;
      how.bottomMarginCSS = '3em';

      this.logger.leaving(me, how);
    }

    /** @inheritdoc */
    _errors = (eventType, count) => {
      const me = 'errors';
      this.logger.entered(me, eventType, count);
      const button = document.querySelector('#lit-nav-button');
      const toggle = button.querySelector('.notification-badge');
      const badge = button.querySelector('.notification-badge__count');
      badge.innerText = `${count}`;
      if (count) {
        toggle.classList.add('notification-badge--show');
      } else {
        toggle.classList.remove('notification-badge--show');
      }
      this.logger.leaving(me);
    }

    /** @inheritdoc */
    docTab() {
      const me = 'docTab';
      this.logger.entered(me);

      const issuesLink = this.#globals.ghUrl('labels/linkedin-tool');
      const newIssueLink = this.#globals.ghUrl('issues/new/choose');
      const newGfIssueLink = this.#globals.gfUrl('feedback');
      const releaseNotesLink = this.#globals.gfUrl('versions');

      const content = [
        `<p>This is information about the <b>${GM.info.script.name}</b> ` +
          'userscript, a type of add-on.  It is not associated with ' +
          'LinkedIn Corporation in any way.</p>',
        '<p>Documentation can be found on ' +
          `<a href="${GM.info.script.supportURL}">GitHub</a>.  Release ` +
          'notes are automatically generated on ' +
          `<a href="${releaseNotesLink}">Greasy Fork</a>.</p>`,
        '<p>Existing issues are also on GitHub ' +
          `<a href="${issuesLink}">here</a>.</p>`,
        '<p>New issues or feature requests can be filed on GitHub (account ' +
          `required) <a href="${newIssueLink}">here</a>.  Then select the ` +
          'appropriate issue template to get started.  Or, on Greasy Fork ' +
          `(account required) <a href="${newGfIssueLink}">here</a>.  ` +
          'Review the <b>Errors</b> tab for any useful information.</p>',
        '',
      ];

      const tab = {
        name: 'About',
        content: content.join('\n'),
      };

      this.logger.leaving(me, tab);
      return tab;
    }

    /** @inheritdoc */
    newsTab() {
      const me = 'newsTab';
      this.logger.entered(me);

      const {dates, knownIssues} = this.#preprocessKnownIssues();

      const content = [
        '<p>The contains a manually curated list of changes over the last ' +
          'month or so that:',
        '<ul>',
        '<li>Added new features like support for new pages or more ' +
          'hotkeys</li>',
        '<li>Explicitly fixed a bug</li>',
        '<li>May cause a use noticeable change</li>',
        '</ul>',
        '</p>',
        '<p>See the <b>About</b> tab for finding all changes by release.</p>',
      ];

      const dateHeader = 'h3';
      const issueHeader = 'h4';

      for (const [date, items] of dates) {
        content.push(`<${dateHeader}>${date}</${dateHeader}>`);
        for (const [issue, subjects] of items) {
          content.push(
            `<${issueHeader}>${knownIssues.get(issue)}</${issueHeader}>`
          );
          content.push('<ul>');
          for (const subject of subjects) {
            content.push(`<li>${subject}</li>`);
          }
          content.push('</ul>');
        }
      }

      const tab = {
        name: 'News',
        content: content.join('\n'),
      };

      this.logger.leaving(me);
      return tab;
    }

    /** @inheritdoc */
    licenseTab() {
      const me = 'licenseTab';
      this.logger.entered(me);

      const {name, url} = this.licenseData;
      const tab = {
        name: 'License',
        content: `<p><a href="${url}">${name}</a></p>`,
      };

      this.logger.leaving(me, tab);
      return tab;
    }

    static #icon =
      '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">' +
      '<defs>' +
      '<mask id="a" maskContentUnits="objectBoundingBox">' +
      '<path fill="#fff" d="M0 0h1v1H0z"/>' +
      '<circle cx=".5" cy=".5" r=".25"/>' +
      '</mask>' +
      '<mask id="b" maskContentUnits="objectBoundingBox">' +
      '<path fill="#fff" mask="url(#a)" d="M0 0h1v1H0z"/>' +
      '<rect x="0.375" y="-0.05" height="0.35" width="0.25"' +
      ' transform="rotate(30 0.5 0.5)"/>' +
      '</mask>' +
      '</defs>' +
      '<rect x="9.5" y="7" width="5" height="10"' +
      ' transform="rotate(45 12 12)"/>' +
      '<circle cx="6" cy="18" r="5" mask="url(#a)"/>' +
      '<circle cx="18" cy="6" r="5" mask="url(#b)"/>' +
      '</svg>';

    #globals
    #infoId
    #infoKeyboard
    #infoTabs
    #infoWidget
    #licenseData
    #licenseLoaded
    #navbar
    #shortcutsWidget
    #useOriginalInfoDialog = !litOptions.enableDevMode;

    /** Hang out until enough HTML has been built to be useful. */
    #waitUntilPageLoadedEnough = async () => {
      const me = 'waitOnPageLoadedEnough';
      this.logger.entered(me);

      this.#navbar = await NH.web.waitForSelector('#global-nav', 0);
      this.#finishConstruction();

      this.logger.leaving(me);
    }

    /** Do the bits that were waiting on the page. */
    #finishConstruction = () => {
      const me = 'finishConstruction';
      this.logger.entered(me);

      this.#createInfoWidget();
      this.#addInfoTabs();
      this.#addLitStyle();
      this.#addToolMenuItem();
      this.#setNavBarInfo();

      this.logger.leaving(me);
    }

    /**
     * Lazily load license text when exposed.
     * @param {Event} evt - The 'expose' event.
     */
    #licenseHandler = async (evt) => {
      const me = 'licenseHandler';
      this.logger.entered(me, evt.target);

      // Probably should debounce this.  If the user visits this tab twice
      // fast enough, they end up with two copies loaded.  Amusing, but
      // probably should be resilient.
      if (!this.#licenseLoaded) {
        const info = document.createElement('p');
        info.innerHTML = '<i>Loading license...</i>';
        evt.target.append(info);
        const {name, url} = this.licenseData;

        const response = await fetch(url);
        if (response.ok) {
          const license = document.createElement('iframe');
          license.style.flexGrow = 1;
          license.title = name;
          license.sandbox = '';
          license.srcdoc = await response.text();
          info.replaceWith(license);
          this.#licenseLoaded = true;
        }
      }

      this.logger.leaving(me);
    }

    #createInfoWidget = () => {
      this.#infoWidget = new NH.widget.Info('LinkedIn Tool');
      const widget = this.#infoWidget.container;
      widget.classList.add('lit-info');
      document.body.prepend(widget);
      const dismissId = NH.base.safeId(`${widget.id}-dismiss`);

      const name = this.#infoName(dismissId);
      const instructions = this.#infoInstructions();

      widget.append(name, instructions);

      document.getElementById(dismissId).addEventListener('click', () => {
        this.#infoWidget.close();
      });

      this.#infoKeyboard = new VM.shortcut.KeyboardService();
      widget.addEventListener('open', this.#onOpenInfo);
      widget.addEventListener('close', this.#onCloseInfo);
    }

    /**
     * @param {string} dismissId - Element #id to give dismiss button.
     * @returns {Element} - For the info widget name header.
     */
    #infoName = (dismissId) => {
      const name = document.createElement('div');
      name.classList.add('lit-justify');
      const title = `<b>${GM.info.script.name}</b> - ` +
            `v${GM.info.script.version}`;
      const dismiss = `<button id=${dismissId}>X</button>`;
      name.innerHTML = `<span>${title}</span><span>${dismiss}</span>`;

      return name;
    }

    /** @returns {Element} - Instructions for navigating the info widget. */
    #infoInstructions = () => {
      const instructions = document.createElement('div');
      instructions.classList.add('lit-justify');
      instructions.classList.add('lit-instructions');
      const left = VMKeyboardService.parseSeq('c-left');
      const right = VMKeyboardService.parseSeq('c-right');
      const esc = VMKeyboardService.parseSeq('esc');
      instructions.innerHTML =
        `<span>Use the ${left} and ${right} keys or click to select ` +
        'tab</span>' +
        `<span>Hit ${esc} to close</span>`;

      return instructions;
    }

    #onOpenInfo = () => {
      VMKeyboardService.setKeyboardContext('inDialog', true);
      this.#infoKeyboard.enable();
      this.#buildShortcutsInfo();
      this.logger.log('info opened');
    }

    #onCloseInfo = () => {
      this.#infoKeyboard.disable();
      VMKeyboardService.setKeyboardContext('inDialog', false);
      this.logger.log('info closed');
    }

    /** Create CSS styles for stuff specific to LinkedIn Tool. */
    #addLitStyle = () => {  // eslint-disable-line max-lines-per-function
      const style = document.createElement('style');
      style.id = `${this.id}-style`;
      const styles = [
        '.lit-info:modal {' +
          ' height: 100%;' +
          ' width: 65rem;' +
          ' display: flex;' +
          ' flex-direction: column;' +
          '}',
        '.lit-info button {' +
          ' border-width: 1px;' +
          ' border-style: solid;' +
          ' border-radius: 1em;' +
          ' padding: 3px;' +
          '}',
        '.lit-news {' +
          ' position: absolute;' +
          ' bottom: 14px;' +
          ' right: -5px;' +
          ' width: 16px;' +
          ' height: 16px;' +
          ' border-radius: 50%;' +
          ' border: 5px solid green;' +
          '}',
        '.lit-justify {' +
          ' display: flex;' +
          ' flex-direction: row;' +
          ' justify-content: space-between;' +
          '}',
        '.lit-instructions {' +
          ' padding-bottom: 1ex;' +
          ' border-bottom: 1px solid black;' +
          ' margin-bottom: 5px;' +
          '}',
        '.lit-info kbd > kbd {' +
          ' font-size: 0.85em;' +
          ' padding: 0.07em;' +
          ' border-width: 1px;' +
          ' border-style: solid;' +
          '}',
        '.lit-info th { text-align: left; }',
        '.lit-info td:first-child {' +
          ' white-space: nowrap;' +
          ' text-align: right;' +
          ' padding-right: 0.5em;' +
          '}',
      ];
      style.textContent = styles.join('\n');
      document.head.prepend(style);
    }

    #addInfoTabs = () => {
      const me = 'addInfoTabs';
      this.logger.entered(me);

      const tabs = [
        this.#shortcutsTab(),
        this.docTab(),
        this.newsTab(),
      ];

      this.#infoTabs = new TabbedUI('LinkedIn Tool');

      for (const tab of tabs) {
        this.#infoTabs.addTab(tab);
      }
      this.#infoTabs.goto(tabs[0].name);

      this.#infoWidget.container.append(this.#infoTabs.container);

      this.#infoKeyboard.register('c-right', this.#nextTab);
      this.#infoKeyboard.register('c-left', this.#prevTab);

      this.logger.leaving(me);
    }

    #nextTab = () => {
      this.#infoTabs.next();
    }

    #prevTab = () => {
      this.#infoTabs.prev();
    }

    /** Add a menu item to the global nav bar. */
    #addToolMenuItem = () => {
      const me = 'addToolMenuItem';
      this.logger.entered(me);

      const ul = document.querySelector('ul.global-nav__primary-items');
      const li = document.createElement('li');
      li.classList.add('global-nav__primary-item');
      li.innerHTML =
        '<button id="lit-nav-button" class="global-nav__primary-link">' +
        '  <div class="global-nav__primary-link-notif ' +
        'artdeco-notification-badge">' +
        '    <div class="notification-badge">' +
        '      <span class="notification-badge__count"></span>' +
        '    </div>' +
        `    <div>${LinkedIn.#icon}</div>` +
        '    <span class="lit-news-badge">TBD</span>' +
        '    <span class="t-12 global-nav__primary-link-text">Tool</span>' +
        '  </div>' +
        '</button>';
      const navMe = ul.querySelector('li .global-nav__me').closest('li');
      if (navMe) {
        navMe.after(li);
      } else {
        // If the site changed and we cannot insert ourself after the Me menu
        // item, then go first.
        ul.prepend(li);
        this.addSetupIssue(
          'Unable to find the Profile navbar item.',
          'LIT menu installed in non-standard location.'
        );
      }
      const button = li.querySelector('button');
      button.addEventListener('click', () => {
        if (this.#useOriginalInfoDialog) {
          const info = document.querySelector(`#${this.infoId}`);
          info.showModal();
          info.dispatchEvent(new Event('open'));
        } else {
          this.#infoWidget.open();
        }
        if (litOptions.enableDevMode) {
          this.#useOriginalInfoDialog = !this.#useOriginalInfoDialog;
        }
      });
      this.logger.leaving(me);
    }

    /** Set some useful global variables. */
    #setNavBarInfo = () => {
      const fudgeFactor = 4;

      this.#globals.navBarHeightPixels = this.#navbar.clientHeight +
        fudgeFactor;
    }

    /**
     * @returns {TabbedUI~TabDefinition} - Keyboard shortcuts listing.
     */
    #shortcutsTab = () => {
      this.#shortcutsWidget = new AccordionTableWidget('Shortcuts');

      const tab = {
        name: 'Keyboard Shortcuts',
        content: this.#shortcutsWidget.container,
      };
      return tab;
    }

    #buildShortcutsInfo = () => {
      const me = 'buildShortcutsInfo';
      this.logger.entered(me);

      this.#shortcutsWidget.clear();
      for (const service of VMKeyboardService.services) {
        this.logger.log('service:', service.shortName, service.active);
        // Works in progress may not have any shortcuts yet.
        if (service.shortcuts.length) {
          const name = NH.base.simpleParseWords(service.shortName).join(' ');
          this.#shortcutsWidget.addSection(service.shortName);
          this.#shortcutsWidget.addHeader(service.active, name);
          for (const shortcut of service.shortcuts) {
            this.logger.log('shortcut:', shortcut);
            this.#shortcutsWidget.addData(
              `${VMKeyboardService.parseSeq(shortcut.seq)}:`, shortcut.desc
            );
          }
        }
      }

      this.logger.leaving(me);
    }

    /** @returns {obj} - dates and known issues. */
    #preprocessKnownIssues = () => {
      const knownIssues = new Map(globalKnownIssues);
      const unknownIssues = new Set();
      const unusedIssues = new Set(knownIssues.keys());

      const dates = new NH.base.DefaultMap(
        () => new NH.base.DefaultMap(Array)
      );

      for (const item of globalNewsContent) {
        for (const issue of item.issues) {
          if (knownIssues.has(issue)) {
            unusedIssues.delete(issue);
            dates.get(item.date).get(issue)
              .push(item.subject);
          } else {
            unknownIssues.add(issue);
          }
        }
      }

      this.logger.log('unknown', unknownIssues);
      this.logger.log('unused', unusedIssues);

      if (unknownIssues.size) {
        const issues = Array.from(unknownIssues).join(', ');
        throw new Error(`Unknown issues were detected: ${issues}`);
      }

      return {
        dates: dates,
        knownIssues: knownIssues,
      };
    }

  }

  /**
   * A userscript driver for working with a single-page application.
   *
   * Generally, a single instance of this class is created, and all instances
   * of {Page} are registered to it.  As the user navigates through the
   * single-page application, this will react to it and enable and disable
   * view specific handling as appropriate.
   */
  class SPA {

    /** @param {SPADetails} details - Implementation specific details. */
    constructor(details) {
      this.#name = `${this.constructor.name}: ${details.constructor.name}`;
      this.#id = NH.base.safeId(NH.base.uuId(this.#name));
      this.#logger = new NH.base.Logger(this.#name);
      this.#details = details;
      this.#details.init(this);
      this._installNavStyle();
      this._initializeInfoView();
      for (const issue of details.setupIssues) {
        this.logger.log('issue:', issue);
        for (const error of issue) {
          this.addError(error);
        }
        this.addErrorMarker();
      }
      document.addEventListener('focus', this._onFocus, true);
      document.addEventListener('urlchange', this.#onUrlChange, true);
      this.#startUrlMonitor();
      this.#details.done();
    }

    static _errorMarker = '---';

    /**
     * @implements {TabGenerator}
     * @returns {TabbedUI~TabDefinition} - Initial table for the keyboard
     * shortcuts.
     */
    static _shortcutsTab() {
      return {
        name: 'Keyboard shortcuts',
        content: '<table data-spa-id="shortcuts"><tbody></tbody></table>',
      };
    }

    /**
     * Generate information about the current environment useful in bug
     * reports.
     * @returns {string} - Text with some wrapped in a `pre` element.
     */
    static _errorPlatformInfo() {
      const header = 'Please consider including some of the following ' +
            'information in any bug report:';

      const msgs = NH.userscript.environmentData();

      return `${header}<pre>${msgs.join('\n')}</pre>`;
    }

    /**
     * @implements {TabGenerator}
     * @returns {TabbedUI~TabDefinition} - Initial placeholder for error
     * logging.
     */
    static _errorTab() {
      return {
        name: 'Errors',
        content: [
          '<p>Any information in the text box below could be helpful in ' +
            'fixing a bug.</p>',
          '<p>The content can be edited and then included in a bug ' +
            'report.  Different errors should be separated by ' +
            `"${SPA._errorMarker}".</p>`,
          '<p><b>Please remove any identifying information before ' +
            'including it in a bug report!</b></p>',
          SPA._errorPlatformInfo(),
          '<textarea data-spa-id="errors" spellcheck="false" ' +
            'placeholder="No errors logged yet."></textarea>',
        ].join(''),
      };
    }

    /** @type {Element} - The most recent element to receive focus. */
    _lastInputElement = null;

    /** @type {KeyboardService} */
    _tabUiKeyboard = null;

    /** @type {SPADetails} */
    get details() {
      return this.#details;
    }

    /** @type {NH.base.Logger} */
    get logger() {
      return this.#logger;
    }

    /**
     * Set the context (used by VM.shortcut) to a specific value.
     * @param {string} context - The name of the context.
     * @param {object} state - What the value should be.
     */
    _setKeyboardContext(context, state) {
      const pages = Array.from(this.#pages.values());
      for (const page of pages) {
        page.keyboard.setContext(context, state);
      }
    }

    /**
     * Handle focus events to track whether we have gone into or left an area
     * where we want to disable hotkeys.
     * @param {Event} evt - Standard 'focus' event.
     */
    _onFocus = (evt) => {
      if (this._lastInputElement && evt.target !== this._lastInputElement) {
        this._lastInputElement = null;
        this._setKeyboardContext('inputFocus', false);
      }
      if (NH.web.isInput(evt.target)) {
        this._setKeyboardContext('inputFocus', true);
        this._lastInputElement = evt.target;
      }
    }

    /** Configure handlers for the info view. */
    _addInfoViewHandlers() {
      const errors = document.querySelector(
        `#${this._infoId} [data-spa-id="errors"]`
      );
      errors.addEventListener('change', (evt) => {
        const count = evt.target.value.split('\n')
          .filter(x => x === SPA._errorMarker).length;
        this.#details.dispatcher.fire('errors', count);
        this._updateInfoErrorsLabel(count);
      });
    }

    /** Create the CSS styles used for indicating the current items. */
    _installNavStyle() {
      const style = document.createElement('style');
      style.id = NH.base.safeId(`${this.#id}-nav-style`);
      const styles = [
        '.tom {' +
          ' border-color: orange !important;' +
          ' border-style: solid !important;' +
          ' border-width: medium !important;' +
          '}',
        '.dick {' +
          ' border-color: red !important;' +
          ' border-style: solid !important;' +
          ' border-width: thin !important;' +
          '}',
        '',
      ];
      style.textContent = styles.join('\n');
      document.head.append(style);
    }

    /**
     * Create and configure a separate {@link KeyboardService} for the info
     * view.
     */
    _initializeTabUiKeyboard() {
      this._tabUiKeyboard = new VM.shortcut.KeyboardService();
      this._tabUiKeyboard.register('c-right', this._nextTab);
      this._tabUiKeyboard.register('c-left', this._prevTab);
    }

    /**
     * @callback TabGenerator
     * @returns {TabbedUI~TabDefinition}
     */

    /** Add CSS styling for use with the info view. */
    _addInfoStyle() {  // eslint-disable-line max-lines-per-function
      const style = document.createElement('style');
      style.id = NH.base.safeId(`${this.#id}-info-style`);
      const styles = [
        `#${this._infoId}:modal {` +
          ' height: 100%;' +
          ' width: 65rem;' +
          ' display: flex;' +
          ' flex-direction: column;' +
          '}',
        `#${this._infoId} .left { text-align: left; }`,
        `#${this._infoId} .right { text-align: right; }`,
        `#${this._infoId} .spa-instructions {` +
          ' display: flex;' +
          ' flex-direction: row;' +
          ' padding-bottom: 1ex;' +
          ' border-bottom: 1px solid black;' +
          ' margin-bottom: 5px;' +
          '}',
        `#${this._infoId} .spa-instructions > span { flex-grow: 1; }`,
        `#${this._infoId} textarea[data-spa-id="errors"] {` +
          ' flex-grow: 1;' +
          ' resize: none;' +
          '}',
        `#${this._infoId} .spa-danger { background-color: red; }`,
        `#${this._infoId} .spa-current-page { background-color: lightgray; }`,
        `#${this._infoId} kbd > kbd {` +
          ' font-size: 0.85em;' +
          ' padding: 0.07em;' +
          ' border-width: 1px;' +
          ' border-style: solid;' +
          '}',
        `#${this._infoId} p { margin-bottom: 1em; }`,
        `#${this._infoId} th { padding-top: 1em; text-align: left; }`,
        `#${this._infoId} td:first-child {` +
          ' white-space: nowrap;' +
          ' text-align: right;' +
          ' padding-right: 0.5em;' +
          '}',
        // The "color: unset" addresses dimming because these display-only
        // buttons are disabled.
        `#${this._infoId} button {` +
          ' border-width: 1px;' +
          ' border-style: solid;' +
          ' border-radius: 1em;' +
          ' color: unset;' +
          ' padding: 3px;' +
          '}',
        `#${this._infoId} ul {` +
          ' padding-inline: revert !important;' +
          '}',
        `#${this._infoId} button.spa-meatball { border-radius: 50%; }`,
        '',
      ];
      style.textContent = styles.join('\n');
      document.head.prepend(style);
    }

    /**
     * Create the Info dialog and add some static information.
     * @returns {Element} - Initialized dialog.
     */
    _initializeInfoDialog() {
      const dialog = document.createElement('dialog');
      dialog.id = this._infoId;
      const name = document.createElement('div');
      name.innerHTML = `<b>${GM.info.script.name}</b> - ` +
        `v${GM.info.script.version}`;
      const instructions = document.createElement('div');
      instructions.classList.add('spa-instructions');
      const left = VMKeyboardService.parseSeq('c-left');
      const right = VMKeyboardService.parseSeq('c-right');
      const esc = VMKeyboardService.parseSeq('esc');
      instructions.innerHTML =
        `<span class="left">Use the ${left} and ${right} keys or ` +
        'click to select tab</span>' +
        `<span class="right">Hit ${esc} to close</span>`;
      dialog.append(name, instructions);
      return dialog;
    }

    /**
     * Add basic dialog with an embedded tabbbed ui for the info view.
     * @param {TabbedUI~TabDefinition[]} tabs - Array defining the info tabs.
     */
    _addInfoDialog(tabs) {
      const dialog = this._initializeInfoDialog();

      this._info = new TabbedUI(`${this.#name} Info`);
      for (const tab of tabs) {
        this._info.addTab(tab);
      }
      // Switches to the first tab.
      this._info.goto(tabs[0].name);

      dialog.append(this._info.container);
      document.body.prepend(dialog);

      // Dialogs do not have a real open event.  We will fake it.
      dialog.addEventListener('open', () => {
        this._setKeyboardContext('inDialog', true);
        VMKeyboardService.setKeyboardContext('inDialog', true);
        this._tabUiKeyboard.enable();
        for (const {panel} of this._info.tabs.values()) {
          // 0, 0 is good enough
          panel.scrollTo(0, 0);
        }
      });
      dialog.addEventListener('close', () => {
        this._setKeyboardContext('inDialog', false);
        VMKeyboardService.setKeyboardContext('inDialog', false);
        this._tabUiKeyboard.disable();
      });
    }

    /** Set up everything necessary to get the info view going. */
    _initializeInfoView() {
      this._infoId = `info-${this.#id}`;
      this.#details.infoId = this._infoId;
      this._initializeTabUiKeyboard();

      const tabGenerators = [
        SPA._shortcutsTab(),
        this.#details.docTab(),
        this.#details.newsTab(),
        SPA._errorTab(),
        this.#details.licenseTab(),
      ];

      this._addInfoStyle();
      this._addInfoDialog(tabGenerators);
      this.#details.ui = this._info;
      this._addInfoViewHandlers();
    }

    _nextTab = () => {
      this._info.next();
    }

    _prevTab = () => {
      this._info.prev();
    }

    /**
     * Generate a unique id for page views.
     * @param {Page} page - An instance of the Page class.
     * @returns {string} - Unique identifier.
     */
    _pageInfoId(page) {
      return `${this._infoId}-${page.infoHeader}`;
    }

    /**
     * Add shortcut descriptions from the page to the shortcut tab.
     * @param {Page} page - An instance of the Page class.
     */
    _addInfo(page) {
      const shortcuts = document.querySelector(`#${this._infoId} tbody`);
      const section = NH.base.simpleParseWords(page.infoHeader).join(' ');
      const pageId = this._pageInfoId(page);
      let s = `<tr id="${pageId}"><th></th><th>${section}</th></tr>`;
      for (const {seq, desc} of page.allShortcuts) {
        const keys = VMKeyboardService.parseSeq(seq);
        s += `<tr><td>${keys}:</td><td>${desc}</td></tr>`;
      }
      // Don't include works in progress that have no keys yet.
      if (page.allShortcuts.length) {
        shortcuts.innerHTML += s;
        for (const button of shortcuts.querySelectorAll('button')) {
          button.disabled = true;
        }
      }
    }

    /**
     * Update Errors tab label based upon value.
     * @param {number} count - Number of errors currently logged.
     */
    _updateInfoErrorsLabel(count) {
      const me = 'updateInfoErrorsLabel';
      this.logger.entered(me, count);
      const label = this._info.tabs.get('Errors').label;
      if (count) {
        this._info.goto('Errors');
        label.classList.add('spa-danger');
      } else {
        label.classList.remove('spa-danger');
      }
      this.logger.leaving(me);
    }

    /**
     * Get the hot keys tab header element for this page.
     * @param {Page} page - Page to find.
     * @returns {?Element} - Element that acts as the header.
     */
    _pageHeader(page) {
      const me = 'pageHeader';
      this.logger.entered(me, page);
      let element = null;
      if (page) {
        const pageId = this._pageInfoId(page);
        this.logger.log('pageId:', pageId);
        element = document.querySelector(`#${pageId}`);
      }
      this.logger.leaving(me, element);
      return element;
    }

    /**
     * Highlight information about the page in the hot keys tab.
     * @param {Page} page - Page to shine.
     */
    _shine(page) {
      const me = 'shine';
      this.logger.entered(me, page);
      const element = this._pageHeader(page);
      element?.classList.add('spa-current-page');
      this.logger.leaving(me);
    }

    /**
     * Remove highlights from this page in the hot keys tab.
     * @param {Page} page - Page to dull.
     */
    _dull(page) {
      const me = 'dull';
      this.logger.entered(me, page);
      const element = this._pageHeader(page);
      element?.classList.remove('spa-current-page');
      this.logger.leaving(me);
    }

    /**
     * Add content to the Errors tab so the user can use it to file feedback.
     * @param {string} content - Information to add.
     */
    addError(content) {
      const errors = document.querySelector(
        `#${this._infoId} [data-spa-id="errors"]`
      );
      errors.value += `${content}\n`;

      if (content === SPA._errorMarker) {
        const event = new Event('change');
        errors.dispatchEvent(event);
      }
    }

    /**
     * Add a marker to the Errors tab so the user can see where different
     * issues happened.
     */
    addErrorMarker() {
      this.addError(SPA._errorMarker);
    }

    /**
     * Add a new page to those supported by this instance.
     * @param {function(SPA): Page} Klass - A {Page} class to instantiate.
     */
    register(Klass) {
      if (Klass.prototype instanceof Page) {
        const page = new Klass(this);
        page.start();
        this._addInfo(page);
        this.#pages.add(page);
      } else {
        throw new Error(`${Klass.name} is not a Page`);
      }
    }

    /**
     * Dump a bunch of information about an HTML element.
     * @param {Element} element - Element to get information about.
     * @param {string} name - What area this information came from.
     */
    dumpInfoAboutElement(element, name) {
      const msg = `An unsupported ${name} element was discovered:`;
      this.addError(msg);
      this.addError(element.outerHTML);
      this.addErrorMarker();
    }

    /**
     * Determine which page can handle this portion of the URL.
     * @param {string} pathname - A {URL.pathname}.
     * @returns {Set<Page>} - The pages to use.
     */
    _findPages(pathname) {
      const pages = Array.from(this.#pages.values());
      return new Set(pages.filter(page => page.pathname.test(pathname)));
    }

    /**
     * Handle switching from the old page (if any) to the new one.
     * @param {string} pathname - A {URL.pathname}.
     */
    activate(pathname) {
      const pages = this._findPages(pathname);
      const oldPages = new Set(this.#activePages);
      const newPages = new Set(pages);
      for (const page of oldPages) {
        newPages.delete(page);
      }
      for (const page of pages) {
        oldPages.delete(page);
      }
      for (const page of oldPages) {
        page.deactivate();
        this._dull(page);
      }
      for (const page of newPages) {
        page.activate();
        this._shine(page);
      }
      this.#activePages = pages;
    }

    /** @type {Set<Page>} - Currently active {Page}s. */
    #activePages = new Set();

    #details
    #id
    #logger
    #name
    #oldUrl

    /** @type {Set<Page>} - Registered {Page}s. */
    #pages = new Set();

    /**
     * Tampermonkey was the first(?) userscript manager to provide events
     * about URLs changing.  Hence the need for `@grant window.onurlchange` in
     * the UserScript header.
     * @fires Event#urlchange
     */
    #startUserscriptManagerUrlMonitor = () => {
      this.logger.log('Using Userscript Manager provided URL monitor.');
      window.addEventListener('urlchange', (info) => {
        // The info that TM gives is not really an event.  So we turn it into
        // one and throw it again, this time onto `document` where something
        // is listening for it.
        const newUrl = new URL(info.url);
        const evt = new CustomEvent('urlchange', {detail: {url: newUrl}});
        document.dispatchEvent(evt);
      });
    }

    /**
     * Install a long lived MutationObserver that watches
     * {SPADetails.urlChangeMonitorSelector}.  Whenever it is triggered, it
     * will check to see if the current URL has changed, and if so, send an
     * appropriate event.
     * @fires Event#urlchange
     */
    #startMutationObserverUrlMonitor = async () => {
      this.logger.log('Using MutationObserver for monitoring URL changes.');

      const observeOptions = {childList: true, subtree: true};

      const element = await NH.web.waitForSelector(
        this.#details.urlChangeMonitorSelector, 0
      );
      this.logger.log('element exists:', element);

      this.#oldUrl = new URL(window.location);
      new MutationObserver(() => {
        const newUrl = new URL(window.location);
        if (this.#oldUrl.href !== newUrl.href) {
          const evt = new CustomEvent('urlchange', {detail: {url: newUrl}});
          this.#oldUrl = newUrl;
          document.dispatchEvent(evt);
        }
      }).observe(element, observeOptions);
    }

    /** Select which way to monitor the URL for changes and start it. */
    #startUrlMonitor = () => {
      if (window.onurlchange === null) {
        this.#startUserscriptManagerUrlMonitor();
      } else {
        this.#startMutationObserverUrlMonitor();
      }
    }

    /**
     * Handle urlchange events that indicate a switch to a new page.
     * @param {CustomEvent} evt - Custom 'urlchange' event.
     */
    #onUrlChange = (evt) => {
      this.activate(evt.detail.url.pathname);
    }

  }

  NH.xunit.testing.run();

  const linkedIn = new LinkedIn(linkedInGlobals);

  // Inject some test errors
  if (litOptions.enableDevMode) {
    linkedIn.addSetupIssue('This is a dummy test issue.',
      'It was added because NH.xunit.testing is enabled.');
    linkedIn.addSetupIssue('This is a second issue.',
      'We just want to make sure things count properly.');
  }

  await linkedIn.ready;
  log.log('proceeding...');

  const spa = new SPA(linkedIn);
  spa.register(Global);
  spa.register(Feed);
  spa.register(MyNetwork);
  spa.register(Messaging);
  spa.register(InvitationManager);
  spa.register(Jobs);
  spa.register(JobCollections);
  spa.register(Notifications);
  spa.activate(window.location.pathname);

  log.log('Initialization successful.');

})();