LinkedIn Tool

Minor enhancements to LinkedIn. Mostly just hotkeys.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==UserScript==
// @name        LinkedIn Tool
// @namespace   [email protected]
// @match       https://www.linkedin.com/*
// @inject-into content
// @noframes
// @version     13
// @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/1787507/NH_xunit.js
// @require     https://update.greasyfork.org/scripts/477290/1788132/NH_base.js
// @require     https://update.greasyfork.org/scripts/478349/1798299/NH_userscript.js
// @require     https://update.greasyfork.org/scripts/478440/1798298/NH_web.js
// @require     https://update.greasyfork.org/scripts/478676/1787505/NH_widget.js
// @require     https://update.greasyfork.org/scripts/570146/1806650/NH_spa.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: 56},
    {name: 'base', minVersion: 54},
    {name: 'userscript', minVersion: 11},
    {name: 'web', minVersion: 13},
    {name: 'widget', minVersion: 46},
    {name: 'spa', minVersion: 6},
  ]);

  const APP_LONG = GM.info.script.name;
  const CKEY = 'componentkey';
  const OPTIONS = 'Options';
  const APP_SHORT = APP_LONG.split(' ')
    .at(NH.base.LAST_ITEM);
  const TOP_CARD = 'Topcard';

  /**
   * Save options to storage.
   *
   * @param {object} options - Options key/value pairs.
   */
  function saveOptions(options) {
    NH.userscript.setValue(OPTIONS, options);
  }

  /**
   * 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,
      enableAlertOldNews: false,
      enableAlertUnsupportedPages: false,
      enableAlertUnknownProfileSections: false,
      enableIssue241ClickMethod: false,
      enableIssue289Monitoring: false,
      fakeErrorRate: 0.8,
      latestNewsRead: 0,
    };
    const savedOptions = await NH.userscript.getValue(OPTIONS, {});
    const options = {
      ...defaultOptions,
      ...savedOptions,
    };
    saveOptions(options);
    return options;
  }

  const litOptions = await loadOptions();

  // eslint-disable-next-line require-atomic-updates
  NH.xunit.testing.enabled = litOptions.enableDevMode;

  // Inject some test errors
  if (litOptions.enableDevMode && Math.random() < litOptions.fakeErrorRate) {
    NH.base.issues.post('This is a dummy test issue.',
      'It was added because enableDevMode is true and' +
                        ` the fakeErrorRate is ${litOptions.fakeErrorRate}.`);
    NH.base.issues.post('This is a second issue.',
      'We just want to make sure things count properly.');
  }

  await NH.userscript.setAutoManageLoggerConfigs(true);

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

  const issuesForLinkedIn = new NH.base.MessageQueue();

  /** @param {...*} items - Posted issues. */
  function issueListener(...issues) {
    issuesForLinkedIn.post(...issues);
  }

  NH.base.issues.listen(issueListener);

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

  /** Encapsulate a GitHub (or similar) issue. */
  class GitHubIssue {

    /**
     * @param {string} issueId - Issue identifier, usually a GitHub number.
     * @param {string} title - The GitHub issue title.
     * @param {string} date - A Date parsable string of the last time the
     * issue was verified opened
     */
    constructor(issueId, title, date) {
      this.#issueId = issueId;
      this.#title = title;
      this.#date = new Date(date);
    }

    /** @type {Date} */
    get date() {
      return this.#date;
    }

    /** @type {string} */
    get issueId() {
      return this.#issueId;
    }

    /** @type {string} */
    get title() {
      return this.#title;
    }

    /** @returns {string} - Human readable information. */
    toString() {
      return `${this.issueId}: ${this.title}`;
    }

    #date
    #issueId
    #title

  }

  /**
   * @param {string} issueId - Issue identifier, usually a GitHub number.
   * @param {string} title - The GitHub issue title.
   * @param {string} date - A Date parsable string of the last time the
   * issue was verified opened
   * @returns {GitHubIssue} - New object.
   */
  function ish(issueId, title, date) {
    return new GitHubIssue(issueId, title, date);
  }

  const globalIssues = [
    ish('', 'Minor internal improvement', '9999'),
    ish('106', 'info view: more tabs: News, License', '2026-03-24'),
    ish(
      '119',
      'info view: Dismissing dialog while focus in textarea leaves' +
        ' keys disabled',
      '2026-04-27'
    ),
    ish('130', 'Factor hotkey handling out of SPA', '2026-03-24'),
    ish('167', 'Refactor into libraries', '2026-04-25'),
    ish('181', 'Add <b>Errors</b> tab to new modal view', '2026-04-22'),
    ish('182', 'Add <b>License</b> tab to new modal view', '2026-04-29'),
    ish('184', 'Fix <b>News</b> tab rendering', '2026-03-30'),
    ish('209', 'Support <b>SearchResultsPeople</b> view', '2026-04-09'),
    ish('232', '<code>Scroller</code>: Change the focus UX', '2026-03-29'),
    ish('236', 'Support <b>Events</b> page', '2026-04-10'),
    ish('238', 'eslint: Bad use of globals', '2026-04-10'),
    ish(
      '239',
      '<code>Scroller</code>: Active card loses shine upon revisit',
      '2026-04-25'
    ),
    ish(
      '240', '<code>Scroller</code>: navbar height can change', '2026-04-30'
    ),
    ish('244', 'Capture info about all unsupported pages', '2026-04-11'),
    ish(
      '245',
      'Revisit pages that use the terms "section{,s}" or "card{,s}"',
      '2026-04-30'
    ),
    ish(
      '251', 'Normalize the `uniqueFooIdentifier()` functions', '2026-04-22'
    ),
    ish(
      '252',
      'Move <code>LinkedInGlobals</code> into <code>LinkedIn</code>',
      '2026-04-10'
    ),
    ish('253', 'Support <b>My Network Events</b> page', '2026-04-03'),
    ish('255', 'Support <b>Search appearances</b> page', '2026-04-07'),
    ish('256', 'Support <b>Verify</b> page', '2026-04-08'),
    ish('257', 'Support <b>Analytics & tools</b> page', '2026-04-08'),
    ish(
      '272', 'Styling issue on <code>Information view</code>', '2026-04-23'
    ),
    ish('278', 'Update <b>Jobs</b> pages', '2026-04-24'),
    ish('279', 'Update <b>Messaging</b> page', '2026-04-25'),
    ish('280', 'Update <b>Invitation Manager</b> pages', '2026-04-26'),
    ish('281', 'Internal page structure changed mid-migration', '2026-04-27'),
    ish(
      '282',
      '<code>LinkedInToolbarService</code> needs a refresh',
      '2026-04-28'
    ),
    ish('283', 'Update <b>Notifications</b> page', '2026-04-29'),
    ish('284', 'Create a style monitoring feature', '2026-04-30'),
    ish('286',
      'Factor out <code>SPA</code> related code into a library',
      '2026-05-01'),
    ish(
      '288',
      'Add <code>Logger</code> startup ability to <code>userscript</code>',
      '2026-05-02'
    ),
    ish('290', 'Update <b>Profile</b> page', '2026-05-03'),
    ish('291', 'Update <b>Events</b> page', '2026-05-04'),
    ish('292', 'Update <b>SearchResultsPeople</b> page', '2026-05-05'),
    ish(
      '295',
      'Navigating from <b>Style-2</b> page to <b>Style-1</b> page breaks LIT',
      '2026-05-06'
    ),
    ish('296', 'Ugly and missing badges', '2026-05-07'),
    ish('297', 'Update <b>Profile</b> page (Style-2)', '2026-05-08'),
    ish(
      '298',
      '<code>Scroller</code>: <code>#isItemViewable()</code> is broken when' +
        ' item is an image',
      '2026-04-08'
    ),
    ish('301', '<b>JobsView</b>: Entries need tuning', '2026-04-13'),
    ish('302', '<b>Profile</b>: Entries need tuning', '2026-04-24'),
    ish('303', 'Keys are captured while editing text', '2026-04-21'),
  ];

  const globalNewsContent = [
    {
      date: '2026-05-02',
      issues: ['302'],
      subject: 'Explicitly capture <code>Profile</code> sections partial' +
        ' orderings',
    },
    {
      date: '2026-05-01',
      issues: ['130'],
      subject: 'Delete some of the <code>SPA</code> related handling of' +
        ' <code>News</code> and <code>Errors</code>',
    },
    {
      date: '2026-05-01',
      issues: ['302'],
      subject: 'Improve support for the <code>About</code> section',
    },
    {
      date: '2026-05-01',
      issues: ['130'],
      subject: 'Make the new <code>Info</code> view the only one',
    },
    {
      date: '2026-04-30',
      issues: ['302'],
      subject: 'Support the <code>Highlights</code> sections',
    },
    {
      date: '2026-04-29',
      issues: ['106'],
      subject: 'Tweak icon styling to match site',
    },
    {
      date: '2026-04-29',
      issues: ['184'],
      subject: 'Add a small highlight to <code>code</code> elements in' +
        ' the <code>Info</code> view',
    },
    {
      date: '2026-04-28',
      issues: ['182'],
      subject: 'Debounce loading the license with better state tracking',
    },
    {
      date: '2026-04-28',
      issues: ['286'],
      subject: 'Rework <code>Page</code> into an adapter for' +
        ' <code>spa/Page</code>',
    },
    {
      date: '2026-04-28',
      issues: ['302'],
      subject: 'Support the <em>Topcard</em>',
    },
    {
      date: '2026-04-27',
      issues: ['119'],
      subject: 'Force a <code>focus</code> event when closing the' +
        ' info dialog',
    },
    {
      date: '2026-04-25',
      issues: ['239'],
      subject: 'Attempt to reconnect current item',
    },
    {
      date: '2026-04-24',
      issues: ['302'],
      subject: 'Skip the Topcard carousel that some profiles have',
    },
    {
      date: '2026-04-24',
      issues: ['106'],
      subject: 'Revert "Remove an apparently no longer needed Style-2' +
        ' error badge tweak."',
    },
    {
      date: '2026-04-23',
      issues: ['106'],
      subject: 'Identify updates with a green badge and highlight the' +
        ' <code>News</code> tab',
    },
    {
      date: '2026-04-22',
      issues: ['298'],
      subject: 'Remove the text length check from' +
        ' <code>#isItemViewable()</code>',
    },
    {
      date: '2026-04-22',
      issues: ['106', '181'],
      subject: 'Hoist up some colors as CSS custom properties',
    },
    {
      date: '2026-04-21',
      issues: ['303'],
      subject: 'Include the <code>VM.shortcut</code> version in the' +
        ' bug reporting information',
    },
    {
      date: '2026-04-20',
      issues: ['130'],
      subject: 'Make <code>enableKeyboardService</code> the only' +
        ' implementation',
    },
    {
      date: '2026-04-20',
      issues: ['106'],
      subject: 'Remove an apparently no longer needed Style-2 error' +
        ' badge tweak',
    },
    {
      date: '2026-04-19',
      issues: ['106'],
      subject: 'Another tweak for icon for navbar placement',
    },
    {
      date: '2026-04-18',
      issues: ['106'],
      subject: 'Update the icon to better handle resizing/placement in' +
        ' the navbar',
    },
    {
      date: '2026-04-16',
      issues: ['232'],
      subject: 'Make <code>enableScrollerChangesFocus</code> the only' +
        ' implementation',
    },
    {
      date: '2026-04-14',
      issues: ['232'],
      subject: 'Update to latest version of <code>focusOnTree()</code> to' +
        ' simplify code',
    },
    {
      date: '2026-04-13',
      issues: ['252'],
      subject: 'Move the remaining items from <code>LinkedInGlobals</code>' +
        ' to <code>LinkedIn</code>',
    },
    {
      date: '2026-04-13',
      issues: ['301'],
      subject: 'Restrict secondary scrolling to <em>More jobs</em> in' +
        ' <code>JobsView</code>',
    },
    {
      date: '2026-04-12',
      issues: ['297'],
      subject: 'Partially handle the <code>About</code> section on the' +
        ' <code>Profile</code> page',
    },
    {
      date: '2026-04-11',
      issues: ['232'],
      subject: 'Ensure the primary item is visible during secondary' +
        ' scrolling',
    },
    {
      date: '2026-04-10',
      issues: ['297'],
      subject: 'Handle <em>Featured</em> and <em>Activity</em> sections on' +
        ' the <code>Profile</code> page',
    },
    {
      date: '2026-04-10',
      issues: ['130'],
      subject: 'Add <code>VMKeyboardService</code> to the' +
        ' <code>InvitationManager</code> page',
    },
    {
      date: '2026-04-10',
      issues: [''],
      subject: 'Update section selector for the <code>Jobs</code> page',
    },
    {
      date: '2026-04-10',
      issues: ['252'],
      subject: 'Migrated a few users of <code>focusOnElement()</code>' +
        ' to <code>focusOnTree()</code>',
    },
    {
      date: '2026-04-10',
      issues: ['232'],
      subject: 'Migrate to using the refactored version of' +
        ' <code>focusOnTree()</code>',
    },
    {
      date: '2026-04-09',
      issues: ['297'],
      subject: 'Handle the <em>Analytics</em> section on the' +
        ' <code>Profile</code> page',
    },
    {
      date: '2026-04-09',
      issues: [''],
      subject: 'Include the <code>fakeErrorRate</code> in the dummy/test' +
        ' error messages',
    },
    {
      date: '2026-04-08',
      issues: ['297'],
      subject: 'Match the <code>Show all</code> button on the' +
        ' <code>Profile</code> page',
    },
    {
      date: '2026-04-08',
      issues: ['232'],
      subject: 'Fire <code>focus</code>/<code>focused</code>' +
        ' events inside <code>Scroller</code>',
    },
    {
      date: '2026-04-08',
      issues: [''],
      subject: 'Update <code>pageReadySelector</code> for' +
        ' <code>Notifications</code>',
    },
    {
      date: '2026-04-08',
      issues: ['286'],
      subject: 'Move a couple of functions from' +
        ' <code>LinkedInGlobals</code> to <code>LinkedIn</code>',
    },
    {
      date: '2026-04-07',
      issues: [''],
      subject: 'Update <code>Feed</code> comments selector',
    },
    {
      date: '2026-04-07',
      issues: [''],
      subject: 'Update selector to load more <code>Feed</code> comments',
    },
    {
      date: '2026-04-07',
      issues: ['286'],
      subject: 'Move <code>ckeyIdentifier()</code> from' +
        ' <code>LinkedInGlobals</code> to <code>LinkedIn</code>',
    },
    {
      date: '2026-04-07',
      issues: ['298'],
      subject: 'Enhance a logging statement for easier filtering',
    },
    {
      date: '2026-04-07',
      issues: ['297'],
      subject: 'Add a list of known sections on the <code>Profile</code>' +
        ' page',
    },
    {
      date: '2026-04-07',
      issues: ['130'],
      subject: 'Put active keyboard services first in the new shortcuts tab',
    },
    {
      date: '2026-04-07',
      issues: ['232'],
      subject: 'Use <code>Scroller.focus()</code> for all focus attempts' +
        ' (behind option)',
    },
    {
      date: '2026-04-07',
      issues: ['251'],
      subject: 'Rename <code>uniqueIdentifier</code> to' +
        ' <code>uniqueNotificationIdentifier</code>',
    },
    {
      date: '2026-04-07',
      issues: ['286'],
      subject: 'Move <code>LinkedInGlobals.Style</code> to' +
        ' <code>LinkedIn.Style</code>',
    },
    {
      date: '2026-04-07',
      issues: [''],
      subject: 'Report old news before unused issues',
    },
    {
      date: '2026-04-07',
      issues: ['297'],
      subject: 'Partial update of <code>Profile</code> with Style-2 support',
    },
    {
      date: '2026-04-04',
      issues: ['232'],
      subject: 'Create a library wide <code>Logger</code> instance',
    },
    {
      date: '2026-04-04',
      issues: ['295'],
      subject: 'Modify how <code>HybridFixerService</code> detects a hybrid' +
        ' page',
    },
    {
      date: '2026-04-04',
      issues: ['251'],
      subject: 'Make all <code>Scroller~uidCallback</code> implementations' +
        ' consistent',
    },
    {
      date: '2026-04-04',
      issues: ['232'],
      subject: 'Allow <code>Jobs.focus()</code> to use the WIP' +
        ' <code>Scroller.focus()</code> work',
    },
    {
      date: '2026-04-04',
      issues: ['232'],
      subject: 'Update how the flagged version of' +
        ' <code>Scroller.focus()</code> is implemented',
    },
    {
      date: '2026-04-03',
      issues: ['297'],
      subject: 'Add a timeout to <code>Page.waitUntilReady</code>',
    },
    {
      date: '2026-04-03',
      issues: [''],
      subject: 'Update <code>Feed</code> comment selectors',
    },
  ];

  /**
   * 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 {

    /* XXX: This class is going away, so not bothering with fixing. */
    /* eslint-disable no-shadow */

    /**
     * @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(NH.base.ONE_ITEM);

      this.#log.leaving(me);
    }

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

      this.#switchTab(-NH.base.ONE_ITEM);

      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;' +
          '}',
        `#${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.
   * - 'focus' - Before the focus is set.
   * - 'focused' - After the focus is set.
   */
  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.
     */

    /**
     * Function that finds an DOM element based upon another one.
     *
     * Useful for cases where CSS selectors are not sufficient.
     * @callback ElementFinder
     * @param {HTMLElement} element - Starting point.
     * @returns {HTMLElement} - Found element.
     */

    /**
     * Common config for finding a clickable element inside the current item.
     *
     * Use only one of selectorArray or finder.
     *
     * @typedef {object} ClickConfig
     * @property {string[]} [selectorArray] - CSS selectors to use to find an
     * element, passed to {@link NH.web.clickElement}.
     * @property {boolean} [matchSelf=false] - If a CSS selector would match
     * base, then use it, {@link NH.web.clickElement}.
     * @property {ElementFinder} [finder] - Function to find the appropriate
     * clickable element, when a selectorArray is too simplistic.
     */

    /**
     * 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 {number} [maxUidLength=20] - Max length for default uid text.
     * @property {string[]} [classes=[]] - Array of CSS classes to add/remove
     * from an element as it becomes current.
     * @property {boolean} [watchForClicks=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 determine 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 watchForClicks.
     * @property {ClickConfig} [clickConfig={}] - Configures how the click()
     * method operates.
     */

    /**
     * @param {What} what - What we want to scroll.
     * @param {How} how - How we want to scroll.
     * @throws {Scroller.Exception} - On many construction problems.
     */
    constructor(what, how) {
      ({
        name: this.#name = 'Unnamed scroller',
        base: this.#base,
        selectors: this.#selectors,
        containerItems: this.#containerItems = [],
      } = what);
      ({
        uidCallback: this.#uidCallback,
        maxUidLength: this.#maxUidLength = Scroller.#defaults.MAX_UID_LENGTH,
        classes: this.#classes = [],
        watchForClicks: this.#watchForClicks =
        Scroller.#defaults.WATCH_FOR_CLICKS,
        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 =
        Scroller.#defaults.WAIT_FOR_ITEM,
        containerTimeout: this.#containerTimeout = 0,
        clickConfig: this.#clickConfig = {},
      } = 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 Exception = class extends NH.base.Exception {}

    /** @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 Scroller.Exception(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;
    }

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

    /**
     * Return normalized text for an element.
     *
     * Like HTMLElement.innerText, but cleaner and mostly deduped.
     *
     * @param {HTMLElement} element - Element to examine.
     * @returns {string} - The normalized text.
     */
    defaultUid(element) {
      const me = this.defaultUid.name;
      this.logger.entered(me, element);

      const texts = new Set();

      /**
       * @param {Node} node - Node to process.
       * @param {number} height - Height of last node that was an Element.
       */
      const recurse = (node, height) => {
        const currHeight = this.#realHeight(node) || height;
        if (node.nodeType === Node.TEXT_NODE) {
          const text = node.nodeValue.trim();
          if (text && currHeight > 1) {
            texts.add(text);
          }
        }
        for (const nextNode of node.childNodes) {
          recurse(nextNode, currHeight);
        }
      };
      recurse(element, this.#realHeight(element));

      let content = [...texts].join(' ');

      if (content.length > this.#maxUidLength) {
        this.logger.log(
          'exceeded maxUidLength', content.length, this.#maxUidLength
        );
        content = NH.base.strHash(content);
      }

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

    /**
     * @param {number} [pixels=0] - Used to determine if scrolling should
     * happen when {snapToTop} is false.
     * @returns {Scroller} - This instance, for chaining.
     */
    topMarginPixels(pixels = 0) {
      this.#topMarginPixels = pixels;
      return this;
    }

    /**
     * @param {number} [pixels=0] - Used to determine if scrolling should
     * happen when {snapToTop} is false.
     * @returns {Scroller} - This instance, for chaining.
     */
    bottomMarginPixels(pixels = 0) {
      this.#bottomMarginPixels = pixels;
      return this;
    }

    /**
     * @param {string} [css='0'] - CSS applied to `scrollMarginTop`.
     * @returns {Scroller} - This instance, for chaining.
     */
    topMarginCSS(css = '0') {
      this.#topMarginCSS = css;
      return this;
    }

    /**
     * @param {string} [css='0'] - CSS applied to `scrollMarginBottom`.
     * @returns {Scroller} - This instance, for chaining.
     */
    bottomMarginCSS(css = '0') {
      this.#bottomMarginCSS = css;
      return this;
    }

    /** Click either the current item OR document.activeElement. */
    click() {
      const me = 'click';
      const item = this.item;
      this.logger.entered(me, item);

      if (item) {
        if (this.#clickConfig.finder) {
          const result = this.#clickConfig.finder(item);
          if (result) {
            result.click();
          } else {
            NH.web.postInfoAboutElement(item,
              `the clickConfig function for ${this.name}`);
          }
        } else if (this.#clickConfig.selectorArray) {
          if (!NH.web.clickElement(
            item,
            this.#clickConfig.selectorArray,
            this.#clickConfig.matchSelf
          )) {
            NH.web.postInfoAboutElement(item,
              `the clickConfig selectorArray for ${this.name}`);
          }
        } else {
          NH.base.issues.post(`Scroller.click() for "${this.name}" was ` +
                            'called without a configuration');
        }
      } else {
        document.activeElement.click();
      }

      this.logger.leaving(me);
    }

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

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

    /** 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();
    }

    /**
     * Focus on current item.
     * @fires 'focus' 'focused'
     */
    focus() {
      const me = this.focus.name;
      this.logger.entered(me);

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

      this.shine();
      this.show();
      NH.web.focusOnTree(this.item);

      this.logger.leaving(me);
      this.dispatcher.fire('focused', null);
    }

    /**
     * Activate the scroller.
     * @fires 'activate'
     */
    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.#watchForClicks) {
          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.#mutationDispatcher.on('records', this.#monitorConnectedness);

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

      this.logger.leaving(me);
    }

    /**
     * Deactivate the scroller (but do not destroy it).
     * @fires 'deactivate'
     */
    deactivate() {
      this.#mutationDispatcher.off('records', this.#monitorConnectedness);
      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);
    }

    static #defaults = {};

    static {
      Scroller.#defaults.MAX_UID_LENGTH = 20;
      Scroller.#defaults.WAIT_FOR_ITEM = 3000;
      Scroller.#defaults.WATCH_FOR_CLICKS = true;

      Object.freeze(Scroller.#defaults);
    }

    /**
     * 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) => {
      const result = Boolean(item.clientHeight);
      return result;
    }

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

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

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

    /**
     * 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);
    }

    /**
     * Return the computed height of an element.
     *
     * The usual element.clientHeight is too unpredictable.
     *
     * @param {Element} element - Element to examine.
     * @returns {number} - The height of the element.
     */
    #realHeight = (element) => {
      const me = this.#realHeight.name;
      this.logger.entered(me, element);

      const height = element.getBoundingClientRect?.().height;

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

    /**
     * @param {MutationRecord[]} records - Standard mutation records.
     * @fires 'records'
     */
    #mutationHandler = (records) => {
      const me = 'mutationHandler';
      this.logger.entered(
        me, `records: ${records.length} type: ${records[0].type}`
      );

      this.#mutationDispatcher.fire('records', null);

      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.
     * @fires 'change'
     */
    #bottomHalf = (val) => {
      const me = 'bottomHalf';
      this.logger.entered(me, val);

      this.#currentItem = val;
      this.#currentItemId = this.#uid(val);
      const idx = this.#getItems()
        .indexOf(val);
      this.#historicalIdToIndex.set(this.#currentItemId, idx);
      this.focus();
      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.#postProcessItems(items);

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

    /**
     * Log items and do any fixups on them.
     * @param {[Element]} items - Elements in the Scroller.
     */
    #postProcessItems = (items) => {
      const me = 'postProcessItems';
      this.logger.starting(me, `count: ${items.length}`);

      const uids = new NH.base.DefaultMap(Array);
      for (const item of items) {
        this.logger.log(
          'item:', item, `isItemViewable: ${Scroller.#isItemViewable(item)}`
        );
        const uid = this.#uid(item);
        uids.get(uid)
          .push(item);
      }
      for (const [uid, list] of uids.entries()) {
        if (list.length > NH.base.ONE_ITEM) {
          this.logger.log(`${list.length} duplicates with "${uid}"`);
          for (const item of list) {
            // Try again, maybe they can be de-duped this time.  The overall
            // experience seems to work better if the uid is recalculated
            // right away, but yeah, a bit of a hack.
            delete item.dataset.scrollerId;
            this.#uid(item);
          }
        }
      }

      this.logger.finished(me, `uid count: ${uids.size}`);
    }

    /**
     * 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;
    }

    /**
     * If necessary, scroll the bottom into view, then same for top.
     * @param {HTMLElement} item - The item to scroll into view.
     */
    #gentlyScrollIntoView = (item) => {
      const me = 'gentlyScrollIntoView';
      this.logger.entered(me, item);

      item.style.scrollMarginBottom = this.#bottomMarginCSS;
      let rect = item.getBoundingClientRect();

      const allowedBottom = document.documentElement.clientHeight -
                this.#bottomMarginPixels;
      if (rect.bottom > allowedBottom) {
        this.logger.log('scrolling up onto page');
        item.scrollIntoView(false);
      }
      rect = item.getBoundingClientRect();
      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.  This also makes the current `Jobs`
      // *Recent job searches* secondary scroller a little wonky.  There are
      // invisible items that take up space at the bottom of the list, and
      // this causes the last visible one to scroll into the middle, leaving
      // blank space at the bottom of the view.
      item.scrollIntoView({block: 'nearest', inline: 'nearest'});

      this.logger.leaving(me);
    };

    /**
     * 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.item;

      if (item) {
        item.style.scrollMarginTop = this.#topMarginCSS;
        if (this.#snapToTop) {
          this.logger.log('snapping to top');
          item.scrollIntoView(true);
        } else {
          this.#gentlyScrollIntoView(item);
        }
      }

      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}`);

      const items = this.#getItems();
      if (items.length) {
        // eslint-disable-next-line no-extra-parens
        let idx = first ? 0 : (items.length - NH.base.ONE_ITEM);
        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 -= NH.base.ONE_ITEM;
            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) => {
      const me = 'scrollBy';
      this.logger.entered(me, n);

      /**
       * Keep viewable items and the current one.
       *
       * The current item may not yet be viewable after a reload, but give it
       * a chance.
       * @param {HTMLElement} item - Item to check.
       * @fires 'out-of-range'
       * @returns {boolean} - Whether to keep or not.
       */
      const filterItem = (item) => {
        if (Scroller.#isItemViewable(item)) {
          return true;
        }
        if (this.#uid(item) === this.#currentItemId) {
          return true;
        }
        return false;
      };

      const items = this.#getItems()
        .filter(item => filterItem(item));
      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 - NH.base.ONE_ITEM;
        }
        if (idx === NH.base.NOT_FOUND || idx >= items.length) {
          this.item = null;
          this.dispatcher.fire('out-of-range', null);
        } else {
          this.item = items[idx];
        }
      }

      this.logger.leaving(me);
    }

    /** @throws {Scroller.Exception} - On many validation issues. */
    #validateInstance = () => {
      this.#validateWhat();
      this.#validateHow();
    }

    /** @throws {Scroller.Exception} - On many validation issues. */
    #validateWhat = () => {
      if (this.#base && this.#containerItems.length) {
        throw new Scroller.Exception(
          `Cannot have both base AND containerItems: ${this.#name} has both`
        );
      }

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

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

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

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

    /** @throws {Scroller.Exception} - On many validation issues. */
    #validateHow = () => {
      if (!this.#uidCallback) {
        throw new Scroller.Exception(
          `Missing uidCallback: ${this.#name} has no uidCallback defined`
        );
      }

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

      if (this.#clickConfig.selectorArray && this.#clickConfig.finder) {
        throw new Scroller.Exception(
          `Invalid clickConfig: ${this.name} cannot have both ` +
            'selectorArray AND a finder function'
        );
      }

      if (this.#clickConfig.selectorArray) {
        if (!(this.#clickConfig.selectorArray instanceof Array)) {
          throw new Scroller.Exception(
            `Invalid clickConfig: ${this.#name} selectorArray is not an Array`
          );
        }
      }

      if (this.#clickConfig.finder) {
        if (!(this.#clickConfig.finder instanceof Function)) {
          throw new Scroller.Exception(
            `Invalid clickConfig: ${this.#name} finder property should be ` +
              'a function'
          );
        }

        if (this.#clickConfig.finder.length !== NH.base.ONE_ITEM) {
          throw new Scroller.Exception(
            `Invalid clickConfig: ${this.#name} finder function should ` +
                'take exactly one argument, currently takes ' +
                `${this.#clickConfig.finder.length}`
          );
        }
      }
    }

    /**
     * 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) {
        this.logger.log('container', container);
        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 = () => {  // eslint-disable-line max-lines-per-function
      const me = this.#currentItemWatcher.name;
      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...');
            if (litOptions.enableIssue289Monitoring) {
              NH.base.issues.post(`${me} timed out`);
            }
          };

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

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

    #monitorConnectedness = () => {
      const me = this.#monitorConnectedness.name;
      this.logger.entered(me, this.#currentItem);

      if (this.#currentItem && !this.#currentItem.isConnected) {
        this.goto(this.#currentItem);
        this.logger.log('current item reconnected');
      }

      this.logger.leaving(me);
    }

    /* eslint-disable require-jsdoc */
    static ScrollerTestCase = class extends NH.xunit.TestCase {

      testClassIsFrozen() {
        this.assertRaisesRegExp(TypeError, /is not extensible/u, () => {
          Scroller.#defaults.FIELD = 'field';
        });
      }

    }
    /* eslint-enable */

  }

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

  /* eslint-disable max-lines-per-function */
  /* eslint-disable no-empty-function */
  /* eslint-disable no-new */
  /* eslint-disable no-unused-vars */
  /* eslint-disable require-jsdoc */
  class ScrollerTestCase extends NH.xunit.TestCase {

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

      this.assertRaisesRegExp(
        Scroller.Exception,
        /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.Exception,
        /Cannot have both base AND containerItems:/u,
        () => {
          new Scroller(what, how);
        }
      );
    }

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

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

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

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

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

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

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

      this.assertNoRaises(() => {
        new Scroller(what, how);
      }, 'everything is in place');
    }

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

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

      how.uidCallback = {};

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

      how.uidCallback = () => {};

      this.assertNoRaises(() => {
        new Scroller(what, how);
      }, 'finally, good');
    }

    testValidClickConfig() {
      const what = {
        name: this.id,
        containerItems: [{}],
      };
      const how = {
        uidCallback: () => {},
      };

      this.assertNoRaises(() => {
        new Scroller(what, how);
      }, 'no clickConfig is fine');

      how.clickConfig = {};

      this.assertNoRaises(() => {
        new Scroller(what, how);
      }, 'empty clickConfig is fine');

      // Existence is what matters for this check, not correctness
      how.clickConfig = {
        selectorArray: {},
        finder: {},
      };

      this.assertRaisesRegExp(
        Scroller.Exception,
        /Invalid clickConfig: .*both selectorArray AND a finder function$/u,
        () => {
          new Scroller(what, how);
        },
        'both selectorArray and finder'
      );

      how.clickConfig = {selectorArray: 'string'};

      this.assertRaisesRegExp(
        Scroller.Exception,
        /Invalid clickConfig: .* selectorArray is not an Array$/u,
        () => {
          new Scroller(what, how);
        },
        'non-array'
      );

      how.clickConfig = {
        finder: (element) => {},
      };

      this.assertNoRaises(() => {
        new Scroller(what, how);
      }, 'single argument element finder is fine');

      how.clickConfig.finder = () => {};

      this.assertRaisesRegExp(
        Scroller.Exception,
        /Invalid clickConfig: .* 0$/u,
        () => {
          new Scroller(what, how);
        },
        'zero argument element finder is not fine'
      );

      how.clickConfig.finder = (a, b, c) => {};

      this.assertRaisesRegExp(
        Scroller.Exception,
        /Invalid clickConfig: .* 3$/u,
        () => {
          new Scroller(what, how);
        },
        'too many arguments element finder is not fine'
      );
    }

  }
  /* eslint-enable */

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

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

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

    /**
     * This becomes the current section.
     * @param {string} section - Name of the new section.
     * @returns {Element} - The new section.
     */
    addSection(section) {
      this.#currentSection = document.createElement('tbody');
      this.#currentSection.id = NH.base.safeId(`${this.id}-${section}`);
      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.#currentSection.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 documentation about this
     * function.
     * @param {NH.web.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 myself = this.bind(this);
      myself.seq = seq;
      myself.desc = desc;
      this.func = func;
      return myself;
    }

  }

  /**
   * Manage a {Scroller} via {NH.base.Service} with LIT idiosyncrasies.
   *
   * It will turn the {Scroller} on/off.
   * Apply any fixups for margin features.
   * Monitor margin for changes.
   */
  class LinkedInScrollerService extends NH.base.Service {

    /**
     * @param {string} instanceName - Custom portion of this instance.
     */
    constructor(instanceName) {
      super(instanceName);
      this.on('activate', this.#onActivate)
        .on('deactivate', this.#onDeactivate)
        .setScroller();
    }

    /**
     * @param {NH.base.Dispatcher} details - The {@link LinkedIn} instance.
     * @returns {LinkedInScrollerService} - This instance, for chaining.
     */
    setDetails(details) {
      this.#details = details;
      return this;
    }

    /**
     * Sets the {@link Scroller} to manage with this service.
     *
     * If not value is passed, any existing instance will be removed.
     *
     * @param {Scroller} [scroller] - The instance to manage.
     * @returns {LinkedInScrollerService} - This instance, for chaining.
     */
    setScroller(scroller = null) {
      this.#scroller = scroller;
      return this;
    }

    #details
    #scroller

    #onActivate = () => {
      this.#scroller?.activate();
    }

    #onDeactivate = () => {
      this.#scroller?.deactivate();
    }

  }

  /**
   * @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 are no known technical reason preventing other implementations
   * from being used.  Otherwise a different service would need to be written.
   *
   * 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/disabled as the service is activated/deactivated.  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 browser `focus` events to update 'inputFocus'
   * can be enabled by executing:
   *
   * @example
   * VMKeyboardService.start();
   */
  class VMKeyboardService extends NH.base.Service {

    /** @inheritdoc */
    constructor(instanceName) {
      super(instanceName);
      VMKeyboardService.#services.add(this);
      this.on('activate', this.#onActivate)
        .on('deactivate', this.#onDeactivate);
    }

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

    /** @param {string} val - New condition. */
    static set condition(val) {
      this.#shortcutOptions.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 === NH.base.ONE_ITEM) {
          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;
    }

    /**
     * @param {*} instance - Object with {Shortcut} properties.
     * @returns {VMKeyboardService} - This instance, for chaining.
     */
    addInstance(instance) {
      const me = this.addInstance.name;
      this.logger.entered(me, instance);

      if (this.#keyboards.has(instance)) {
        this.logger.log('Already registered');
      } else {
        const keyboard = new VM.shortcut.KeyboardService();
        for (const [key, value] of Object.entries(instance)) {
          if (value instanceof Shortcut) {
            // While we are here, give the function a name.
            Object.defineProperty(value, 'name', {value: key});
            keyboard.register(
              value.seq, value, VMKeyboardService.#shortcutOptions
            );
          }
        }
        this.#keyboards.set(instance, keyboard);
        this.#rebuildShortcuts();
      }

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

    /**
     * @param {*} instance - Object with {Shortcut} properties.
     * @returns {VMKeyboardService} - This instance, for chaining.
     */
    removeInstance(instance) {
      const me = this.removeInstance.name;
      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);
      return this;
    }

    static #focusOption = {
      capture: true,
    };

    static #lastFocusedElement = null

    static #services = new Set();

    /**
     * @type {VM.shortcut.IShortcutOptions} - Initial options for all
     * shortcuts.
     */
    static #shortcutOptions = {
      condition: '!inputFocus',
      caseSensitive: true,
    };

    /**
     * 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 = [];

    #onActivate = () => {
      for (const keyboard of this.#keyboards.values()) {
        keyboard.enable();
      }
      this.#active = true;
    }

    #onDeactivate = () => {
      for (const keyboard of this.#keyboards.values()) {
        keyboard.disable();
      }
      this.#active = false;
    }

    #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, text);
      }
    }

    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);

  /** LinkedIn specific information. */
  class LinkedIn extends NH.spa.Details {

    /** @inheritdoc */
    constructor() {
      super();
      this.#navbarMutationObserver = new MutationObserver(
        this.#navbarHandler
      );
      this.#navbarResizeObserver = new ResizeObserver(
        this.#navbarHandler
      );
      this.ready = this.#waitUntilPageLoadedEnough();

      this.#licenseElement = document.createElement('p');
      this.#licenseElement.innerHTML = '<i>Loading license...</i>';
    }

    static Style = {
      UNKNOWN: Symbol.for('Style-0'),
      ONE: Symbol.for('Style-1'),
      TWO: Symbol.for('Style-2'),
    }

    static {
      Object.freeze(LinkedIn.Style);
    }

    static errorMarker = '---';

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

    /** @type {string} - LinkedIn's common navigation bar. */
    static get primaryNavSelector() {
      return this.#primaryNavSelector;
    }

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

    /**
     * @returns {TabbedUI~TabDefinition} - Where to find documentation and
     * file bugs.
     */
    static aboutTab() {
      const issuesLink = this.#ghUrl('labels/linkedin-tool');
      const newIssueLink = this.#ghUrl('issues/new/choose');
      const newGfIssueLink = this.#gfUrl('feedback');
      const releaseNotesLink = this.#gfUrl('versions');

      const content = [
        `<p>This is information about the <b>${APP_LONG}</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'),
      };

      return tab;
    }

    /**
     * @param {string} variant - Migration text, one of `spa` or `lit`.
     * @returns {TabbedUI~TabDefinition} - Initial placeholder for error
     * logging.
     */
    static errorTab(variant) {
      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 ' +
            `"${this.errorMarker}".</p>`,
          '<p><b>Please remove any identifying information before ' +
            'including it in a bug report!</b></p>',
          this.errorPlatformInfo(),
          `<textarea data-${variant}-id="errors" spellcheck="false" ` +
            'placeholder="No errors logged yet."></textarea>',
        ].join(''),
      };
    }

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static ckeyIdentifier(element) {
      const me = LinkedIn.ckeyIdentifier.name;
      this.logger?.entered(me, element);

      const content = element?.getAttribute(CKEY);

      this.logger?.leaving(me, content);
      return content;
    }

    /**
     * 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();
      msgs.push('Other libraries:', ` VM.shortcut: ${VM.shortcut.version}`);

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

    /**
     * Combine text from child headers.
     *
     * @param {Element} element - Element to examine.
     * @param {number} level - Header level to use.
     * @returns {string} - Combined header text.
     */
    static hN(element, level) {
      return element.querySelectorAll(`h${level}`)
        .values()
        .map(x => x.innerText.trim())
        .toArray()
        .join('; ');
    }

    /**
     * Combine text from child headers.
     *
     * @param {Element} element - Element to examine.
     * @returns {string} - Combined header text.
     */
    static h1(element) {
      const level = 1;
      return this.hN(element, level);
    }

    /**
     * Combine text from child headers.
     *
     * @param {Element} element - Element to examine.
     * @returns {string} - Combined header text.
     */
    static h2(element) {
      const level = 2;
      return this.hN(element, level);
    }

    urlChangeMonitorSelector = 'html';

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

    /** @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.Exception) {
            this.logger.log('e:', e);
            NH.base.issues.post(e.message);
            this.#licenseData = {
              id: 'Unable to extract: Please file a bug',
              url: '',
            };
          }
        }
      }

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

    /** @type {HTMLElement} */
    get navbar() {
      return this.#navbar;
    }

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

    /** @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;
    }

    /** @type {LinkedIn.Style} */
    get pageStyle() {
      return this.#pageStyle;
    }

    /** @param {SPA} spa - The SPA instance. */
    init(spa) {
      this.dispatcher.fire('initialize', spa);
      this.dispatcher.fire('initialized', null);
    }

    /** Called by SPA. */
    done() {
      const me = 'done';
      this.logger.entered(me);

      this.#checkForNewRelease();

      this.#infoTabs.tabs
        .get('License').panel
        .addEventListener('expose', this.#licenseHandler);

      this.#infoTabs.tabs
        .get('News').panel.addEventListener('expose', this.#newsHandler);

      VMKeyboardService.condition = '!inputFocus && !inDialog';
      VMKeyboardService.start();

      this.logger.leaving(me);
    }

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

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

    /**
     * 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.navbarHeightPixels;
      how.topMarginCSS = this.navbarHeightCSS;
      how.bottomMarginCSS = '3em';

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

    /** Special processing to handle page transitions. */
    pageChanged() {
      const me = this.pageChanged.name;
      this.logger.entered(me);

      this.#navbarHandler();

      this.logger.leaving(me);
    }

    /**
     * @returns {TabbedUI~TabDefinition} - News information.
     */
    newsTab() {
      const me = this.newsTab.name;
      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:</p>',
        '<ul>',
        '<li>Added new features like support for new pages or more ' +
          'hotkeys</li>',
        '<li>Explicitly fixed a bug</li>',
        '<li>May cause a user noticeable change</li>',
        '</ul>',
        '<p></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) {
          const ki = knownIssues.get(issue);
          content.push(
            `<${issueHeader}>${ki.title}</${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;
    }

    /**
     * @returns {TabbedUI~TabDefinition} - License information.
     */
    licenseTab() {
      const me = this.licenseTab.name;
      this.logger.entered(me);

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

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

    static #FetchState = {
      EMPTY: Symbol.for('Empty'),
      FETCHING: Symbol.for('Fetching'),
      FETCHED: Symbol.for('Fetched'),
    }

    static {
      Object.freeze(LinkedIn.#FetchState);
    }

    static #asideSelector = [
      // Style 1
      'aside.scaffold-layout__aside',
      // Style 2
      '#workspace > div > div > div:nth-of-type(3)',
    ].join(', ');

    static #icon =
      '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24"' +
      ' fill="currentColor"' +
      ' viewBox="0 0 24 24" data-supported-dps="24x24">' +
      '<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>';

    static #primaryNavSelector = [
      // Style 1
      '#global-nav .global-nav__primary-items',
      // Style 2
      `nav[${CKEY}="primaryNavLinksComponentRef"] > ul`,
    ].join(', ');

    static #sidebarSelector = [
      // Style 1
      'aside.scaffold-layout__sidebar',
      // Style 2
      '#workspace > div > div > div:nth-of-type(1)',
    ].join(', ');

    /**
     * Create a Greasy Fork project URL.
     * @param {string} path - Portion of the URL.
     * @returns {string} - Full URL.
     */
    static #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.
     */
    static #ghUrl = (path) => {
      const base = 'https://github.com/nexushoratio/userscripts';
      const url = `${base}/${path}`;
      return url;
    }

    #badgeErrorResultsStyle2
    #badgeErrorStyle1
    #badgeErrorStyle2
    #badgeNewsResultsStyle2
    #badgeNewsStyle1
    #badgeNewsStyle2
    #dispatcher = new NH.base.Dispatcher('errors', 'news');
    #errorText
    #globals
    #iframeDoc
    #infoId
    #infoKeyboard
    #infoTabs
    #infoWidget
    #licenseData
    #licenseElement
    #licenseState = LinkedIn.#FetchState.EMPTY;
    #navbar
    #navbarDispatcher = new NH.base.Dispatcher('resize');
    #navbarHeightPixels = 0;
    #navbarMutationObserver
    #navbarResizeObserver
    #newsQueue = new NH.base.MessageQueue();
    #ourMenuItemStyle1
    #ourMenuItemStyle2
    #pageStyle
    #shortcutsWidget

    #checkForNewRelease = () => {
      const curr = parseFloat(GM.info.script.version);
      let prev = parseFloat(litOptions.latestNewsRead);

      if (isNaN(prev)) {
        prev = 0;
      }
      this.#newsQueue.post(curr > prev);
    }

    /**
     * @param {HTMLElement} element - Starting element to avoid another query.
     * @returns {LinkedIn.Style} - Guessed style.
     */
    #guessPageStyle = (element) => {
      const me = this.#guessPageStyle.name;
      this.logger.entered(me, element);

      const hint = element.closest('[id]').id;
      let pageStyle = null;

      switch (hint) {
        case 'global-nav':
          pageStyle = LinkedIn.Style.ONE;
          break;
        case 'root':
          pageStyle = LinkedIn.Style.TWO;
          break;
        default:
          pageStyle = LinkedIn.Style.UNKNOWN;
      }

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

    /** Hang out until the navigation bar has stabilized. */
    #waitUntilPageLoadedEnough = async () => {
      const me = 'waitOnPageLoadedEnough';
      this.logger.entered(me);

      // Wait for page to hopefully settle.
      await NH.web.waitForSelector(
        `${LinkedIn.primaryNavSelector} svg`
      );

      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.#addScrollerStyle();
      this.#addLitStyle();
      this.#findNavbar();

      this.logger.leaving(me);
    }

    /**
     * @param {Event} evt - The 'expose' event.
     */
    #newsHandler = (evt) => {
      const me = this.#newsHandler.name;
      this.logger.entered(me, evt.target);

      this.#newsQueue.post(false);
      litOptions.latestNewsRead = parseFloat(GM.info.script.version);
      saveOptions(litOptions);

      this.logger.leaving(me);
    }

    #licenseUpdateTabs = () => {
      const me = this.#licenseUpdateTabs.name;
      this.logger.entered(me);

      const litPanel = this.#infoTabs.tabs.get('License').panel;

      litPanel.replaceChildren(this.#licenseElement.cloneNode(true));

      this.logger.leaving(me);
    }

    /**
     * Lazily load license text when exposed.
     */
    #licenseHandler = async () => {
      const me = this.#licenseHandler.name;
      this.logger.entered(me, this.#licenseState);

      if (this.#licenseState === LinkedIn.#FetchState.EMPTY) {
        const {id, url} = this.licenseData;

        this.#licenseUpdateTabs();

        this.#licenseState = LinkedIn.#FetchState.FETCHING;
        const response = await fetch(url);
        if (response.ok) {
          const license = document.createElement('iframe');
          license.style.flexGrow = 1;
          license.title = id;
          license.sandbox = '';
          license.srcdoc = await response.text();
          this.#licenseElement = license;
          this.#licenseUpdateTabs();
          this.#licenseState = LinkedIn.#FetchState.FETCHED;
        } else {
          this.#licenseState = LinkedIn.#FetchState.EMPTY;
        }
      }

      this.logger.leaving(me);
    }

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

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

      widget.append(infoName, 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 nameElement = document.createElement('div');
      nameElement.classList.add('lit-justify');
      const title = `<b>${APP_LONG}</b> - v${GM.info.script.version}`;
      const dismiss = `<button id=${dismissId}>X</button>`;
      nameElement.innerHTML = `<span>${title}</span><span>${dismiss}</span>`;

      return nameElement;
    }

    /** @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');
    }

    /** Force any 'focus' handlers to run. */
    #forceFocusEvent = () => {
      document.activeElement.dispatchEvent(new Event('focus'));
    }

    #onCloseInfo = () => {
      this.#infoKeyboard.disable();
      VMKeyboardService.setKeyboardContext('inDialog', false);
      // Force this to run on the next event loop.
      setTimeout(this.#forceFocusEvent, 0);
      this.logger.log('info closed');
    }

    /** Create the CSS styles used for indicating the current items. */
    #addScrollerStyle = () => {
      const style = document.createElement('style');
      style.id = NH.base.safeId(`${this.id}-scroller-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 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 = [
        ':root {' +
          ' --lit-color-positive: #01754f;' +
          ' --lit-color-negative: #cb112d;' +
          '}',
        '.lit-positive {' +
          ' color: white;' +
          ' background-color: var(--lit-color-positive);' +
          '}',
        '.lit-negative {' +
          ' background-color: var(--lit-color-negative);' +
          '}',
        '.lit-info:modal {' +
          ' height: 100%;' +
          ' width: 65rem;' +
          ' font-size: 1.6rem;' +
          ' line-height: 1.5em;' +
          ' display: flex;' +
          ' flex-direction: column;' +
          '}',
        '.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 button {' +
          ' border-width: 1px;' +
          ' border-style: solid;' +
          ' border-radius: 1em;' +
          ' padding: 3px;' +
          '}',
        '.lit-info code {' +
          ' background-color: ButtonFace;' +
          ' font-family: monospace;' +
          '}',
        '.lit-info kbd > kbd {' +
          ' font-size: 0.85em;' +
          ' padding: 0.07em;' +
          ' border-width: 1px;' +
          ' border-style: solid;' +
          '}',
        '.lit-info p {' +
          ' margin-bottom: 1em;' +
          '}',
        '.lit-info ul {' +
          ' list-style: unset;' +
          ' padding-inline: revert;' +
          '}',
        '.lit-info th {' +
          ' padding-top: 1em;' +
          ' text-align: left;' +
          '}',
        '.lit-info td:first-child {' +
          ' white-space: nowrap;' +
          ' text-align: right;' +
          ' padding-right: 0.5em;' +
          '}',
        '.lit-info textarea[data-lit-id="errors"] {' +
          ' flex-grow: 1;' +
          ' resize: none;' +
          '}',
        '.lit-kbd-service-active th {' +
          ' background-color: lightgray;' +
          '}',
        '.lit-menu-badge-news-style1 {' +
          ' top: 1.35rem !important;' +
          ' --color-alert: var(--lit-color-positive) !important;' +
          '}',
        '.lit-menu-badge-news-style2 {' +
          ' align-items: center;' +
          ' background-color: var(--lit-color-positive);' +
          ' border-radius: 1.2rem;' +
          ' color-scheme: light;' +
          ' color: white;' +
          ' display: flex;' +
          ' font-size: 1.2rem;' +
          ' font-weight: 600;' +
          ' height: 1.6rem;' +
          ' inset-block-start: -0.2rem;' +
          ' inset-inline-start: 100%;' +
          ' justify-content: center;' +
          ' margin-inline-start: -0.8rem;' +
          ' min-width: 1.6rem;' +
          ' padding-inline-end: 0.4rem;' +
          ' padding-inline-start: 0.4rem;' +
          ' position: absolute;' +
          ' top: 1.6rem;' +
          ' z-index: 100;' +
          '}',
        '.lit-menu-badge-news-style2::after {' +
          ' background: white;' +
          ' border-radius: 50%;' +
          ' content: "";' +
          ' height: 0.6rem;' +
          ' width: 0.6rem;' +
          '}',
        '.lit-menu-badge-error {' +
          ' align-items: center;' +
          ' background-color: var(--lit-color-negative);' +
          ' border-radius: 1.2rem;' +
          ' color-scheme: light;' +
          ' color: white;' +
          ' display: flex;' +
          ' font-size: 1.2rem;' +
          ' font-weight: 600;' +
          ' height: 1.6rem;' +
          ' inset-block-start: -0.2rem;' +
          ' inset-inline-start: 100%;' +
          ' justify-content: center;' +
          ' margin-inline-start: -0.8rem;' +
          ' min-width: 1.6rem;' +
          ' padding-inline-end: 0.4rem;' +
          ' padding-inline-start: 0.4rem;' +
          ' position: absolute;' +
          ' width: fit-content;' +
          ' z-index: 100;' +
          '}',
        // Get rid of the donut
        '.lit-menu-badge-error::after {' +
          ' content: none !important;' +
          '}',
        '.lit-menu-badge-hide {' +
          ' opacity: 0;' +
          '}',
      ];
      style.textContent = styles.join('\n');
      document.head.prepend(style);
    }

    /**
     * Update Errors tab label based upon value.
     *
     * @param {number} count - Number of errors currently logged.
     */
    #updateInfoErrorsLabel = (count) => {
      const me = this.#updateInfoErrorsLabel.name;
      this.logger.entered(me, count);

      const label = this.#infoTabs.tabs.get('Errors').label;
      if (count) {
        this.#infoTabs.goto('Errors');
        label.classList.add('lit-negative');
      } else {
        label.classList.remove('lit-negative');
      }

      this.logger.leaving(me);
    }

    /** @param {Event} evt - The 'change' event. */
    #errorTextHandler = (evt) => {
      const me = this.#errorTextHandler.name;
      this.logger.entered(me, evt);

      const count = evt.target.value
        .split('\n')
        .filter(x => x === LinkedIn.errorMarker).length;
      this.dispatcher2.fire('errors', count);
      this.#updateInfoErrorsLabel(count);

      this.logger.leaving(me);
    }

    #addInfoTabsHandlers = () => {
      const me = this.#addInfoTabsHandlers.name;
      this.logger.entered(me);

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

      this.#errorText = document.querySelector('[data-lit-id="errors"]');
      this.#errorText.addEventListener('change', this.#errorTextHandler);
      issuesForLinkedIn.listen(this.#issueListener);

      this.logger.leaving(me);
    }

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

      const tabs = [
        this.#shortcutsTab(),
        LinkedIn.aboutTab(),
        this.newsTab(),
        LinkedIn.errorTab('lit'),
        this.licenseTab(),
      ];

      this.#infoTabs = new TabbedUI(APP_LONG);

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

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

      this.#addInfoTabsHandlers();

      this.logger.leaving(me);
    }

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

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

    #toolButtonHandler = () => {
      this.#infoWidget.open();
    }

    /**
     * Determine the style property differences between two elements.
     *
     * @param {Element} el1 - The first element.
     * @param {Element} el2 - The second element.
     * @param {Set<string>} ignore - A collection of style properties to
     * ignore.
     * @returns {[string]} - Style properties present in the first, but not
     * the second element, formatted to add to this source file.
     */
    #findMissingStyleProperties = (el1, el2, ignore) => {
      const me = this.#findMissingStyleProperties.name;
      this.logger.entered(me, el1, el2, ignore);

      const missing = new Map();
      const styles1 = getComputedStyle(el1);
      const styles2 = getComputedStyle(el2);
      const set1 = new Set([...styles1]);
      const set2 = new Set([...styles2]);
      for (const prop of set1.union(set2)
        .difference(ignore)) {
        const val1 = styles1.getPropertyValue(prop);
        const val2 = styles2.getPropertyValue(prop);
        if (val1 !== val2) {
          missing.set(prop, val1);
        }
      }

      const results = [];
      for (const [key, value] of missing.entries()) {
        results.push(`' ${key}: ${value};' +`);
      }

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

   /**
    * Update news badge as appropriate.
    *
    * @implements {NH.base.Dispatcher~Handler}
    * @param {string} eventType - Event type.
    * @param {boolean} show - Whether to show the badge or not.
    */
   #newsHandlerBadgeStyle1 = (eventType, show) => {
     const me = this.#newsHandlerBadgeStyle1.name;
     this.logger.entered(me, eventType, show);

     if (show) {
       this.#badgeNewsStyle1.classList.add('notification-badge--show');
     } else {
       this.#badgeNewsStyle1.classList.remove('notification-badge--show');
     }

     this.logger.leaving(me);
   }

    /**
     * Update error badge as appropriate.
     *
     * @implements {NH.base.Dispatcher~Handler}
     * @param {string} eventType - Event type.
     * @param {number} count - Number of errors currently logged.
     */
    #errorHandlerBadgeStyle1 = (eventType, count) => {
      const me = this.#errorHandlerBadgeStyle1.name;
      this.logger.entered(me, eventType, count);

      this.#badgeErrorStyle1
        .querySelector('.notification-badge__count').innerText = `${count}`;

      if (count) {
        this.#badgeErrorStyle1.classList.add('notification-badge--show');
      } else {
        this.#badgeErrorStyle1.classList.remove('notification-badge--show');
      }

      this.logger.leaving(me);
    }

    /**
     * Tweak the internals of whatever random element we cloned.
     *
     * @param {HTMLElement} button - The newly created button.
     */
    #finishButtonStyle1 = (button) => {
      const title = button.querySelector('.global-nav__primary-link-text');
      title.innerText = APP_SHORT;
      title.setAttribute('title', APP_SHORT);

      button.querySelector('li-icon')
        .setAttribute('type', APP_SHORT.toLowerCase());
    }

    /** @param {Element} element - Element that will hold the badges. */
    #assembleBadgesStyle1 = (element) => {
      this.#badgeErrorStyle1 = element.querySelector(
        '.notification-badge'
      );
      this.#badgeNewsStyle1 = this.#badgeErrorStyle1.cloneNode(true);
      this.#badgeNewsStyle1
        .classList.add('lit-menu-badge-news-style1');
      this.#badgeErrorStyle1.after(this.#badgeNewsStyle1);

      // Style-1 badges are easy to switch between counting or not.  This
      // makes sure we are in the correct mode for each badge.
      let count = this.#badgeErrorStyle1
        .querySelector('.notification-badge__no-count');
      count?.classList.remove('notification-badge__no-count');
      count?.classList.add('notification-badge__count');
      count = this.#badgeNewsStyle1
        .querySelector('.notification-badge__count');
      count?.classList.remove('notification-badge__count');
      count?.classList.add('notification-badge__no-count');

      let a11y = this.#badgeErrorStyle1.querySelector('.a11y-text');
      if (a11y) {
        a11y.innerText = `${APP_LONG} error count`;
      }
      a11y = this.#badgeNewsStyle1.querySelector('.a11y-text');
      if (a11y) {
        a11y.innerText = `${APP_LONG} news notifications`;
      }

    }

    #createMenuItemStyle1 = () => {
      const me = this.#createMenuItemStyle1.name;
      this.logger.entered(me, this.#navbar);

      // Making the assumption that the there is at least one item with a
      // badge and it is an anchor.
      let item = this.#navbar
        .querySelector('.global-nav__primary-item:has(.notification-badge)');
      const subItem = item.querySelector('a');
      item = item.cloneNode(false);

      const button = document.createElement('button');
      button.classList.add('global-nav__primary-link');

      button.append(...Array.from(subItem.childNodes)
        .map(x => x.cloneNode(true))
        .map((x) => {
          x.removeAttribute?.('id');
          return x;
        }));

      const svg = button.querySelector('svg');
      if (svg) {
        svg.parentElement.innerHTML = LinkedIn.#icon;
        item.append(button);

        this.#finishButtonStyle1(button);
        this.#assembleBadgesStyle1(button);

        button.addEventListener('click', this.#toolButtonHandler);
        this.#ourMenuItemStyle1 = item;
        this.dispatcher2.on('errors', this.#errorHandlerBadgeStyle1);
        this.dispatcher2.on('news', this.#newsHandlerBadgeStyle1);
      }

      this.logger.leaving(me, this.#ourMenuItemStyle1);
    }

   /**
    * Update news badge as appropriate.
    *
    * @implements {NH.base.Dispatcher~Handler}
    * @param {string} eventType - Event type.
    * @param {boolean} show - Whether to show the badge or not.
    */
   #newsHandlerBadgeStyle2 = (eventType, show) => {
     const me = this.#newsHandlerBadgeStyle2.name;
     this.logger.entered(me, eventType, show, this.#badgeNewsStyle2);

     if (show) {
       this.#badgeNewsStyle2.classList.remove('lit-menu-badge-hide');
     } else {
       this.#badgeNewsStyle2.classList.add('lit-menu-badge-hide');
     }

     this.logger.leaving(me);
   }

    /**
     * Updates error badge as appropriate.
     *
     * @implements {NH.base.Dispatcher~Handler}
     * @param {string} eventType - Event type.
     * @param {number} count - Number of errors currently logged.
     */
    #errorHandlerBadgeStyle2 = (eventType, count) => {
      const me = this.#errorHandlerBadgeStyle2.name;
      this.logger.entered(me, eventType, count);

      this.#badgeErrorStyle2.innerText = `${count}`;

      if (count) {
        this.#badgeErrorStyle2
          .classList.remove('lit-menu-badge-hide');
      } else {
        this.#badgeErrorStyle2.classList.add('lit-menu-badge-hide');
      }

      this.logger.leaving(me);
    }

    /**
     * Tweak the internals of whatever random element we cloned.
     *
     * @param {HTMLElement} button - The newly created button.
     */
    #finishButtonStyle2 = (button) => {
      // Grab the common obfuscated class names
      const buttons = this.#navbar.querySelectorAll('li > button');
      const buttonClasses = new Set(buttons[0].classList)
        .intersection(new Set(buttons[1].classList));

      button.ariaLabel = APP_SHORT;
      button.removeAttribute('aria-current');
      button.className = [...buttonClasses].join(' ');

      const textNodes = Array.from(button.querySelectorAll('*'))
        .filter(el => el.childNodes[0]?.nodeType === Node.TEXT_NODE);

      textNodes[0].innerText = APP_SHORT;
    }

    /** @param {Element} element - Element that will hold the badges. */
    #assembleBadgesStyle2 = (element) => {
      this.#badgeErrorStyle2 = document.createElement('span');
      this.#badgeErrorStyle2.classList.add('lit-menu-badge-error');
      this.#badgeNewsStyle2 = document.createElement('span');
      this.#badgeNewsStyle2.classList.add('lit-menu-badge-news-style2');
      element.append(this.#badgeErrorStyle2, this.#badgeNewsStyle2);
    }

    #createMenuItemStyle2 = () => {
      const me = this.#createMenuItemStyle2.name;
      this.logger.entered(me, this.#navbar);

      const item = this.#navbar
        .querySelector('li')
        .cloneNode(true);

      // The page may not have settled down yet, so check each bit carefully.
      const button = item.querySelector('button');
      if (button) {
        const svg = button.querySelector('svg');
        if (svg) {
          const svgParent = svg.parentElement;
          svg.outerHTML = LinkedIn.#icon;

          button.querySelector('svg + span')
            ?.remove();

          this.#finishButtonStyle2(button);
          this.#assembleBadgesStyle2(svgParent);

          button.addEventListener('click', this.#toolButtonHandler);
          this.#ourMenuItemStyle2 = item;
          this.dispatcher2.on('errors', this.#errorHandlerBadgeStyle2);
          this.dispatcher2.on('news', this.#newsHandlerBadgeStyle2);
        }
      }

      this.logger.leaving(me, this.#ourMenuItemStyle2);
    }

    /**
     * Connect our menu item to the navbar if necessary.
     *
     * It will always go after "Me" menu item.
     *
     * This supports both Styles 1 and 2.
     *
     * @param {HTMLElement} menuItem - The menu item to connect.
     * @param {string} selector - The CSS selector for "Me".
     */
    #connectMenuItem = (menuItem, selector) => {
      const me = this.#connectMenuItem.name;
      this.logger.entered(me, menuItem, selector);

      if (this.#navbar) {
        if (!menuItem.isConnected) {
          this.logger.log('Will connect menu item to', this.navbar);

          const navMe = this.#navbar.querySelector(selector)
            ?.closest('li');
          this.logger.log('navMe', navMe);

          if (navMe) {
            navMe.after(menuItem);
          } else {
            // If the site changed and we cannot insert ourself after the Me
            // menu item, then go first.
            this.#navbar.prepend(menuItem);
            NH.base.issues.post(
              'Unable to find the Profile navbar item.',
              'LIT menu installed in non-standard location.'
            );
          }
          this.#refreshErrors();
          this.#checkForNewRelease();
        }
      }

      this.logger.leaving(me);
    }

    #ensureMenuStyle1 = () => {
      const me = this.#ensureMenuStyle1.name;
      this.logger.entered(me, this.#ourMenuItemStyle1);

      if (this.#pageStyle === LinkedIn.Style.ONE) {
        if (!this.#ourMenuItemStyle1) {
          this.#createMenuItemStyle1();
        }
        if (this.#ourMenuItemStyle1) {
          this.#connectMenuItem(this.#ourMenuItemStyle1, '.global-nav__me');
        }
      }

      this.logger.leaving(me);
    }

    #compareBadgeErrorStyle2 = () => {
      const me = this.#compareBadgeErrorStyle2.name;
      this.logger.entered(me);

      // Only do this once.
      if (!this.#badgeErrorResultsStyle2 &&
          this.#badgeErrorStyle2?.isConnected) {
        this.logger.log('checking error badge', this.#badgeErrorStyle2);
        // Some badges are bad examples, so skip them using :not().
        const badges = this.navbar
          .querySelectorAll('svg:not([id^="home"]) + span');
        if (badges.length > NH.base.ONE_ITEM) {
          const ignore = new Set([
            'inline-size',
            'inset-inline-end',
            'opacity',
            'perspective-origin',
            'right',
            'transform-origin',
            'width',
          ]);
          const results = this.#findMissingStyleProperties(
            badges[0], this.#badgeErrorStyle2, ignore
          );
          if (results.length) {
            NH.base.issues.post(
              'Style-2 error badge needs updating:', results.join('\n')
            );
          }
          this.#badgeErrorResultsStyle2 = results;
        }
      }

      this.logger.leaving(me);
    }

    #compareBadgeNewsStyle2 = () => {
      const me = this.#compareBadgeNewsStyle2.name;
      this.logger.entered(me);

      // Only do this once.
      if (!this.#badgeNewsResultsStyle2 &&
          this.#badgeNewsStyle2?.isConnected) {
        this.logger.log('checking error badge', this.#badgeNewsStyle2);
        const badge = this.navbar
          .querySelector('svg[id^="home"] + span');
        if (badge) {
          const ignore = new Set([
            'background-color',
            'bottom',
            'inset-block-end',
            'inset-block-start',
            'opacity',
            'top',
          ]);
          const results = this.#findMissingStyleProperties(
            badge, this.#badgeNewsStyle2, ignore
          );
          if (results.length) {
            NH.base.issues.post(
              'Style-2 news badge needs updating:', results.join('\n')
            );
          }
          this.#badgeNewsResultsStyle2 = results;
        }
      }

      this.logger.leaving(me);
    }

    #compareBadgesStyle2 = () => {
      const me = this.#compareBadgesStyle2.name;
      this.logger.entered(me);

      this.#compareBadgeErrorStyle2();
      this.#compareBadgeNewsStyle2();

      this.logger.leaving(me);
    }

    /**
    * Update News tab label as appropriate.
    *
    * @param {boolean} highlight - Whether to show the badge or not.
    */
    #updateInfoNewsLabel = (highlight) => {
      const me = this.#updateInfoNewsLabel.name;
      this.logger.entered(me, highlight);

      const litLabel = this.#infoTabs.tabs.get('News').label;

      // Cannot automatically use `goto()` to focus the tab as that would then
      // trigger the mark read feature.
      if (highlight) {
        litLabel.classList.add('lit-positive');
      } else {
        litLabel.classList.remove('lit-positive');
      }

      this.logger.leaving(me);
    }

    /** Decisions about news could be made before the UI is available. */
    #newsListener = (...msgs) => {
      const me = this.#newsListener.name;
      this.logger.entered(me, msgs);

      for (const msg of msgs) {
        this.dispatcher2.fire('news', msg);
        this.#updateInfoNewsLabel(msg);
      }

      this.logger.leaving(me);
    }

    #ensureMenuStyle2 = () => {
      const me = this.#ensureMenuStyle2.name;
      this.logger.entered(me, this.#ourMenuItemStyle2);

      if (this.#pageStyle === LinkedIn.Style.TWO) {
        if (!this.#ourMenuItemStyle2) {
          this.#createMenuItemStyle2();
        }
        if (this.#ourMenuItemStyle2) {
          this.#connectMenuItem(this.#ourMenuItemStyle2, 'li:last-child');
        }
        this.#compareBadgesStyle2();
      }

      this.logger.leaving(me);
    }

    /** Find the nav links and ensure observers. */
    #findNavbar = () => {
      const me = this.#findNavbar.name;
      this.logger.entered(me, this.#navbar?.isConnected);

      if (!this.#iframeDoc) {
        const iframe = document
          .querySelector('iframe[data-testid]')
          ?.contentDocument;
        // Do not track the iframe until it has settled a bit.
        if (iframe && iframe.URL !== 'about:blank') {
          this.#iframeDoc = iframe;
        }
      }

      let doObserve = !this.#navbar?.isConnected;

      const navbar = document.querySelector(
        LinkedIn.primaryNavSelector
      ) || this.#iframeDoc
        ?.querySelector(LinkedIn.primaryNavSelector);

      if (navbar) {
        const pageStyle = this.#guessPageStyle(navbar);
        doObserve ||= pageStyle !== this.#pageStyle;
        this.#pageStyle = pageStyle;
      }

      if (this.#navbar && navbar) {
        doObserve ||= !this.#navbar.isSameNode(navbar);
      }

      if (doObserve) {
        this.#navbar = navbar;
        this.#observeNavbar();
      }

      this.logger.leaving(me, this.#navbar);
    }

    /** Reset observers for the navbar. */
    #observeNavbar = () => {
      const me = this.#observeNavbar.name;
      this.logger.entered(me, this.#navbar);

      this.#navbarMutationObserver.disconnect();
      this.#navbarResizeObserver.disconnect();

      if (this.#iframeDoc?.head) {
        this.#navbarMutationObserver.observe(
          this.#iframeDoc.head, {childList: true, subtree: true}
        );
      }

      if (this.#navbar) {
        this.#navbarMutationObserver.observe(
          this.#navbar, {childList: true, subtree: true}
        );
        this.#navbarResizeObserver.observe(this.#navbar);
      }

      this.logger.leaving(me);
    }

    /**
     * Recheck various items after a change to the navbar.
     * @fires 'resize'
     */
    #navbarHandler = () => {
      const me = this.#navbarHandler.name;
      this.logger.entered(me);

      const margin = 4;

      this.#findNavbar();

      if (this.#navbar) {
        this.#ensureMenuStyle1();
        this.#ensureMenuStyle2();

        this.logger.log('Raw navbar height is', this.#navbar.clientHeight);
        this.navbarHeightPixels = this.#navbar.clientHeight + margin;

        this.#navbarDispatcher.fire('resize', this);
      }

      this.logger.leaving(me);
    }

    /**
     * @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 = this.#buildShortcutsInfo.name;
      this.logger.entered(me);

      this.#shortcutsWidget.clear();

      const activeFirst = [
        ...VMKeyboardService.services.values()
          .filter(x => x.active),
        ...VMKeyboardService.services.values()
          .filter(x => !x.active),
      ];
      for (const service of activeFirst) {
        this.logger.log('service:', service.shortName, service.active);
        // Works in progress may not have any shortcuts yet.
        if (service.shortcuts.length) {
          const parsedName = NH.base.simpleParseWords(service.shortName)
            .join(' ');
          const section = this.#shortcutsWidget.addSection(service.shortName);
          if (service.active) {
            section.classList.add('lit-kbd-service-active');
          }
          this.#shortcutsWidget.addHeader('', parsedName);
          for (const shortcut of service.shortcuts) {
            this.logger.log('shortcut:', shortcut);
            this.#shortcutsWidget.addData(
              `${VMKeyboardService.parseSeq(shortcut.seq)}:`, shortcut.desc
            );
          }
        }
      }

      this.logger.leaving(me);
    }

    /**
     * Post problems about stale issues.
     *
     * @param {Set<string>} unknown - Issue ids referenced in news items but
     * not in {@link globalKnownIssues}.
     * @param {Set<string>} unused - Stale {@link globalKnownIssues} ids.
     * @param {Set<string>} old - Stale {@link globalNewsContent} entries.
     */
    #reportIssueProblems = (unknown, unused, old) => {
      for (const item of unknown) {
        NH.base.issues.post('Unknown issue detected:', item);
      }

      for (const item of old) {
        NH.base.issues.post('Old news item:', item);
      }

      for (const item of unused.values()) {
        NH.base.issues.post('Unused issue detected:', item);
      }
    }

    /** @returns {obj} - dates and known issues. */
    #preprocessKnownIssues = () => {
      const thirtyDays = 30 * 24 * 60 * 60 * 1000;  // eslint-disable-line no-magic-numbers
      const oldestAllowedDate = litOptions.enableAlertOldNews
        ? Date.now() - thirtyDays
        : 0;

      const knownIssues = new Map(globalIssues.map(x => [x.issueId, x]));
      const unknownIssues = new Set();
      const unusedIssues = new Map(
        knownIssues
          .entries()
          .filter(x => x[1].date < oldestAllowedDate)
      );
      const oldItems = new Set();

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

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

      this.#reportIssueProblems(unknownIssues, unusedIssues, oldItems);

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

    /** Send `change` event to the errors text area. */
    #refreshErrors = () => {
      const evt = new Event('change');
      this.#errorText.dispatchEvent(evt);
    }

    /**
     * Add content to the Errors tab so the user can use it to file feedback.
     * @param {string} content - Information to add.
     */
    #addError = (content) => {
      this.#errorText.value += `${content}\n`;

      if (content === LinkedIn.errorMarker) {
        this.#refreshErrors();
      }
    }

    /**
     * Add a marker to the Errors tab so the user can see where different
     * issues happened.
     */
    #addErrorMarker = () => {
      this.#addError(LinkedIn.errorMarker);
    }

    #issueListener = (...issues) => {
      for (const issue of issues) {
        this.#addError(issue);
      }
      this.#addErrorMarker();
    }

  }

  /** TODO(#295): This is a hack.  Find a more principled solution. */
  class HybridFixerService extends NH.base.Service {

    /**
     * @param {string} instanceName - Custom portion of this instance.
     * @param {Page} page - Page this service is tied to.
     */
    constructor(instanceName, page) {
      super(instanceName);
      this.#page = page;
      this.on('activate', this.#onActivate);
    }

    #page

    #onActivate = () => {
      const pageStyle = this.#page.spa.details.pageStyle;
      const main = document.querySelector('main')?.id;
      if (pageStyle === LinkedIn.Style.ONE && main === 'workspace') {
        this.logger.log('hybrid mode, reloading');
        document.location.reload();
      }
    }

  }

  /**
   * Verify a {Page} implementation and current site style match.
   *
   * It will post a bug on mismatches.
   */
  class LinkedInStyleService extends NH.base.Service {

    /**
     * @param {string} instanceName - Custom portion of this instance.
     * @param {Page} page - Page this service is tied to.
     */
    constructor(instanceName, page) {
      super(instanceName);
      this.#page = page;
      this.on('activate', this.#onActivate);
    }

    /**
     * @param {...LinkedIn.Style} styles - Styles allowed for the page.
     * @returns {LinkedInStyleService} - This instance, for chaining.
     */
    addStyles(...styles) {
      for (const style of styles) {
        this.#allowedStyles.add(style);
      }
      return this;
    }

    #allowedStyles = new Set();
    #page

    #onActivate = () => {
      if (!this.#allowedStyles.has(this.#page.spa.details.pageStyle)) {
        const style = this.#page.spa.details.pageStyle.toString()
          .replace('Symbol(', '')
          .replace(')', '');
        NH.base.issues.post([
          `The page "${this.shortName}" was activated`,
          `with unsupported style: ${style}`,
        ].join(' '));
      }
    }

  }

  /**
   * Helper for pages that have an extra drop-down toolbar.
   *
   * Some LinkedIn pages have an extra toolbar that will drop down and obscure
   * content.  This makes it difficult for `LinkedIn.navbarScrollerFixup()` to
   * properly adjust.
   *
   * For those pages, use this Service which will activate once to do the
   * initial fixups, then the additional ones necessary for that page.
   */
  class LinkedInToolbarService extends NH.base.Service {

    /**
     * @param {string} instanceName - Custom portion of this instance.
     * @param {Page} page - Page this service is tied to.
     */
    constructor(instanceName, page) {
      super(instanceName);
      this.#page = page;
      this.#postHook = () => {};  // eslint-disable-line no-empty-function
      this.on('activate', this.#onActivate);
    }

    /**
     * @param {...Scroller~How} hows - How types to update.
     * @returns {LinkedInToolbarService} - This instance, for chaining.
     */
    addHows(...hows) {
      for (const how of hows) {
        this.#scrollerHows.add(how);
      }
      return this;
    }

    /**
     * Often a {Page} would like to do a bit more initialization after this
     * fixups.  That is what this hook is for.
     *
     * @param {NH.web.SimpleFunction} hook - Function to call post activation.
     * @returns {LinkedInToolbarService} - This instance, for chaining.
     */
    postActivateHook(hook) {
      this.#postHook = hook;
      return this;
    }

    #activatedOnce = false;
    #page
    #postHook
    #scrollerHows = new Set();

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

      if (!this.#activatedOnce) {
        const toolbarElement = document.querySelector(
          '.scaffold-layout-toolbar'
        );
        this.logger.log('toolbar:', toolbarElement);

        if (toolbarElement) {
          for (const how of this.#scrollerHows) {
            this.logger.log('how:', how);
            this.#page.spa.details.navbarScrollerFixup(how);

            const newHeight = how.topMarginPixels +
                  toolbarElement.clientHeight;
            const newCSS = `${newHeight}px`;

            how.topMarginPixels = newHeight;
            how.topMarginCSS = newCSS;
          }

          this.#postHook();
        }
      }

      this.#activatedOnce = true;

      this.logger.leaving(me);
    }

  }

  /**
   * Adapt the new NH.spa.Page to the older implementation.
   */
  class Page extends NH.spa.Page {

    /**
     * @typedef {NH.spa.PageDetails} PageDetails
     * @deprecated @property {string} [pageName=name] - See {@link name}.
     * @deprecated @property {string} [pageReadySelector=readySelector] -
     * See {@link readySelector}.
     */

    /** @param {PageDetails} details - Details about the instance. */
    constructor(details = {}) {
      if (new.target === Page) {
        throw new TypeError('Abstract class; do not instantiate directly.');
      }

      // Adapt old to new.
      const {
        readySelector: readySelector = details.pageReadySelector,
        name: pageName = details.pageName,
      } = details;
      details.readySelector = readySelector;
      details.name = pageName;

      super(details);
      this.logger.log('Adapter page constructed', this);
    }

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

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

    /** @type {string} - Machine readable name for the page. */
    get pageId() {
      return this.id;
    }

    /** @type {string} - Human readable name for the page. */
    get pageName() {
      return this.name;
    }

    /**
     * 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.
     */
    activate() {
      this.dispatcher.fire('activate');
    }

    /**
     * Turns off this Page's features.  Called by {@link SPA} when this is no
     * longer the current view.
     */
    deactivate() {
      this.dispatcher.fire('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();

    /**
     * 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 {

    /** @param {NH.base.Logger} logger - Logger to use. */
    constructor(logger) {
      this.#logger = logger;
    }

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

    activeElement = new Shortcut(
      'c-c c-a',
      'Log the active element',
      () => {
        this.#logger.log('activeElement', document.activeElement);
      }
    );

    #logger

  }

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

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

      this.addService(HybridFixerService, this);

      this.addService(LinkedInStyleService, this)
        .addStyles(LinkedIn.Style.ONE, LinkedIn.Style.TWO);

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

      if (litOptions.enableAlertUnsupportedPages) {
        this.addService(Global.#Activator, this);
      }
    }

    info = new Shortcut(
      '?',
      'Show this information view',
      () => {
        if (this.spa.details.pageStyle === LinkedIn.Style.ONE) {
          this.#gotoNavButton(APP_SHORT);
        } else {
          this.#gotoNavLabel(APP_SHORT);
        }
      }
    );

    gotoSearch = new Shortcut(
      '/',
      'Go to Search box',
      () => {
        if (this.spa.details.pageStyle === LinkedIn.Style.ONE) {
          NH.web.clickElement(document, ['#global-nav-search button']);
        } else {
          const element = document.querySelector(
            `[${CKEY}="SearchResults_SearchTyahInputRef"]`
          );
          NH.web.focusOnElement(element);
        }
      }
    );

    goHome = new Shortcut(
      'g h',
      'Go Home (aka, Feed)',
      () => {
        if (this.spa.details.pageStyle === LinkedIn.Style.ONE) {
          this.#gotoNavLink('feed');
        } else {
          this.#gotoNavLabel('Home');
        }
      }
    );

    gotoMyNetwork = new Shortcut(
      'g w',
      'Go to My Network',
      () => {
        if (this.spa.details.pageStyle === LinkedIn.Style.ONE) {
          this.#gotoNavLink('mynetwork');
        } else {
          this.#gotoNavLabel('My Network');
        }
      }
    );

    gotoJobs = new Shortcut(
      'g j',
      'Go to Jobs',
      () => {
        if (this.spa.details.pageStyle === LinkedIn.Style.ONE) {
          this.#gotoNavLink('jobs');
        } else {
          this.#gotoNavLabel('Jobs');
        }
      }
    );

    gotoMessaging = new Shortcut(
      'g m',
      'Go to Messaging',
      () => {
        if (this.spa.details.pageStyle === LinkedIn.Style.ONE) {
          this.#gotoNavLink('messaging');
        } else {
          this.#gotoNavLabel('Messaging');
        }
      }
    );

    gotoNotifications = new Shortcut(
      'g n',
      'Go to Notifications',
      () => {
        if (this.spa.details.pageStyle === LinkedIn.Style.ONE) {
          this.#gotoNavLink('notifications');
        } else {
          this.#gotoNavLabel('Notifications');
        }
      }
    );

    gotoProfile = new Shortcut(
      'g p',
      'Go to Profile (aka, Me)',
      () => {
        if (this.spa.details.pageStyle === LinkedIn.Style.ONE) {
          this.#gotoNavButton('Me');
        } else {
          // Nothing easy to identify, so assume always after Notification
          this.spa.details
            .navbar
            .querySelector('[aria-label^="Notifications"]')
            .closest('li')
            .nextSibling
            .querySelector('button')
            .click();
        }
      }
    );

    gotoBusiness = new Shortcut(
      'g b',
      'Go to Business',
      () => {
        if (this.spa.details.pageStyle === LinkedIn.Style.ONE) {
          this.#gotoNavButton('Business');
        } else {
          this.#gotoNavLabel('For Business');
        }
      }
    );

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

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

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

    /** Called on every page activation to reset observers. */
    async activate() {
      await super.activate();
      this.spa.details.pageChanged();
    }

    static #Activator = class extends NH.base.Service {

      /**
       * @param {string} instanceName - Custom portion of this instance.
       * @param {Page} page - Page this service is tied to.
       */
      constructor(instanceName, page) {
        super(instanceName);
        this.#page = page;
        this.on('activate', this.#onActivate);
      }

      #page

      /** Called each time service is activated. */
      #onActivate = () => {
        const me = 'onActivate';
        this.logger.entered(me);

        if (this.#page.spa.activePages.size === NH.base.ONE_ITEM) {
          const pathname = window.location.pathname;
          /* eslint-disable prefer-regex-literals */
          const knownUrlsTodo = [
            // TODO(#237): Support *SpecificEvent* pages
            RegExp('^/events/.*/(?:about|comments)/.*', 'u'),
            // TODO(#253): Support *My Network Events* page
            '/mynetwork/network-manager/events/',
            // TODO(#255): Support *Search appearances* page
            '/analytics/search-appearances/',
            // TODO(#256): Support *Verify* page
            RegExp('/verify/?.*', 'u'),
            // TODO(#257): Support *Analytics & tools* page
            '/dashboard/',
            // TODO(#260): Support *My Jobs* page
            RegExp('^/my-items/saved-jobs/.*', 'u'),
            // TODO(#261): Support *Follow Page* Page
            '/suggested-for-you/follow-page/',
            // TODO(#262): Support *Analytics Posts* Page
            '/analytics/creator/content/',
            // TODO(#263): Support *Feed update* Page
            '/feed/update/',
            // TODO(#264): Support *Saved Posts* Page
            '/my-items/saved-posts/',
            // TODO(#265): Support *Post analytics* Page
            '/analytics/post-summary/',
            // TODO(#266): Support *Company* Page
            '/company/',
          ];
          /* eslint-enable */

          if (!knownUrlsTodo.some(x => pathname.match(x))) {
            NH.base.issues.post('Unsupported page:', window.location);
          }
        }

        this.logger.leaving(me);
      }

    }

    /**
     * Click on the requested link in the global nav bar.
     * @param {string} item - Portion of the link to match.
     */
    #gotoNavLink = (item) => {
      const me = this.#gotoNavLink.name;
      this.logger.entered(me, item);

      // The navbar elements may be split across two containers.  So we start
      // at the navbar, move up to find a container that has the link.
      const target = `a[href*="/${item}"]`;
      this.spa.details.navbar
        .closest(`:has(${target})`)
        .querySelector(target)
        .click();

      this.logger.leaving(me);
    }

    /**
     * Click on the requested button in the global nav bar.
     * @param {string} item - Text on the button to look for.
     */
    #gotoNavButton = (item) => {
      const me = 'gotoNavButton';
      this.logger.entered(me, item);

      Array.from(
        this.spa.details.navbar.querySelectorAll('button')
      )
        .find(el => el.textContent.includes(item))
        ?.click();

      this.logger.leaving(me);
    }

    /**
     * Click on the requested element in the Style-2 global nav bar.
     *
     * This uses the `aria-label`, which has the potential to be translated.
     * @param {string} item - The prefix for the target `aria-label`.
     */
    #gotoNavLabel = (item) => {
      const me = this.#gotoNavLabel.name;
      this.logger.entered(me, item);

      // The navbar elements may be split across two containers.  So we start
      // at the navbar, move up to find a container that has the label, then
      // back down.
      const target = `[aria-label^="${item}"]`;
      this.spa.details.navbar
        .closest(`:has(${target})`)
        .querySelector(target)
        .click();

      this.logger.leaving(me);
    }

  }

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

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

      this.addService(LinkedInStyleService, this)
        .addStyles(LinkedIn.Style.TWO);

      this.addService(VMKeyboardService)
        .addInstance(this);

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

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

      this.#lastScroller = this.#postScroller;
    }

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniquePostIdentifier(element) {
      const me = Feed.uniquePostIdentifier.name;
      this.logger.entered(me, element);

      let content = '';
      const key = LinkedIn.ckeyIdentifier(element);
      const groups = Feed.#uidPostRE.exec(key)?.groups;

      if (key) {
        content = key;
      }
      if (groups) {
        content = groups.body;
      }
      if (!content) {
        content = this.defaultUid(element);
      }

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

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueCommentIdentifier(element) {
      const me = Feed.uniqueCommentIdentifier.name;
      this.logger.entered(me, element);

      let content = '';
      const key = LinkedIn.ckeyIdentifier(element);
      const groups = Feed.#uidCommentRE.exec(key)?.groups;

      if (key) {
        content = key;
      }
      if (groups) {
        content = groups.body;
      }
      if (!content) {
        content = this.defaultUid(element);
      }

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

    /** @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('change', this.#onCommentChange)
          .on('out-of-range', this.#returnToPost);
      }

      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',
      () => {
        this.#lastScroller.focus();
      }
    );

    showComments = new Shortcut(
      'c',
      'Show comments',
      () => {
        const el = this.posts.item;
        // Check for the "Load more" button first, otherwise we just keep
        // clicking on the first comment button which does nothing useful
        // after the first batch of comments is loaded.
        NH.web.clickElement(el, [
          // Load more comments
          `[${CKEY}*="LoadMoreComments"] button`,
          // Inside post body
          '[role="button"]',
        ]);
      }
    );

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

    loadMorePosts = new Shortcut(
      'l',
      'Load more posts (if the <button>New Posts</button> button ' +
        'is available, load those)', () => {  // eslint-disable-line max-lines-per-function
        const me = this.loadMorePosts.name;
        this.logger.entered(me);

        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 = document.querySelector(
            'main [data-testid="mainFeed"] > div:nth-of-type(3) button'
          );
          if (topButton?.checkVisibility()) {
            topButton.click();
            first = true;
          } else {
            // If there is not top button, there should always be a button at
            // the bottom to click.
            const botButton =
                  'main [data-testid="mainFeed"] > div:last-child button';
            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: `${this.pageId} ${me}`,
          base: document.querySelector('main [data-testid="mainFeed"]'),
        };
        const how = {
          trigger: trigger,
          action: action,
          duration: 2000,
        };
        NH.web.otrot2(what, how);

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

    viewReactions = new Shortcut(
      'v r',
      'View reactions on current post or comment',
      () => {
        const el = this.#getItemStatusBar();
        NH.web.clickElement(el, ['a:has([role])']);
      }
    );

    viewReposts = new Shortcut(
      'v R',
      'View reposts of current post',
      () => {
        const el = this.#getPostStatusBar();
        NH.web.clickElement(el, ['a:not(:has([role]))']);
      }
    );

    openMeatballMenu = new Shortcut(
      '=',
      'Open closest <button class="spa-meatball">⋯</button> menu',
      () => {
        const el = this.#getItemHeader();
        NH.web.clickElement(el, [':has(> * > svg[id^="overflow"])']);
      }
    );

    likeItem = new Shortcut(
      'L',
      'Like current post or comment',
      () => {
        const el = this.#getItemFooter();
        NH.web.clickElement(el, [':has(> * > svg[id^="chevron-up"])']);
      }
    );

    commentOnItem = new Shortcut(
      'C',
      'Comment on current post or comment',
      () => {
        const el = this.#getItemFooter();
        NH.web.clickElement(el, [
          // For post
          ':has(> * > svg[id^="comment"])',
          // For comment
          ':scope > div > button',
        ]);
      }
    );

    repost = new Shortcut(
      'R',
      'Repost current post',
      () => {
        const el = this.#getPostFooter();
        NH.web.clickElement(el, [':has(> * > svg[id^="repost"])']);
      }
    );

    sendPost = new Shortcut(
      'S',
      'Send current post privately',
      () => {
        const el = this.#getPostFooter();
        NH.web.clickElement(el, [':has(> * > svg[id^="send"])']);
      }
    );

    gotoShare = new Shortcut(
      'P',
      `Go to the share box to start a post or ${Feed.#tabSnippet} ` +
        'to the other creator options',
      () => {
        document
          .querySelector(`main [data-testid="mainFeed"] a[${CKEY}]`)
          .focus();
      }
    );

    toggleItem = new Shortcut(
      'X',
      'Toggle hiding current item',
      async () => {
        const me = this.toggleItem.name;
        this.logger.entered(me);

        const el = this.#lastScroller.item;

        const target = await this.#getDismissElement();

        /** Trigger function for {@link NH.web.otrot}. */
        function trigger() {
          target.click();
        }
        if (target) {
          const what = {
            name: `${this.pageId} ${me}`,
            base: el,
          };
          const how = {
            trigger: trigger,
            timeout: 3000,
          };
          await NH.web.otrot(what, how);
          this.#lastScroller.item = el;
        }

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

    nextPostPlus = new Shortcut(
      'J',
      'Toggle hiding current post, then next post',
      async () => {
        this.#returnToPost();
        await this.toggleItem();
        this.nextPost();
      }
    );

    prevPostPlus = new Shortcut(
      'K',
      'Toggle hiding current post, then previous post',
      async () => {
        this.#returnToPost();
        await this.toggleItem();
        this.prevPost();
      }
    );

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

    /** @type {Scroller~What} */
    static #commentsWhat = {
      name: `${this.name} comments`,
      selectors: [
        [
          // Regular
          `[${CKEY}*=":comment:"]:has(> * > [${CKEY}*=":comment:"])`,
          // Dismissed
          `[${CKEY}*=":comment:"]:has(> * > * > [${CKEY}^="hiddenComment"])`,
        ].join(','),
      ],
    };

    /** @type {Page~PageDetails} */
    static #details = {
      // eslint-disable-next-line prefer-regex-literals
      pathname: RegExp('^/feed/?', 'u'),
      pageReadySelector: 'main > div > div > div',
    };

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

    /** @type {Scroller~What} */
    static #postsWhat = {
      name: `${this.name} posts`,
      containerItems: [
        {
          container: 'main [data-testid="mainFeed"]',
          items: [
            // Regular items
            '[role="listitem"]',
            // Dismissed item placeholders
            `div[${CKEY}^="collapsed"]`,
          ].join(','),
        },
      ],
    };

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

    static #uidCommentRE =
    /^(?:replaceableComment_urn:li:comment:\()?(?<body>.*)\)/u;

    static #uidPostRE = /^(?:expanded|collapsed)?(?<body>.*)FeedType/u;

    #commentScroller
    #lastScroller
    #postScroller

    /** @returns {HTMLElement} - Header container for current post. */
    #getPostHeader = () => {
      const me = this.#getPostHeader.name;
      this.logger.entered(me);

      const el = this.posts.item?.querySelector([
        // Regular
        'h2 + div',
        // Dismissed
        'div:has(+ hr)',
      ].join(','));

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

    /** @returns {HTMLElement} - Header container for current comment. */
    #getCommentHeader = () => {
      const me = this.#getCommentHeader.name;
      this.logger.entered(me);

      const el = this.comments
        ?.item?.querySelector('div:has(> div ~ button)');

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

    /** @returns {HTMLElement} - Header container for current item. */
    #getItemHeader = () => {
      const me = this.#getItemHeader.name;
      this.logger.entered(me);

      const el = this.#getCommentHeader() || this.#getPostHeader();

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

    /** @returns {HTMLElement} - Footer container for current post. */
    #getPostFooter = () => {
      const me = this.#getPostFooter.name;
      this.logger.entered(me);

      const el = this.posts.item
        ?.querySelector('div:has(> h2) > div:last-of-type');

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

    /** @returns {HTMLElement} - Footer container for current comment. */
    #getCommentFooter = () => {
      const me = this.#getCommentFooter.name;
      this.logger.entered(me);

      // Comment Footer and StatusBar use the same query.
      const el = this.comments?.item
        ?.querySelector('div:has(> hr)');

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

    /** @returns {HTMLElement} - Footer container for current item. */
    #getItemFooter = () => {
      const me = this.#getItemFooter.name;
      this.logger.entered(me);

      const el = this.#getCommentFooter() || this.#getPostFooter();

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

    /** @returns {HTMLElement} - StatusBar container for current post. */
    #getPostStatusBar = () => {
      const me = this.#getPostStatusBar.name;
      this.logger.entered(me);

      const el = this.posts.item
        ?.querySelector('div:has(> a [role="presentation"])');

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

    /** @returns {HTMLElement} - StatusBar container for current comment. */
    #getCommentStatusBar = () => {
      const me = this.#getCommentStatusBar.name;
      this.logger.entered(me);

      // Comment Footer and StatusBar use the same query.
      const el = this.comments?.item
        ?.querySelector('div:has(> hr)');

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

    /** @returns {HTMLElement} - StatusBar container for current item. */
    #getItemStatusBar = () => {
      const me = this.#getItemStatusBar.name;
      this.logger.entered(me);

      const el = this.#getCommentStatusBar() || this.#getPostStatusBar();

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

    /**
     * Find the correct element to dismiss the current item.
     *
     * Comments and ads require invoking a popup menu (portal).
     *
     * @returns {HTMLElement} - The element to click.
     */
    #getDismissElement = async () => {  // eslint-disable-line max-lines-per-function, max-statements
      const me = this.#getDismissElement.name;
      this.logger.entered(me, this.#lastScroller.item);

      let el = null;

      if (this.#lastScroller.item) {
        const portalSelector = '[data-floating-ui-portal]';
        const timeout = 2000;
        if (document.querySelector(portalSelector)) {
          document.dispatchEvent(
            new KeyboardEvent('keydown', {key: 'Escape'})
          );
          await this.#waitForSelectorToBeGone(portalSelector, timeout);
        }

        // If the current item is a regular post, this will match.
        let selector = [
          // Visible
          ':has(> * > svg[id^="close"])',
          // Dismissed
          'button:has(> span > span)',
        ].join(',');
        let header = this.#getItemHeader();
        el = header?.querySelector(selector);
        if (!el) {
          // Some items need to trigger a popup menu.
          this.openMeatballMenu();
          try {
            // The menus take a while to populate.  Even though known types of
            // menus look to have the same "cancelled eye" icon, they are
            // currently brought in differently.  One menu type uses one named
            // "visibility-off-*" while another menu has no id.  Interestingly
            // enough, the one without an id is the only svg icon without a
            // name so, :not([id]) is used for finding it proper, while a
            // sibling named "signal" is used waiting for the menu to settle.
            header = await NH.web.waitForSelector([
              portalSelector,
              ':has(svg[id^="visibility-off"], svg[id^="signal"])',
            ].join(''), timeout);
            selector = [
              ':has(> * > svg[id^="visibility-off"])',
              ':has(> * > svg:not([id])',
            ].join(',');
          } catch (e) {
            // If the menu was slow, use a simpler selector.
            selector = 'svg:not([id])';
          }
          el = header?.querySelector(selector);
        }
      }

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

    /**
     * Wait for matching selector to disappear.
     *
     * This could probably be rolled into {@link NH.web.waitForSelector}.
     *
     * @param {string} selector - CSS selector.
     * @param {number} [timeout=0] - Time to wait in milliseconds, 0 disables.
     * @returns {Promise<NH.web.Continuation.results>} - Basically, something
     * to await on.
     */
    #waitForSelectorToBeGone = (selector, timeout = 0) => {

      /**
       * @implements {Monitor}
       * @returns {Continuation} - Indicate whether done monitoring.
       */
      const monitor = () => {
        const element = document.querySelector(selector);
        if (element) {
          this.logger.log(`match for ${selector}`, element);
          return {done: false};
        }
        this.logger.log('And gone');
        return {done: true};
      };

      const what = {
        name: this.#waitForSelectorToBeGone.name,
        base: document,
      };

      const how = {
        observeOptions: {childList: true, subtree: true},
        monitor: monitor,
        timeout: timeout,
      };

      return NH.web.otmot(what, how);
    }

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

      /**
       * Wait for the post to be reloaded.
       * @implements {NH.web.Monitor}
       * @returns {NH.web.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: `${this.pageId} ${me}`,
          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 MyNetwork page.
   *
   * This page takes 3-4 seconds to load every time.  Revisits are
   * likely to take a while.
   */
  class MyNetwork extends Page {

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

      this.addService(LinkedInStyleService, this)
        .addStyles(LinkedIn.Style.TWO);

      this.addService(VMKeyboardService)
        .addInstance(this);

      spa.details.navbarScrollerFixup(MyNetwork.#collectionsHow);
      spa.details.navbarScrollerFixup(MyNetwork.#individualsHow);

      this.#collectionScroller = new Scroller(MyNetwork.#collectionsWhat,
        MyNetwork.#collectionsHow);
      this.addService(LinkedInScrollerService)
        .setScroller(this.#collectionScroller);
      this.#collectionScroller.dispatcher
        .on('change', this.#onCollectionChange)
        .on('out-of-range', this.spa.details.focusOnSidebar);

      this.#lastScroller = this.#collectionScroller;
    }

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueCollectionIdentifier(element) {
      const me = MyNetwork.uniqueCollectionIdentifier.name;
      this.logger.entered(me, element);

      let content = '';
      const key = LinkedIn.ckeyIdentifier(element);
      const childKey = LinkedIn.ckeyIdentifier(
        element.querySelector(`[${CKEY}]`)
      );

      if (childKey) {
        content = childKey;
      }
      if (key) {
        content = key;
      }
      if (!content) {
        content = this.defaultUid(element);
      }

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

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueIndividualsIdentifier(element) {
      const me = MyNetwork.uniqueIndividualsIdentifier.name;
      this.logger.entered(me, element);

      let content = '';
      const key = LinkedIn.ckeyIdentifier(element);
      const childKey = LinkedIn.ckeyIdentifier(
        element.querySelector(`[${CKEY}]`)
      );

      if (childKey) {
        content = childKey;
      }
      if (key) {
        content = key;
      }
      if (!content) {
        content = this.defaultUid(element);
      }

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

    /** @type {Scroller} */
    get collections() {
      return this.#collectionScroller;
    }

    /** @type {Scroller} */
    get individuals() {
      if (!this.#individualScroller && this.collections.item) {
        this.#individualScroller = new Scroller(
          {base: this.collections.item, ...MyNetwork.#individualsWhat},
          MyNetwork.#individualsHow
        );
        this.#individualScroller.dispatcher
          .on('change', this.#onIndividualChange)
          .on('focus', this.#onIndividualFocus)
          .on('out-of-range', this.#returnToCollection);
      }
      return this.#individualScroller;
    }

    nextCollection = new Shortcut(
      'j',
      'Next collection card',
      () => {
        this.collections.next();
      }
    );

    prevCollection = new Shortcut(
      'k',
      'Previous collection card',
      () => {
        this.collections.prev();
      }
    );

    nextIndividual = new Shortcut(
      'n',
      'Next individual item in collection',
      () => {
        this.individuals.next();
      }
    );

    prevIndividual = new Shortcut(
      'p',
      'Previous individual item in collection',
      () => {
        this.individuals.prev();
      }
    );

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

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

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

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

    openMeatballMenu = new Shortcut(
      '=',
      'Open closest <button class="spa-meatball">⋯</button> menu',
      () => {
        const el = this.#lastScroller?.item;
        NH.web.clickElement(el, [
          // Catch up items
          ':has(> * > svg[id^="overflow"])',
        ]);
      }
    );

    tabList = new Shortcut(
      'l',
      'Focus on Manage invitations tab list',
      () => {
        const el = document.querySelector('main nav [aria-current]');
        el.scrollIntoView(false);
        NH.web.focusOnElement(el);
      }
    );

    engageIndividual = new Shortcut(
      'E',
      'Engage the individual (Connect, Follow, Join, Message, etc)',
      () => {
        const el = this.individuals?.item;
        NH.web.clickElement(el, [
          // Connect
          ':has(> * > svg[id^="connect"])',
          // Withdraw pending
          ':has(> * > svg[id^="clock"])',
          // Follow
          ':has(> * > svg[id^="add-"])',
          // Unfollow
          ':has(> * > svg[id^="check"])',
          // Catch up Message
          ':has(> * > svg[id^="send"])',
        ]);
      }
    );

    likeItem = new Shortcut(
      'L',
      'Like current item',
      () => {
        const el = this.#lastScroller.item;
        NH.web.clickElement(el, [':has(> * > svg[id^="chevron-up"])']);
      }
    );

    commentOnItem = new Shortcut(
      'C',
      'Comment on current item',
      () => {
        const el = this.#lastScroller.item;
        NH.web.clickElement(el, [':has(> * > svg[id^="comment"])']);
      }
    );

    dismissIndividual = new Shortcut(
      'X',
      'Dismiss current item',
      () => {
        const el = this.individuals?.item;
        NH.web.clickElement(el, [
          // Most items
          ':has(> * > svg[id^="close"]',
        ]);
      }
    );

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

    /** @type {Scroller~What} */
    static #collectionsWhat = {
      name: `${this.name} cards`,
      containerItems: [
        {
          container: 'main > div > div > div:nth-of-type(2) > div',
          items: [
            // Most "Grow" cards
            '[role="main"] > div > div > div > section',
            // Other "Grow" cards
            '[role="main"] > div > div > section',
            // "Catch up" card
            ':scope > div > section',
          ].join(','),
        },
      ],
    };

    /** @type {Page~PageDetails} */
    static #details = {
      pageName: 'My Network (Grow, Catch up)',
      // eslint-disable-next-line prefer-regex-literals
      pathname: RegExp('^/mynetwork/(?:grow/|catch-up/.*)', 'u'),
      pageReadySelector: 'main > div > div > div',
    };

    /** @type {Scroller~How} */
    static #individualsHow = {
      uidCallback: MyNetwork.uniqueIndividualsIdentifier,
      classes: ['dick'],
      autoActivate: true,
      snapToTop: false,
      clickConfig: {
        selectorArray: ['a', 'button'],
        matchSelf: true,
      },
    };

    /** @type {Scroller~What} */
    static #individualsWhat = {
      name: `${this.name} individual`,
      selectors: [
        [
          // Carousel cards (different variations)
          '[data-testid="carousel-child-container"] > div > a',
          '[data-testid="carousel-child-container"] > div:has(> div)',
          // Most cards with followable entities in them
          '[role="listitem"]',
        ].join(','),
      ],
    };

    #collectionScroller
    #individualScroller
    #lastScroller

    #resetIndividuals = () => {
      if (this.#individualScroller) {
        this.#individualScroller.destroy();
        this.#individualScroller = null;
      }
      this.individuals;
    }

    #onIndividualChange = () => {
      this.#lastScroller = this.individuals;
    }

    #onIndividualFocus = () => {
      this.collections.show();
    }

    #onCollectionChange = () => {
      const me = this.#onCollectionChange.name;
      this.logger.entered(me);

      this.#resetIndividuals();
      this.#lastScroller = this.collections;

      this.logger.leaving(me, this.collections.item);
    }

    #returnToCollection = () => {
      this.collections.item = this.collections.item;
    }

  }

  /**
   * Class for handling Invitation Manager.
   *
   * While this page does have multiple sections (Manage Invitations and
   * Suggestions for you), the latter is enclosed by the former.  There is no
   * way to highlight the former without including the latter.  So just treat
   * the page as one big long list.
   */
  class InvitationManager extends Page {

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

      this.addService(LinkedInStyleService, this)
        .addStyles(LinkedIn.Style.TWO);

      this.addService(VMKeyboardService)
        .addInstance(this);

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

      this.#inviteScroller = new Scroller(
        InvitationManager.#invitesWhat,
        InvitationManager.#invitesHow
      );
      this.addService(LinkedInScrollerService)
        .setScroller(this.#inviteScroller);
    }

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueInvitationIdentifier(element) {
      const me = InvitationManager.uniqueInvitationIdentifier.name;
      this.logger.entered(me, element);

      let content = '';
      const key = LinkedIn.ckeyIdentifier(element);

      if (key) {
        content = key;
      }
      if (!content) {
        content = this.defaultUid(element);
      }

      this.logger.leaving(me, content);
      return 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',
      () => {
        this.invites.focus();
      }
    );

    seeMore = new Shortcut(
      'm',
      'Toggle seeing more of current invite',
      () => {
        const el = this.invites.item;
        NH.web.clickElement(el, ['button[data-testid]']);
      }
    );

    viewInviter = new Shortcut(
      'i',
      'View invite principal',
      () => {
        const el = this.invites.item;
        NH.web.clickElement(el, [
          // Most invites
          ':scope [role="listitem"] a:has(+ span)',
          // Suggestions for you
          'a',
        ]);
      }
    );

    viewTarget = new Shortcut(
      't',
      'View invitation target ' +
        '(may not be the same as inviter, e.g., Newsletter)',
      () => {
        const el = this.invites.item;
        if (el.tagName === 'A') {
          el.click();
        } else {
          NH.web.clickElement(el, [':scope [role="listitem"] > * > a']);
        }
      }
    );

    tabList = new Shortcut(
      'l',
      'Focus on Manage invitations tab list',
      () => {
        const el = document.querySelector('main nav [aria-current]');
        el.scrollIntoView(false);
        NH.web.focusOnElement(el);
      }
    );

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

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

    connectSuggestion = new Shortcut(
      'C',
      'Connect with suggestion',
      () => {
        const el = this.invites.item;
        this.logger.log('el', el);
        NH.web.clickElement(el, ['[aria-label^="Invite"]']);
      }
    );

    messageInviter = new Shortcut(
      'M',
      'Message inviter',
      () => {
        const el = this.invites.item;
        NH.web.clickElement(el, ['a[href*="/compose/"]']);
      }
    );

    withDraw = new Shortcut(
      'W',
      'Withdraw invitation',
      () => {
        const el = this.invites.item;
        const rc = NH.web.clickElement(el, [
          // Suggestions for you
          '[aria-label^="Pending"]',
        ]);
        if (!rc) {
          // Sent tab
          const elements = Array.from(el?.querySelectorAll('a') || [])
            .filter(x => x.innerText === 'Withdraw');
          if (elements.length === NH.base.ONE_ITEM) {
            elements[0].click();
          }
        }
      }
    );

    dismissInvite = new Shortcut(
      'X',
      'Dismiss invitation (after accepting or ignoring)',
      () => {
        const el = this.invites.item;
        NH.web.clickElement(el, [':has(> * > svg[id^="close"]']);
      }
    );

    /** @type {Page~PageDetails} */
    static #details = {
      // eslint-disable-next-line prefer-regex-literals
      pathname: RegExp('^/mynetwork/invitation-manager/.*', 'u'),
      pageReadySelector: 'main > div > div > div',
    };

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

    /** @type {Scroller~What} */
    static #invitesWhat = {
      name: `${this.name} cards`,
      containerItems: [
        {
          container: 'main [role="main"] [data-testid="lazy-column"]',
          items: [
            // Standard invites
            `:scope > div[${CKEY}]`,
            // Suggestions for you
            'h3 ~ div > a',
          ].join(','),
        },
      ],
    };

    #inviteScroller

  }

  /**
   * 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 {

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

      this.addService(LinkedInStyleService, this)
        .addStyles(LinkedIn.Style.TWO);

      this.addService(VMKeyboardService)
        .addInstance(this);

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

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

      this.#lastScroller = this.#sectionScroller;
    }

    /**
     * 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 me = Jobs.uniqueJobIdentifier.name;
      this.logger.entered(me, element);

      let content = '';
      const key = LinkedIn.ckeyIdentifier(element);

      if (key) {
        content = key;
      }
      if (!content) {
        content = this.defaultUid(element);
      }

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

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueSectionIdentifier(element) {
      const me = Jobs.uniqueSectionIdentifier.name;
      this.logger.entered(me, element);

      let content = '';
      const key = LinkedIn.ckeyIdentifier(element);

      if (key) {
        content = key;
      }
      if (!content) {
        content = this.defaultUid(element);
      }

      this.logger.leaving(me, content);
      return 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)
          .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();
      }
    );

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

    lastItem = 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.#lastScroller.focus();
      }
    );

    activateItem = new Shortcut(
      'Enter',
      'Activate the current item (click on it)',
      () => {
        if (litOptions.enableIssue241ClickMethod) {
          this.jobs.click();
        } else {
          const el = this.jobs?.item;
          if (el) {
            if (!NH.web.clickElement(el,
              [
                '[role="button"]',
                'a',
                'button',
              ], true)) {
              NH.web.postInfoAboutElement(el, 'el');
            }
          } else {
            // Again, because we use Enter as the hotkey for this action.
            document.activeElement.click();
          }
        }
      }
    );

    openMeatballMenu = new Shortcut(
      '=',
      'Open closest <button class="spa-meatball">⋯</button> menu',
      () => {
        const el = this.jobs?.item;
        NH.web.clickElement(el, [':has(> * > svg[id^="overflow"])']);
      }
    );

    loadMoreSections = new Shortcut(
      'l',
      'Load more sections',
      () => {
        const base = document.querySelector(Jobs.#sectionsContainer);
        NH.web.clickElement(base,
          [':scope > div:last-of-type > button']);
      }
    );

    toggleDismissJob = new Shortcut(
      'X',
      'Toggle dismissing job',
      () => {
        const el = this.jobs?.item;
        NH.web.clickElement(el, [
          ':has(> * > svg[id^="close"]',
          ':has(> * > svg[id^="undo-"]',
        ]);
      }
    );

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

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

    /** @type {Scroller~What} */
    static #jobsWhat = {
      name: `${this.name} entries`,
      selectors: [
        [
          // Match your profile - Show all button
          ':scope > * > a',
          // Most job entries
          ':scope > * > * > a',
          // Carousels
          '[data-testid="carousel-child-container"] a',
          // Job collections tabs
          '[role="button"]',
          // Job collections entries
          `[${CKEY}^="JobsHomeModuleTabbed"] > * > a`,
          `[${CKEY}^="JobsHomeModuleTabbed"] > * > * > a`,
        ].join(','),
      ],
    };

    static #sectionsContainer =
      '[data-testid="JobsHomeFeedModuleListCollection"]';

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

    /** @type {Scroller~What} */
    static #sectionsWhat = {
      name: `${this.name} sections`,
      containerItems: [
        {
          container: Jobs.#sectionsContainer,
          items: [
            // Premium "top applicant"
            `:scope > [${CKEY}^="Jobs"] > * > [${CKEY}^="Jobs"]`,
            // Everything else
            `:scope > div > div[${CKEY}]`,
          ].join(','),
        },
      ],
    };

    #jobScroller
    #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 Jobs collections. */
  class JobsCollections extends Page {

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

      this.addService(LinkedInStyleService, this)
        .addStyles(LinkedIn.Style.ONE);

      this.addService(VMKeyboardService)
        .addInstance(this);

      this.#jobCardScroller = new Scroller(JobsCollections.#jobCardsWhat,
        JobsCollections.#jobCardsHow);
      this.addService(LinkedInScrollerService)
        .setScroller(this.#jobCardScroller)
        .allowReactivation(false);
      this.#jobCardScroller.dispatcher
        .on('activate', this.#onJobCardActivate)
        .on('change', this.#onJobCardChange);

      this.#paginationScroller = new Scroller(
        JobsCollections.#paginationWhat, JobsCollections.#paginationHow
      );
      this.addService(LinkedInScrollerService)
        .setScroller(this.#paginationScroller)
        .allowReactivation(false);
      this.#paginationScroller.dispatcher
        .on('activate', this.#onPaginationActivate)
        .on('change', this.#onPaginationChange);

      spa.details.navbarScrollerFixup(JobsCollections.#detailsHow);
      this.#detailsScroller = new Scroller(
        JobsCollections.#detailsWhat, JobsCollections.#detailsHow
      );
      this.addService(LinkedInScrollerService)
        .setScroller(this.#detailsScroller)
        .allowReactivation(false);
      this.#detailsScroller.dispatcher
        .on('change', this.#onDetailsChange);

      this.#lastScroller = this.#jobCardScroller;
    }

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueDetailsIdentifier(element) {
      const me = JobsCollections.uniqueDetailsIdentifier.name;
      this.logger.entered(me, element);

      let content = '';
      const id = element.id;
      const nestedId = element.querySelector(
        '[id]:not([id^="ember"]):not([id^="artdeco"])'
      )?.id;
      const h2 = LinkedIn.h2(element);
      const classes = new Set(
        element.querySelectorAll('*:not(h2,svg)')
          .values()
          .map(x => [...x.classList])
          .toArray()
          .flat()
          .sort()
      );
      const klass = new Set(
        classes
          .values()
          .map(x => JobsCollections.#uidDetailsClassRE.exec(x)?.groups.class)
          .filter(x => x)
      )
        .values()
        .toArray()
        .sort()
        .join('-_-');

      if (h2) {
        content = h2;
      }
      if (klass) {
        content = klass;
      }
      if (nestedId) {
        content = nestedId;
      }
      if (id) {
        content = id;
      }
      if (!content) {
        content = this.defaultUid(element);
      }

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

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueJobIdentifier(element) {
      const me = JobsCollections.uniqueJobIdentifier.name;
      this.logger.entered(me, element);

      let content = '';
      const jobId = element.dataset.occludableJobId;

      if (jobId) {
        content = jobId;
      }
      if (!content) {
        content = this.defaultUid(element);
      }

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

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniquePaginationIdentifier(element) {
      const me = JobsCollections.uniquePaginationIdentifier.name;
      this.logger.entered(me, element);

      let content = '';
      const label = element.getAttribute('aria-label');

      if (label) {
        content = label;
      }
      if (!content) {
        content = this.defaultUid(element);
      }

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

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

    /** @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();
      }
    );

    nextDetail = new Shortcut(
      'n',
      'Next job detail',
      () => {
        this.details.next();
      }
    );

    prevDetail = new Shortcut(
      'p',
      'Previous job detail',
      () => {
        this.details.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',
      () => {
        this.#lastScroller.focus();
      }
    );

    detailsPane = new Shortcut(
      'd',
      'Move browser focus to the details pane',
      () => {
        NH.web.focusOnTree(document.querySelector(
          'div.jobs-details__main-content'
        ));
      }
    );

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

    tabList = new Shortcut(
      'l',
      'Focus on discovery tab list (has native scrolling using arrows)',
      () => {
        const el = document.querySelector(
          '.jobs-search-discovery-tabs nav [aria-current="true"]'
        );
        el.focus();
      }
    );

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

    openMeatballMenu = new Shortcut(
      '=',
      'Open the <button class="spa-meatball">⋯</button> menu',
      () => {
        // XXX: There are TWO buttons.  The *second* 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)',
      () => {
        NH.web.clickElement(document, [
          // Apply and Easy Apply buttons
          '#jobs-apply-button-id',
          // See application link
          'a[href^="/jobs/tracker"]',
        ]);
      }
    );

    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',
      () => {
        NH.web.clickElement(this.jobCards.item, ['button']);
      }
    );

    nextJobPlus = new Shortcut(
      'J',
      'Toggle dismissing then next job card',
      () => {
        this.toggleDismissJob();
        this.nextJob();
      }
    );

    prevJobPlus = new Shortcut(
      'K',
      'Toggle dismissing then previous job card',
      () => {
        this.toggleDismissJob();
        this.prevJob();
      }
    );

    toggleFollowCompany = new Shortcut(
      'F', 'Toggle following company', () => {
        NH.web.clickElement(document, ['button.follow']);
      }
    );

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

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

    /** @type {Scroller~How} */
    static #detailsHow = {
      uidCallback: JobsCollections.uniqueDetailsIdentifier,
      classes: ['dick'],
      snapToTop: true,
    };

    /** @type {Scroller~What} */
    static #detailsWhat = {
      name: `${this.name} details`,
      containerItems: [
        {
          container: 'div.jobs-details__main-content',
          items: ':scope > div, :scope > section',
        },
      ],
    };

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

    /** @type {Scroller~What} */
    static #jobCardsWhat = {
      name: `${this.name} cards`,
      containerItems: [
        {
          container: 'div.scaffold-layout__list > div > ul',
          // This selector is also used in #onJobCardActivate.
          items: ':scope > li',
        },
      ],
    };

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

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

    static #uidDetailsClassRE = /^(?:job-details|jobs)-(?<class>[^_]*)__/u;

    #detailsScroller
    #jobCardScroller
    #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.logger.log('Found', item);
        this.jobCards.gotoUid(JobsCollections.uniqueJobIdentifier(item));
        this.logger.log('and went to it');
      } 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.details.first();
      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 [aria-current]',
          timeout
        );
        this.paginator.goto(item);
      } catch (e) {
        this.logger.log('Results paginator not found, staying put');
      }

      this.logger.leaving(me);
    }

    #onPaginationChange = () => {
      this.#lastScroller = this.paginator;
    }

    #onDetailsChange = () => {
      this.#lastScroller = this.details;
    }

  }

  /** Class for handling the direct Jobs view. */
  class JobsView extends Page {

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

      this.addService(LinkedInStyleService, this)
        .addStyles(LinkedIn.Style.TWO);

      this.addService(VMKeyboardService)
        .addInstance(this);

      this.addService(LinkedInToolbarService, this)
        .addHows(JobsView.#cardsHow, JobsView.#entriesHow)
        .postActivateHook(this.#toolbarHook);
    }

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueCardIdentifier(element) {
      const me = JobsView.uniqueCardIdentifier.name;
      this.logger.entered(me, element);

      let content = '';
      const key = LinkedIn.ckeyIdentifier(element);
      const label = element
        .querySelector('[aria-label]')
        ?.getAttribute('aria-label');
      const h2 = LinkedIn.h2(element);

      if (h2) {
        content = h2;
      }
      if (label) {
        content = label;
      }
      if (key) {
        content = key;
      }
      if (!content) {
        content = this.defaultUid(element);
      }

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

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueEntryIdentifier(element) {
      const me = JobsView.uniqueEntryIdentifier.name;
      this.logger.entered(me, element);

      let content = '';
      const href = element.href;

      if (href) {
        content = new URL(href).searchParams.get('currentJobId');
      }
      if (!content) {
        content = this.defaultUid(element);
      }

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

    /** @type {Scroller} */
    get cards() {
      if (!this.#cardScroller) {
        this.#cardScroller = new Scroller(JobsView.#cardsWhat,
          JobsView.#cardsHow);
        this.addService(LinkedInScrollerService)
          .setScroller(this.#cardScroller);
        this.#cardScroller.dispatcher
          .on('change', this.#onCardChange);

        this.#lastScroller = this.#cardScroller;
      }
      return this.#cardScroller;
    }

    /** @type {Scroller} */
    get entries() {
      if (!this.#entryScroller && this.cards.item) {
        this.#entryScroller = new Scroller(
          {base: this.cards.item, ...JobsView.#entriesWhat},
          JobsView.#entriesHow
        );
        this.#entryScroller.dispatcher
          .on('change', this.#onEntryChange)
          .on('out-of-range', this.#returnToCard);
      }
      return this.#entryScroller;
    }

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

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

    nextEntry = new Shortcut(
      'n',
      'Next entry in a section',
      () => {
        this.entries.next();
      }
    );

    prevEntry = new Shortcut(
      'p',
      'Previous entry in a section',
      () => {
        this.entries.prev();
      }
    );

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

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

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

    showMore = new Shortcut(
      'm',
      'Show more of current item',
      () => {
        const el = this.#lastScroller.item;
        if (el) {
          NH.web.clickElement(el, ['[data-testid="expandable-text-button"]']);
        }
      }
    );

    applyToJob = new Shortcut(
      'A',
      'Apply to job',
      () => {
        const el = document.querySelector(JobsView.#jobCardSelector);
        NH.web.clickElement(el, [
          // Matches both "link-external" and "linkedin-bug" icons
          '[aria-label]:has(> * > svg[id^="link"])',
        ]);
      }
    );

    toggleFollowCompany = new Shortcut(
      'F', 'Toggle following company', () => {
        // The anchor below is the link to the company in "About the company"
        NH.web.clickElement(document, [
          // Follow
          'a + :has(> * > svg[id^="add-"])',
          // Unfollow
          'a + :has(> * > svg[id^="check-"])',
        ]);
      }
    );

    toggleAlert = new Shortcut(
      'L',
      'Toggle the similar job search aLert',
      () => {
        NH.web.clickElement(document, ['[role="switch"]']);
      }
    );

    toggleSaveJob = new Shortcut(
      'S',
      'Toggle saving job',
      () => {
        const el = document.querySelector(JobsView.#jobCardSelector);
        NH.web.clickElement(el, [
          // Fragile, as currently only button in the card without an icon.
          'button:not(:has(svg))',
        ]);
      }
    );

    static #cardsContainer = '[data-testid="lazy-column"]';

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

    /** @type {Scroller~What} */
    static #cardsWhat = {
      name: `${this.name} cards`,
      containerItems: [
        {
          container: JobsView.#cardsContainer,
          items: [
            // Main content
            ':scope > :first-child',
            // Rest
            ':scope > :not(:first-child) > *',
          ].join(','),
        },
      ],
    };

    /** @type {Page~PageDetails} */
    static #details = {
      // eslint-disable-next-line prefer-regex-literals
      pathname: RegExp('^/jobs/view/\\d+.*', 'u'),
      pageReadySelector: 'main > div > div > div',
    };

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

    /** @type {Scroller~What} */
    static #entriesWhat = {
      name: `${this.name} entries`,
      selectors: [
        // More jobs - Matches grid and footer
        `:scope[${CKEY}^="JobDetailsSimilarJobsSlot"] a`,
      ],
    };

    static #jobCardSelector = `${JobsView.#cardsContainer} > div:first-child`;

    #cardScroller
    #entryScroller
    #lastScroller

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

      this.logger.log('Initializing scroller:', this.cards.item);

      this.logger.leaving(me);
    }

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

    #resetEntries = () => {
      if (this.#entryScroller) {
        this.#entryScroller.destroy();
        this.#entryScroller = null;
      }
      this.entries;
    }

    #onEntryChange = () => {
      this.#lastScroller = this.entries;
    }

    #returnToCard = () => {
      this.cards.item = this.cards.item;
    }

  }

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

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

      this.addService(LinkedInStyleService, this)
        .addStyles(LinkedIn.Style.ONE);

      this.addService(VMKeyboardService)
        .addInstance(this);

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

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueConvoCardsIdentifier(element) {
      const me = Messaging.uniqueConvoCardsIdentifier.name;
      this.logger.entered(me, element);

      // XXX: As of 2026-04-14, there are no distinguishing features in the
      // cards.  Unlike the similar UI for JobsCollections, there is no easy
      // mapping between the URL and the card.  The img.src looks interesting,
      // but not really.  It is possible to have multiple cards for the
      // person, making using the URL unsuitable.  And some folks do not have
      // photos, so get the same placeholder data: scheme.
      const content = this.defaultUid(element);

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

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueMessageIdentifier(element) {
      const me = Messaging.uniqueMessageIdentifier.name;
      this.logger.entered(me, element);

      let content = '';
      const urn = element.dataset.eventUrn;

      if (urn) {
        content = urn;
      }
      if (!content) {
        content = this.defaultUid(element);
      }

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

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

    /** @type {Scroller} */
    get messages() {
      const me = 'get messages';
      this.logger.entered(me, this.convoCards.item);

      if (!this.#messageScroller && this.convoCards.item) {
        this.#messageScroller = new Scroller(
          Messaging.#messagesWhat, Messaging.#messagesHow
        );
        this.#messageScroller.dispatcher
          .on('change', this.#onMessageChange);
      }

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

    nextConvo = new Shortcut(
      'j',
      'Next conversation card',
      () => {
        this.convoCards.next();
      }
    );

    prevConvo = new Shortcut(
      'k',
      'Previous conversation card',
      () => {
        this.convoCards.prev();
      }
    );

    nextMessage = new Shortcut(
      'n',
      'Next message in conversation',
      () => {
        this.messages.next();
      }
    );

    prevMessage = new Shortcut(
      'p',
      'Previous message in conversation',
      () => {
        this.messages.prev();
      }
    );

    firstItem = new Shortcut(
      '<',
      'First conversation card or message',
      () => {
        this.#lastScroller.first();
      }
    );

    lastItem = new Shortcut(
      '>',
      'Last conversation card or message',
      () => {
        this.#lastScroller.last();
      }
    );

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

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

        // This button has no distinguishing features, but seems to be the
        // last item in this list, and only one immediately a list item.
        NH.web.clickElement(document,
          [`${Messaging.#convoCardsList} > li > button`]);

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

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

        NH.web.focusOnElement(document.querySelector(
          `${Messaging.#messagingFilterSelector} button`
        ));

        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);
      }
    );

    openMeatballMenu = new Shortcut(
      '=',
      'Open closest <button class="spa-meatball">⋯</button> menu (tricky, ' +
        'as there are many buttons to choose from)',
      () => {
        const me = this.openMeatballMenu.name;
        this.logger.entered(me, this.#lastScroller);

        if (this.convoCards.item.contains(document.activeElement) ||
            this.messages.item?.contains(document.activeElement)) {
          let buttons = null;
          if (this.#lastScroller === this.convoCards) {
            buttons = this.convoCards.item.querySelectorAll('button');
            if (buttons.length === NH.base.ONE_ITEM) {
              buttons[0].click();
            } else {
              NH.base.issues.post(
                'Current conversation card does not have only one button',
                this.convoCards.item.outerHTML
              );
            }
          } else {
            this.logger.log('Using messages', this.messages.item);
            buttons = document.querySelectorAll(
              'div.msg-title-bar button.msg-thread-actions__control'
            );
            if (buttons.length === NH.base.ONE_ITEM) {
              buttons[0].click();
            } else {
              const msgs = Array.from(buttons)
                .map(x => x.outerHTML);
              NH.base.issues.post(
                'The message title bar did not have exactly one button ' +
                  'matching the search criteria',
                ...msgs
              );
            }
          }
        } else {
          this.#clickClosestMenuButton();
        }
        this.logger.leaving(me);
      }
    );

    messageBox = new Shortcut(
      'M',
      'Go to the <i>Write a message</i> box',
      () => {
        NH.web.clickElement(document, [Messaging.#messageBoxSelector]);
      }
    );

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

        // Composing a new message changes the URL, triggering page
        // activation, which immediately refocuses on the current
        // conversation.  Setting it to `null` does lose are spot in the
        // Scroller, but then at least the feature works.
        this.convoCards.item = null;
        NH.web.clickElement(document,
          ['#messaging :has(> svg[data-test-icon^="compose"])']);

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

    toggleStar = new Shortcut(
      'S',
      'Toggle star on the current conversation',
      () => {
        NH.web.clickElement(document, ['button.msg-thread__star-icon']);
      }
    );

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

    static #convoCardsList =
      'main ul.msg-conversations-container__conversations-list';

    /** @type {Scroller~What} */
    static #convoCardsWhat = {
      name: `${this.name} conversations`,
      containerItems: [
        {
          container: Messaging.#convoCardsList,
          items: ':scope > li.msg-conversations-container__pillar',
        },
      ],
    };

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

    static #messageBoxSelector = 'main div.msg-form__contenteditable';

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

    /** @type {Scroller~What} */
    static #messagesWhat = {
      name: `${this.name} messages`,
      containerItems: [
        {
          container: 'ul.msg-s-message-list-content',
          items:
          ':scope > li.msg-s-message-list__event > div[data-event-urn]',
        },
      ],
    };

    static #messagingFilterSelector =
      '.msg-cross-pillar-inbox-filters-v3__container';

    static #messagingOptionsSelector =
      'button[aria-label="See more messaging options"]';

    static #sendToggleSelector = 'button.msg-form__send-toggle';

    #activator
    #convoCardScroller
    #lastScroller
    #messageScroller

    /**
     * @typedef {object} Point
     * @property {number} x - Horizontal location in pixels.
     * @property {number} y - Vertical location in pixels.
     * @property {HTMLElement} element - Associated element.
     */

    /**
     * @param {HTMLElement} element - Element to examine.
     * @returns {Point} - Center of the element.
     */
    #centerOfElement = (element) => {
      const TWO = 2;

      const center = {
        x: 0,
        y: 0,
        element: element,
      };
      if (element) {
        const bbox = element.getBoundingClientRect();
        this.logger.log('bbox:', bbox);
        center.x = (bbox.left + bbox.right) / TWO;
        center.y = (bbox.top + bbox.bottom) / TWO;
      }
      return center;
    }

    #clickClosestMenuButton = () => {
      // Two more buttons to choose from.  There are two ways of calculating
      // the distance from the activeElement to the buttons: Path in the DOM
      // tree or geometry.  Considering the buttons are fixed, I suspect
      // geometry is probably easier than trying to find the common ancestors.
      const messagingOptions = document.querySelector(
        Messaging.#messagingOptionsSelector
      );
      if (!messagingOptions) {
        NH.base.issues.post(
          'Unable to find the messaging options button.',
          'Selector used:',
          Messaging.#messagingOptionsSelector
        );
      }

      const sendToggle = document.querySelector(
        Messaging.#sendToggleSelector
      );
      if (!sendToggle) {
        NH.base.issues.post(
          'Unable to find the messaging send toggle button',
          'Selector used:',
          Messaging.#sendToggleSelector
        );
      }
      const activeCenter = this.#centerOfElement(document.activeElement);
      const optionsCenter = this.#centerOfElement(messagingOptions);
      const toggleCenter = this.#centerOfElement(sendToggle);
      optionsCenter.distance = this.#distanceBetweenPoints(
        activeCenter, optionsCenter
      );
      toggleCenter.distance = this.#distanceBetweenPoints(
        activeCenter, toggleCenter
      );
      const centers = [optionsCenter, toggleCenter];
      centers.sort((a, b) => a.distance - b.distance);
      centers[0].element.click();
    }

    /**
     * @param {Point} one - First point.
     * @param {Point} two - Second point.
     * @returns {number} - Distance between the points in pixels.
     */
    #distanceBetweenPoints = (one, two) => {
      const me = 'distanceBetweenPoints';
      this.logger.entered(me, one, two);

      const xd = one.x - two.x;
      const yd = one.y - two.y;
      const distance = Math.sqrt((xd * xd) + (yd * yd));

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

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

      await this.#findActiveConvo();

      this.logger.leaving(me);
    }

    #onConvoCardChange = () => {
      const me = this.#onConvoCardChange.name;
      this.logger.entered(me);

      const el = this.convoCards?.item;
      NH.web.clickElement(el, ['.msg-conversation-listitem__link']);

      this.#resetMessages();
      this.#lastScroller = this.convoCards;

      this.logger.leaving(me);
    }

    #resetMessages = () => {
      if (this.#messageScroller) {
        this.#messageScroller.destroy();
        this.#messageScroller = null;
      }
      this.messages;
    }

    #onMessageChange = () => {
      this.#lastScroller = this.messages;
    }

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

      try {
        const timeout = 2000;
        const item = await NH.web.waitForSelector(
          '.msg-conversations-container__convo-item-link--active', timeout
        );
        this.convoCards.goto(item.closest('li'));
      } catch (e) {
        this.logger.log('Active conversation card not found, staying put');
      }

      this.logger.leaving(me);
    }

  }

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

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

      this.addService(LinkedInStyleService, this)
        .addStyles(LinkedIn.Style.ONE);

      this.addService(VMKeyboardService)
        .addInstance(this);

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

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

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueNotificationIdentifier(element) {
      const me = Notifications.uniqueNotificationIdentifier.name;
      this.logger.entered(me, element);

      let content = '';
      const hotKey = element.parentElement.dataset.finiteScrollHotkeyItem;
      const cardIndex = element.dataset.ntCardIndex;

      if (hotKey) {
        content = hotKey;
      }
      if (cardIndex) {
        content = cardIndex;
      }
      if (!content) {
        content = this.defaultUid(element);
      }

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

    /**
     * Given a notification card, find the correct item inside of it to click.
     *
     * @implements {Scroller~ElementFinder}
     * @param {HTMLElement} element - Element to examine.
     * @returns {HTMLElement} - Found element.
     */
    static cardItemToClick(element) {
      let found = null;

      if (element) {
        const elements = element.querySelectorAll(
          '.nt-card__headline'
        );
        if (elements.length === NH.base.ONE_ITEM) {
          found = elements[0];
        } else {
          const ba = element.querySelectorAll('button,a');
          if (ba.length === NH.base.ONE_ITEM) {
            found = ba[0];
          }
        }
      }

      return found;
    }

    /** @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.focus();
      }
    );

    activateNotification = new Shortcut(
      'Enter',
      'Activate the current notification (click on it)',
      () => {
        if (litOptions.enableIssue241ClickMethod) {
          this.notifications.click();
        } else {
          const element = Notifications.cardItemToClick(
            this.notifications.item
          );
          if (element) {
            element.click();
          } else {
            // Again, because we use Enter as the hotkey for this action.
            document.activeElement.click();
          }
        }
      }
    );

    loadMoreNotifications = new Shortcut(
      'l',
      'Load more notifications',
      () => {
        const me = this.loadMoreNotifications.name;
        this.logger.entered(me);

        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,
            ['main button:has(> svg[data-test-icon^="arrow-up"]'])) {
            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: `${this.pageId} ${me}`,
          base: document.querySelector('div.scaffold-finite-scroll__content'),
        };
        const how = {
          trigger: trigger,
          action: action,
          duration: 2000,
        };

        NH.web.otrot2(what, how);

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

    openMeatballMenu = new Shortcut(
      '=',
      'Open the <button class="spa-meatball">⋯</button> menu',
      () => {
        NH.web.clickElement(this.notifications.item,
          ['button:has(> svg[data-test-icon^="overflow"]']);
      }
    );

    gotoFilter = new Shortcut(
      'F',
      'Move focus to the notification filters',
      () => {
        this.notifications.item = null;
        NH.web.focusOnElement(
          document.querySelector('#notification-nt-pill .nt-pill--selected')
        );
      }
    );

    deleteNotification = new Shortcut(
      'X',
      'Toggle current notification deletion',
      async () => {
        const me = this.deleteNotification.name;
        this.logger.entered(me);

        const el = this.notifications.item;

        /** Trigger function for {@link NH.web.otrot}. */
        function trigger() {
          NH.web.clickElement(el, [
            'button:has(svg[data-test-icon^="trash"])',
            'button:has(svg[data-test-icon^="undo"])',
          ]);
        }
        if (el) {
          const what = {
            name: `${this.pageId} ${me}`,
            base: el,
          };
          const how = {
            trigger: trigger,
            timeout: 3000,
          };
          await NH.web.otrot(what, how);
        }

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

    /** @type {Page~PageDetails} */
    static #details = {
      pathname: '/notifications/',
      pageReadySelector: 'footer.global-footer-compact',
    };

    /** @type {Scroller~How} */
    static #notificationsHow = {
      uidCallback: Notifications.uniqueNotificationIdentifier,
      classes: ['tom'],
      snapToTop: false,
      clickConfig: {
        finder: Notifications.cardItemToClick,
      },
    };

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

    #notificationScroller

  }

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

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

      this.addService(LinkedInStyleService, this)
        .addStyles(LinkedIn.Style.TWO);

      this.addService(VMKeyboardService)
        .addInstance(this);

      this.addService(LinkedInToolbarService, this)
        .addHows(Profile.#sectionsHow, Profile.#entriesHow)
        .postActivateHook(this.#toolbarHook);

      this.dispatcher
        .on('activate', this.#onActivate);
    }

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueSectionIdentifier(element) {
      const me = Profile.uniqueSectionIdentifier.name;
      this.logger.entered(me, element);

      let content = '';
      let cardId = '';
      const key = LinkedIn.ckeyIdentifier(element);
      const h2 = LinkedIn.h2(element);

      if (key) {
        content = key;
        if (key.startsWith(Profile.#uidSectionPrefix)) {
          cardId = key.slice(Profile.#uidSectionPrefix.length);
        }
      }
      if (h2) {
        content = h2;
      }
      if (cardId) {
        content = cardId;
      }
      if (!content) {
        content = this.defaultUid(element);
      }

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

    /**
     * Create a CSS child combinator selector of DIVs.
     *
     * @param {number} n - The number of DIVs in the selector.
     * @returns {string} - N divs like "div > div > ... > div".
     */
    static div(n) {
      const a = Array(n)
        .fill('div');
      return a.join(' > ');
    }

    /**
     * With so much variation in items, this is overly long.
     *
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueEntryIdentifier(element) {  // eslint-disable-line max-lines-per-function, max-statements
      const me = Profile.uniqueEntryIdentifier.name;
      this.logger.entered(me, element);

      let content = '';
      let pathname = '';
      const key = LinkedIn.ckeyIdentifier(element);
      const href = element.href;
      const img = element.querySelector(':scope:is(a) img');
      const anchor = element.querySelector('a')?.href;
      const anchors = element.querySelectorAll('a');

      const page = new URL(document.location);
      if (key) {
        content = key;
      }
      if (anchor) {
        pathname = new URL(anchor).pathname;
        if (!['/', page.pathname].includes(pathname)) {
          content = pathname;
        }
      }
      if (href) {
        pathname = new URL(href).pathname;
        // The Activity > Images grid all link to the Profile.
        if (img && ['/', page.pathname].includes(pathname)) {
          // There are lots of options to choose from here.  With minimal
          // testing, so far this seems to be both unique and stable.
          content = new URL(img.src)
            .pathname
            .split('/')
            .at(NH.base.LAST_ITEM);
        } else {
          // Some sections have a responsive mode where the same information
          // is listed twice, in two different layouts.  And the active layout
          // is determined by page size.  So far, they seem to follow this
          // similar pattern.
          const extra = element.parentElement.matches(':has(hr)')
            ? '-hr'
            : '';

          content = pathname + extra;
        }
      }
      if (!content) {
        content = this.defaultUid(element);
        if (anchors.length) {
          const filtered = anchors.values()
            .map(x => x.href)
            .filter(x => !['/', page.pathname].includes(new URL(x).pathname))
            .toArray();
          if (filtered.length) {
            this.logger.log('anchors to consider:', filtered);
          }
        }
      }

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

    /** @type {Scroller} */
    get entries() {
      if (!this.#entryScroller && this.sections.item) {
        this.#entryScroller = new Scroller(
          {base: this.sections.item, ...Profile.#entriesWhat},
          Profile.#entriesHow
        );
        this.#entryScroller.dispatcher
          .on('change', this.#onEntryChange)
          .on('out-of-range', this.#returnToSection);
      }
      return this.#entryScroller;
    }

    /** @type {Scroller} */
    get sections() {
      if (!this.#sectionScroller) {
        this.#sectionScroller = new Scroller(Profile.#sectionsWhat,
          Profile.#sectionsHow);
        this.addService(LinkedInScrollerService)
          .setScroller(this.#sectionScroller);
        this.#sectionScroller.dispatcher
          .on('change', this.#onSectionChange);

        this.#lastScroller = this.#sectionScroller;
      }
      return this.#sectionScroller;
    }

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

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

    nextEntry = new Shortcut(
      'n',
      'Next entry in a section',
      () => {
        this.entries.next();
      }
    );

    prevEntry = new Shortcut(
      'p',
      'Previous entry in a section',
      () => {
        this.entries.prev();
      }
    );

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

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

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

    seeMore = new Shortcut(
      'm',
      'Show more of the current item',
      () => {
        const el = this.#lastScroller.item;
        NH.web.clickElement(el, ['[data-testid="expandable-text-button"]']);
      }
    );

    editItem = new Shortcut(
      'E',
      'Edit the current item (if possible)',
      () => {
        // Some sections have multiple edit buttons, so walk amongst them.
        const el = this.#lastScroller.item;
        this.logger.log('el', el);
      }
    );

    /** @type {Page~PageDetails} */
    static #details = {
      // eslint-disable-next-line prefer-regex-literals
      pathname: RegExp('^/in/.*', 'u'),
      pageReadySelector: '[data-sdui-component$="profileCardsAboveActivity"]',
    };

    static #div3
    static #div5
    static #div6

    /* eslint-disable no-magic-numbers */
    static {
      this.#div3 = this.div(3);
      this.#div5 = this.div(5);
      this.#div6 = this.div(6);
    }
    /* eslint-enable */

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

    /** @type {Scroller~What} */
    static #entriesWhat = {
      name: `${this.name} entries`,
      // TODO(#302): Need to start from scratch.
      selectors: [
        [
          // Most Topcard items
          `:scope[${CKEY}$="${TOP_CARD}"] > ${this.#div6}` +
            // Random premium badge
            ':not([role])' +
            // Carousel
            ':not(:has(> div > div > section))',
          // Background on most profiles
          `:scope[${CKEY}$="${TOP_CARD}"] > ${this.#div5} > a:has(img)`,
          // Carousels (premium business profile backgrounds, private edit)
          `:scope[${CKEY}$="${TOP_CARD}"]` +
            ' [data-testid="carousel-child-container"] div:has(> a)',

          // Highlights -- no discernable parts, using negative matching
          ':scope' +
            // Skip SDUI sections
            `:not([${CKEY}^="com.linkedin.sdui."])` +
            // Skip Activity (h2) and Interests (radio buttons)
            `:not(:has(> ${this.#div3} > :is(h2, [role="radio"])))` +
            ` > ${this.#div6}`,

          // Analytics (svg == Private to you)
          ':scope:has(svg[id^="visibility"])' +
            ' a:not(:has(svg[id^="arrow-right"]))',

          // About's extra content
          `:scope[${CKEY}$="About"] > ${this.#div3}:has(> p) > *`,

          // Activity has different layouts by tab
          // Posts use a carousel (also works for Featured)
          // Topcard is handled separately
          `:scope:not([${CKEY}$="${TOP_CARD}"])` +
            ' [data-testid="carousel-child-container"] > * > *',
          // Comments use `div` wrapped `a` like a list
          `div[${CKEY}*="comments"] div > div > a:not(:has(svg))`,
          // Videos
          `div[${CKEY}*="videos"] div > a:not(:has(svg[id^="arrow-right"]))`,
          // Images are a straight forward series of `a`
          `div[${CKEY}*="images"] div > a:not(:has(svg))`,
          // Documents
          `div[${CKEY}*="documents"] > div > div > div:not(:has(> a > span))`,

          // "Show all" buttons
          'hr ~ div > a:has(svg[id^="arrow-right"])',
        ].join(','),
      ],
    };

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

    // Known sections in "curr next" pairs, suitable for tsort.
    static #sectionsPartialOrder = new Set([

      'About, Activity',
      'About, Featured',
      'About, Services',
      'Activity, ExperienceTopLevelSection',
      'Analytics, About',
      'CertificationTopLevel, Projects',
      'CertificationTopLevel, Skills',
      'CertificationTopLevel, VolunteerExperienceTopLevel',
      'CourseTopLevelSection, HonorsTopLevel',
      'CourseTopLevelSection, Interests',
      'CourseTopLevelSection, LanguageTopLevel',
      'CourseTopLevelSection, Organizations',
      'EducationTopLevelSection, CertificationTopLevel',
      'EducationTopLevelSection, Interests',
      'EducationTopLevelSection, Projects',
      'EducationTopLevelSection, RecommendationsTopLevel',
      'EducationTopLevelSection, Skills',
      'EducationTopLevelSection, VolunteerExperienceTopLevel',
      'ExperienceTopLevelSection, EducationTopLevelSection',
      'ExperienceTopLevelSection, Interests',
      'ExperienceTopLevelSection, Skills',
      'Featured, Activity',
      'Highlights, About',
      'Highlights, Activity',
      'HonorsTopLevel, Interests',
      'HonorsTopLevel, LanguageTopLevel',
      'HonorsTopLevel, TestScoresTopLevel',
      'Interests, Causes',
      'LanguageTopLevel, Interests',
      'LanguageTopLevel, Organizations',
      'Organizations, Interests',
      'Patents, Interests',
      'Projects, Skills',
      'PublicationTopLevelSection, Interests',
      'PublicationTopLevelSection, LanguageTopLevel',
      'PublicationTopLevelSection, Organizations',
      'RecommendationsTopLevel, CourseTopLevelSection',
      'RecommendationsTopLevel, Interests',
      'RecommendationsTopLevel, LanguageTopLevel',
      'RecommendationsTopLevel, Patents',
      'RecommendationsTopLevel, PublicationTopLevelSection',
      'Services, Activity',
      'Services, Featured',
      'Skills, CourseTopLevelSection',
      'Skills, HonorsTopLevel',
      'Skills, Interests',
      'Skills, LanguageTopLevel',
      'Skills, Patents',
      'Skills, PublicationTopLevelSection',
      'Skills, RecommendationsTopLevel',
      'TestScoresTopLevel, LanguageTopLevel',
      'Topcard, About',
      'Topcard, Activity',
      'Topcard, Analytics',
      'Topcard, Featured',
      'Topcard, Highlights',
      'VolunteerExperienceTopLevel, RecommendationsTopLevel',
      'VolunteerExperienceTopLevel, Skills',

    ]);

    /** @type {Scroller~What} */
    static #sectionsWhat = {
      name: `${this.name} sections`,
      containerItems: [
        {
          container: '[data-testid="lazy-column"]',
          items: [
            // Sections of interest.
            'section:not([aria-roledescription="carousel"])',
          ].join(','),
        },
      ],
    };

    static #uidSectionPrefix

    #checkingPartialOrder = false
    #entryScroller
    #lastScroller
    #sectionScroller

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

      this.logger.log('Initializing scroller:', this.sections.item);

      this.logger.leaving(me);
    }

    #resetEntries = () => {
      if (this.#entryScroller) {
        this.#entryScroller.destroy();
        this.#entryScroller = null;
      }
      this.entries;
    }

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

      // Grab the per-user prefix for the current profile that is used for
      // many `section` identifiers.
      const topCard = document.querySelector(
        `[${CKEY}$="${TOP_CARD}"]`
      );
      Profile.#uidSectionPrefix = topCard?.getAttribute(CKEY)
        ?.slice(0, -TOP_CARD.length);

      this.logger.leaving(me);
    }

    #checkPartialOrder = () => {
      const me = this.#checkPartialOrder.name;
      this.logger.entered(me, this.#checkingPartialOrder);

      if (!this.#checkingPartialOrder) {
        this.#checkingPartialOrder = true;
        const startItem = this.sections.item;
        this.sections.last();
        const lastItem = this.sections.item;
        this.sections.first();

        while (this.sections.item !== lastItem) {
          const current = this.sections.item;
          this.sections.next();
          const left = current.dataset.scrollerId;
          const right = this.sections.item.dataset.scrollerId;
          const pair = `${left}, ${right}`;
          if (!Profile.#sectionsPartialOrder.has(pair)) {
            Profile.#sectionsPartialOrder.add(pair);
            NH.base.issues.post('Missing Profile pairing', `'${pair}',`);
          }
        }
        this.sections.goto(startItem);
        this.#checkingPartialOrder = false;
      }

      this.logger.leaving(me);
    }

    #onEntryChange = () => {
      this.#lastScroller = this.entries;
    }

    #onSectionChange = () => {
      this.#resetEntries();
      this.#lastScroller = this.sections;
      if (litOptions.enableAlertUnknownProfileSections) {
        this.#checkPartialOrder();
      }
    }

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

  }

  /**
   * Class for handling the Events page.
   * TODO(#236): WIP.
   */
  class Events extends Page {

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

      this.addService(LinkedInStyleService, this)
        .addStyles(LinkedIn.Style.ONE);

      this.addService(VMKeyboardService)
        .addInstance(this);

      spa.details.navbarScrollerFixup(Events.#collectionsHow);
      spa.details.navbarScrollerFixup(Events.#eventsHow);

      this.#collectionScroller = new Scroller(
        Events.#collectionsWhat, Events.#collectionsHow
      );
      this.addService(LinkedInScrollerService)
        .setScroller(this.#collectionScroller);

      this.#collectionScroller.dispatcher
        .on('change', this.#onCollectionChange);
    }

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueCollectionIdentifier(element) {
      const me = Events.uniqueCollectionIdentifier.name;
      this.logger.entered(me, element);

      let content = '';
      const h1 = LinkedIn.h1(element);
      const h2 = LinkedIn.h2(element);

      if (h2) {
        content = h2;
      }
      if (h1) {
        content = h1;
      }
      if (!content) {
        content = this.defaultUid(element);
      }

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

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueEventIdentifier(element) {
      const me = Events.uniqueEventIdentifier.name;
      this.logger.entered(me, element);

      let content = '';
      const anchor = element.querySelector('a');

      if (anchor?.href) {
        content = new URL(anchor.href).pathname;
      }
      if (!content) {
        content = this.defaultUid(element);
      }

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

    /** @type {Scroller} */
    get collections() {
      return this.#collectionScroller;
    }

    /** @type {Scroller} */
    get events() {
      if (!this.#eventScroller && this.collections.item) {
        this.#eventScroller = new Scroller(
          {base: this.collections.item, ...Events.#eventsWhat},
          Events.#eventsHow
        );
        this.#eventScroller.dispatcher
          .on('change', this.#onEventChange)
          .on('out-of-range', this.#returnToCollection);
      }
      return this.#eventScroller;
    }

    nextEventsCollection = new Shortcut(
      'j',
      'Next event collection',
      () => {
        this.collections.next();
      }
    );

    prevEventsCollection = new Shortcut(
      'k',
      'Previous event collection',
      () => {
        this.collections.prev();
      }
    );

    nextEvent = new Shortcut(
      'n',
      'Next event in collection',
      () => {
        this.events.next();
      }
    );

    prevEvent = new Shortcut(
      'p',
      'Previous event in collection',
      () => {
        this.events.prev();
      }
    );

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

    lastItem = new Shortcut(
      '>',
      'Go to last item',
      () => {
        this.#lastScroller.last();
      }
    );

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

    shareItem = new Shortcut(
      'S',
      'Share the current item, if available',
      () => {
        const me = 'shareItem';
        const item = this.events.item;
        this.logger.entered(me, item);

        if (item) {
          NH.web.clickElement(item,
            ['button.events-components-shared-support-share__share-button']);
        }

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

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

    /** @type {Scroller~What} */
    static #collectionsWhat = {
      name: `${this.name} collections`,
      containerItems: [
        {
          container: 'main:has(> section)',
          items: [
            // Major collections
            ':scope > section',
          ].join(','),
        },
      ],
    };

    static #details = {
      pathname: '/events/',
      pageReadySelector: '#share-linkedin-small',
    };

    /** @type {Scroller~How} */
    static #eventsHow = {
      uidCallback: Events.uniqueEventIdentifier,
      classes: ['dick'],
      snapToTop: false,
    };

    /** @type {Scroller~What} */
    static #eventsWhat = {
      name: `${this.name} events`,
      selectors: [
        // Your events collection
        ':scope > section > div',
        // Most event collections
        ':scope > main > div > section',
        // Exclusive for Premium
        ':scope > main > div > div > section',
        // Show more
        ':scope > footer',
      ],
    };

    #collectionScroller
    #eventScroller
    #lastScroller

    #resetEvents = () => {
      this.#eventScroller?.destroy();
      this.#eventScroller = null;
      this.events;
    }

    #onEventChange = () => {
      this.#lastScroller = this.events;
    }

    #returnToCollection = () => {
      this.collections.item = this.collections.item;
    }

    #onCollectionChange = () => {
      this.#resetEvents();
      this.#lastScroller = this.collections;
    }

  }

  /**
   * Class for handling the SearchResultsPeople page.
   * TODO(#209): WIP.
   */
  class SearchResultsPeople extends Page {

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

      this.addService(LinkedInStyleService, this)
        .addStyles(LinkedIn.Style.TWO);

      this.addService(VMKeyboardService)
        .addInstance(this);

      this.addService(LinkedInToolbarService, this)
        .addHows(
          SearchResultsPeople.#resultsHow,
          SearchResultsPeople.#paginationHow
        )
        .postActivateHook(this.#toolbarHook);
    }

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniquePaginationIdentifier(element) {
      const me = SearchResultsPeople.uniquePaginationIdentifier.name;
      this.logger.entered(me, element);

      const content = this.defaultUid(element);

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

    /**
     * @implements {Scroller~uidCallback}
     * @param {Element} element - Element to examine.
     * @returns {string} - A value unique to this element.
     */
    static uniqueResultIdentifier(element) {
      const me = SearchResultsPeople.uniqueResultIdentifier.name;
      this.logger.entered(me, element);

      let content = '';
      const href = element.href;

      if (href) {
        content = new URL(href).pathname;
      }
      if (!content) {
        content = this.defaultUid(element);
      }

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

    /** @type {Scroller} */
    get paginator() {
      if (!this.#paginationScroller) {
        this.#paginationScroller = new Scroller(
          SearchResultsPeople.#paginationWhat,
          SearchResultsPeople.#paginationHow
        );
        this.addService(LinkedInScrollerService)
          .setScroller(this.#paginationScroller);
        this.#paginationScroller.dispatcher
          .on('activate', this.#onPaginationActivate)
          .on('change', this.#onPaginationChange);
      }
      return this.#paginationScroller;
    }

    /** @type {Scroller} */
    get results() {
      if (!this.#resultScroller) {
        this.#resultScroller = new Scroller(SearchResultsPeople.#resultsWhat,
          SearchResultsPeople.#resultsHow);
        this.addService(LinkedInScrollerService)
          .setScroller(this.#resultScroller);
        this.#resultScroller.dispatcher
          .on('change', this.#onResultChange);

        this.#lastScroller = this.#resultScroller;
      }
      return this.#resultScroller;
    }

    nextResult = new Shortcut(
      'j',
      'Next result',
      () => {
        this.results.next();
      }
    );

    prevResult = new Shortcut(
      'k',
      'Previous result',
      () => {
        this.results.prev();
      }
    );

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

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

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

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

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

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

    gotoFilter = new Shortcut(
      'F',
      'Move focus to the search filters',
      () => {
        const element = document.querySelector(
          `[${CKEY}="SearchResults_SearchResultsFilterBar"] [role="button"]`
        );
        NH.web.focusOnElement(element);
      }
    );

    static #details = {
      pathname: '/search/results/people/',
      pageReadySelector: 'div > footer',
    };

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

    /** @type {Scroller~What} */
    static #paginationWhat = {
      name: `${this.name} pagination`,
      containerItems: [
        {
          // This selector is also used in #onPaginationActivate.
          container: 'main ul[data-testid="pagination-controls-list"]',
          items: ':scope > li',
        },
      ],
    };

    /** @type {Scroller~How} */
    static #resultsHow = {
      uidCallback: SearchResultsPeople.uniqueResultIdentifier,
      classes: ['dick'],
      snapToTop: false,
    };

    /** @type {Scroller~What} */
    static #resultsWhat = {
      name: `${this.name} cards`,
      containerItems: [
        {
          container: '[data-testid="lazy-column"]',
          items: `a[${CKEY}]:not([aria-label])`,
        },
      ],
    };

    #lastScroller
    #paginationScroller
    #resultScroller

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

      this.logger.log('Initializing paginator:', this.paginator.item);
      this.logger.log('Initializing results:', this.results.item);

      this.logger.leaving(me);
    }

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

      try {
        const timeout = 2000;
        const item = await NH.web.waitForSelector(
          'div.artdeco-pagination > ul > li.selected',
          timeout
        );
        this.paginator.goto(item);

        // The previous line popped the page to the bottom, so go to someplace
        // reasonable.  On a similar page, JobsCollections, the URL changes to
        // match the current card, so it watches that to avoid this same
        // problem.
        const result = this.results.item;
        if (result) {
          this.results.goto(result);
        } else {
          this.results.first();
        }
      } catch (e) {
        this.logger.log('Results paginator not found, staying put');
      }

      this.logger.leaving(me);
    }

    #onPaginationChange = () => {
      this.#lastScroller = this.paginator;
    }

    #onResultChange = () => {
      this.#lastScroller = this.results;
    }

  }

  /**
   * 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 {LinkedIn} 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._initializeInfoView();
      document.addEventListener('focus', this._onFocus, true);
      document.addEventListener('urlchange', this.#onUrlChange, true);
      this.#startUrlMonitor();
      this.#details.done();
    }

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

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

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

    /** @type {Set<Page>} - A copy of the current active pages. */
    get activePages() {
      return new Set(this.#activePages);
    }

    /** @type {LinkedIn} */
    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) => {
      const me = 'onFocus';
      this.logger.entered(me, 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;
      }

      this.logger.leaving(me);
    }

    /**
     * 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);
    }

    /** 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;' +
          ' font-size: 1.6rem;' +
          ' line-height: 1.5em;' +
          ' 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} code { background-color: ButtonFace; }`,
        `#${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 {` +
          ' list-style: unset;' +
          ' 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 nameElement = document.createElement('div');
      nameElement.innerHTML =
        `<b>${APP_LONG}</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(nameElement, instructions);
      return dialog;
    }

    /**
     * Add basic dialog with an embedded tabbed 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);
        // Force this to run on the next event loop.
        setTimeout(this.#forceFocusEvent, 0);
        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(),
        LinkedIn.aboutTab(),
        this.#details.newsTab(),
        LinkedIn.errorTab('spa'),
        this.#details.licenseTab(),
      ];

      this._addInfoStyle();
      this._addInfoDialog(tabGenerators);
    }

    _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.pageId}`;
    }

    /**
     * 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 = page.pageName;
      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 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`);
      }
    }

    /**
     * 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 me = 'activate';
      this.logger.entered(me, pathname, this.#activePageNames());

      const pages = this._findPages(pathname);
      const oldPages = new Set(this.#activePages);
      const newPages = new Set(pages);
      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;

      this.logger.leaving(me, this.#activePageNames());
    }

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

    #details
    #id
    #logger
    #name
    #oldUrl

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

    /** @returns {string[]} - Names of active pages. */
    #activePageNames = () => {
      const names = Array.from(this.#activePages, x => x.pageName);
      return names;
    }

    /** Force any 'focus' handlers to run. */
    #forceFocusEvent = () => {
      document.activeElement.dispatchEvent(new Event('focus'));
    }

    /**
     * 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
     * {LinkedIn.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();

  // TODO(#240): Due to changes in start up, this value is no longer set
  // before Pages are registered.
  linkedIn.navbarHeightPixels = 16;

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

  const spa = new SPA(linkedIn);
  spa.register(Global);
  spa.register(Feed);
  spa.register(MyNetwork);
  spa.register(InvitationManager);
  spa.register(Jobs);
  spa.register(JobsCollections);
  spa.register(JobsView);
  spa.register(Messaging);
  spa.register(Notifications);
  spa.register(Profile);
  spa.register(Events);
  spa.register(SearchResultsPeople);
  spa.activate(window.location.pathname);

  log.log('Initialization successful.');

})();