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

})();