« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/static/js/client
diff options
context:
space:
mode:
Diffstat (limited to 'src/static/js/client')
-rw-r--r--src/static/js/client/additional-names-box.js150
-rw-r--r--src/static/js/client/album-commentary-sidebar.js212
-rw-r--r--src/static/js/client/art-tag-gallery-filter.js151
-rw-r--r--src/static/js/client/art-tag-network.js147
-rw-r--r--src/static/js/client/artist-external-link-tooltip.js196
-rw-r--r--src/static/js/client/css-compatibility-assistant.js30
-rw-r--r--src/static/js/client/datetimestamp-tooltip.js36
-rw-r--r--src/static/js/client/dragged-link.js62
-rw-r--r--src/static/js/client/hash-link.js146
-rw-r--r--src/static/js/client/hoverable-tooltip.js1102
-rw-r--r--src/static/js/client/image-overlay.js385
-rw-r--r--src/static/js/client/index.js237
-rw-r--r--src/static/js/client/intrapage-dot-switcher.js82
-rw-r--r--src/static/js/client/live-mouse-position.js21
-rw-r--r--src/static/js/client/lyrics-switcher.js70
-rw-r--r--src/static/js/client/quick-description.js62
-rw-r--r--src/static/js/client/scripted-link.js285
-rw-r--r--src/static/js/client/sidebar-search.js1147
-rw-r--r--src/static/js/client/sticky-heading.js345
-rw-r--r--src/static/js/client/summary-nested-link.js48
-rw-r--r--src/static/js/client/text-with-tooltip.js34
-rw-r--r--src/static/js/client/wiki-search.js239
22 files changed, 5187 insertions, 0 deletions
diff --git a/src/static/js/client/additional-names-box.js b/src/static/js/client/additional-names-box.js
new file mode 100644
index 00000000..195ba25d
--- /dev/null
+++ b/src/static/js/client/additional-names-box.js
@@ -0,0 +1,150 @@
+/* eslint-env browser */
+
+import {cssProp} from '../client-util.js';
+
+import {info as hashLinkInfo} from './hash-link.js';
+import {info as stickyHeadingInfo} from './sticky-heading.js';
+
+export const info = {
+  id: 'additionalNamesBoxInfo',
+
+  box: null,
+
+  links: null,
+  stickyHeadingLink: null,
+
+  contentContainer: null,
+  mainContentContainer: null,
+
+  state: {
+    visible: false,
+  },
+};
+
+export function getPageReferences() {
+  info.box =
+    document.getElementById('additional-names-box');
+
+  info.links =
+    document.querySelectorAll('a[href="#additional-names-box"]');
+
+  info.stickyHeadingLink =
+    document.querySelector(
+      '.content-sticky-heading-container' +
+      ' ' +
+      'a[href="#additional-names-box"]' +
+      ':not(:where([inert] *))');
+
+  info.contentContainer =
+    document.querySelector('#content');
+
+  info.mainContentContainer =
+    document.querySelector('#content .main-content-container');
+}
+
+export function addInternalListeners() {
+  hashLinkInfo.event.beforeHashLinkScrolls.push(({target}) => {
+    if (target === info.box) {
+      return false;
+    }
+  });
+
+  stickyHeadingInfo.event.whenStuckStatusChanges.push((index, stuck) => {
+    const {state} = info;
+
+    if (!info.stickyHeadingLink) return;
+
+    const container = stickyHeadingInfo.contentContainers[index];
+    if (container !== info.contentContainer) return;
+
+    if (stuck) {
+      if (!state.visible) {
+        info.stickyHeadingLink.removeAttribute('href');
+
+        if (info.stickyHeadingLink.hasAttribute('title')) {
+          info.stickyHeadingLink.dataset.restoreTitle = info.stickyHeadingLink.getAttribute('title');
+          info.stickyHeadingLink.removeAttribute('title');
+        }
+      }
+    } else {
+      info.stickyHeadingLink.setAttribute('href', '#additional-names-box');
+
+      const {restoreTitle} = info.stickyHeadingLink.dataset;
+      if (restoreTitle) {
+        info.stickyHeadingLink.setAttribute('title', restoreTitle);
+        delete info.stickyHeadingLink.dataset.restoreTitle;
+      }
+    }
+  });
+}
+
+export function addPageListeners() {
+  for (const link of info.links) {
+    link.addEventListener('click', domEvent => {
+      handleAdditionalNamesBoxLinkClicked(domEvent);
+    });
+  }
+}
+
+function handleAdditionalNamesBoxLinkClicked(domEvent) {
+  const {state} = info;
+
+  domEvent.preventDefault();
+
+  if (!domEvent.target.hasAttribute('href')) return;
+  if (!info.box || !info.mainContentContainer) return;
+
+  const margin =
+    +(cssProp(info.box, 'scroll-margin-top').replace('px', ''));
+
+  const {top} =
+    (state.visible
+      ? info.box.getBoundingClientRect()
+      : info.mainContentContainer.getBoundingClientRect());
+
+  const {bottom, height} =
+    (state.visible
+      ? info.box.getBoundingClientRect()
+      : {bottom: null});
+
+  const boxFitsInFrame =
+    (height
+      ? height < window.innerHeight - margin - 60
+      : null);
+
+  const worthScrolling =
+    top + 20 < margin ||
+
+    (height && boxFitsInFrame
+      ? top > 0.7 * window.innerHeight
+   : height && !boxFitsInFrame
+      ? top > 0.4 * window.innerHeight
+      : top > 0.5 * window.innerHeight) ||
+
+    (bottom && boxFitsInFrame
+      ? bottom > window.innerHeight - 20
+      : false);
+
+  if (worthScrolling) {
+    if (!state.visible) {
+      toggleAdditionalNamesBox();
+    }
+
+    window.scrollTo({
+      top: window.scrollY + top - margin,
+      behavior: 'smooth',
+    });
+  } else {
+    toggleAdditionalNamesBox();
+  }
+}
+
+export function toggleAdditionalNamesBox() {
+  const {state} = info;
+
+  state.visible = !state.visible;
+  info.box.style.display =
+    (state.visible
+      ? 'block'
+      : 'none');
+}
diff --git a/src/static/js/client/album-commentary-sidebar.js b/src/static/js/client/album-commentary-sidebar.js
new file mode 100644
index 00000000..c5eaf81b
--- /dev/null
+++ b/src/static/js/client/album-commentary-sidebar.js
@@ -0,0 +1,212 @@
+/* eslint-env browser */
+
+import {empty} from '../../shared-util/sugar.js';
+
+import {info as hashLinkInfo} from './hash-link.js';
+import {info as stickyHeadingInfo} from './sticky-heading.js';
+
+export const info = {
+  id: 'albumCommentarySidebarInfo',
+
+  sidebar: null,
+  sidebarHeading: null,
+
+  sidebarTrackLinks: null,
+  sidebarTrackDirectories: null,
+
+  sidebarTrackSections: null,
+  sidebarTrackSectionStartIndices: null,
+
+  state: {
+    currentTrackSection: null,
+    currentTrackLink: null,
+    justChangedTrackSection: false,
+  },
+};
+
+export function getPageReferences() {
+  if (document.documentElement.dataset.urlKey !== 'localized.albumCommentary') {
+    return;
+  }
+
+  info.sidebar =
+    document.getElementById('sidebar-left');
+
+  info.sidebarHeading =
+    info.sidebar.querySelector('h1');
+
+  info.sidebarTrackLinks =
+    Array.from(info.sidebar.querySelectorAll('li a'));
+
+  info.sidebarTrackDirectories =
+    info.sidebarTrackLinks
+      .map(el => el.getAttribute('href')?.slice(1) ?? null);
+
+  info.sidebarTrackSections =
+    Array.from(info.sidebar.getElementsByTagName('details'));
+
+  info.sidebarTrackSectionStartIndices =
+    info.sidebarTrackSections
+      .map(details => details.querySelector('ol, ul'))
+      .reduce(
+        (accumulator, _list, index, array) =>
+          (empty(accumulator)
+            ? [0]
+            : [
+              ...accumulator,
+              (accumulator[accumulator.length - 1] +
+                array[index - 1].querySelectorAll('li a').length),
+            ]),
+        []);
+}
+
+function scrollAlbumCommentarySidebar() {
+  const {state} = info;
+  const {currentTrackLink, currentTrackSection} = state;
+
+  if (!currentTrackLink) {
+    return;
+  }
+
+  const {sidebar, sidebarHeading} = info;
+
+  const scrollTop = sidebar.scrollTop;
+
+  const headingRect = sidebarHeading.getBoundingClientRect();
+  const sidebarRect = sidebar.getBoundingClientRect();
+
+  const stickyPadding = headingRect.height;
+  const sidebarViewportHeight = sidebarRect.height - stickyPadding;
+
+  const linkRect = currentTrackLink.getBoundingClientRect();
+  const sectionRect = currentTrackSection.getBoundingClientRect();
+
+  const sectionTopEdge =
+    sectionRect.top - (sidebarRect.top - scrollTop);
+
+  const sectionHeight =
+    sectionRect.height;
+
+  const sectionScrollTop =
+    sectionTopEdge - stickyPadding - 10;
+
+  const linkTopEdge =
+    linkRect.top - (sidebarRect.top - scrollTop);
+
+  const linkBottomEdge =
+    linkRect.bottom - (sidebarRect.top - scrollTop);
+
+  const linkScrollTop =
+    linkTopEdge - stickyPadding - 5;
+
+  const linkVisibleFromTopOfSection =
+    linkBottomEdge - sectionTopEdge > sidebarViewportHeight;
+
+  const linkScrollBottom =
+    linkScrollTop - sidebarViewportHeight + linkRect.height + 20;
+
+  const maxScrollInViewport =
+    scrollTop + stickyPadding + sidebarViewportHeight;
+
+  const minScrollInViewport =
+    scrollTop + stickyPadding;
+
+  if (linkBottomEdge > maxScrollInViewport) {
+    if (linkVisibleFromTopOfSection) {
+      sidebar.scrollTo({top: linkScrollBottom, behavior: 'smooth'});
+    } else {
+      sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'});
+    }
+  } else if (linkTopEdge < minScrollInViewport) {
+    if (linkVisibleFromTopOfSection) {
+      sidebar.scrollTo({top: linkScrollTop, behavior: 'smooth'});
+    } else {
+      sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'});
+    }
+  } else if (state.justChangedTrackSection) {
+    if (sectionHeight < sidebarViewportHeight) {
+      sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'});
+    }
+  }
+}
+
+function markDirectoryAsCurrentForAlbumCommentary(trackDirectory) {
+  const {state} = info;
+
+  const trackIndex =
+    (trackDirectory
+      ? info.sidebarTrackDirectories
+          .indexOf(trackDirectory)
+      : -1);
+
+  const sectionIndex =
+    (trackIndex >= 0
+      ? info.sidebarTrackSectionStartIndices
+          .findIndex((start, index, array) =>
+            (index === array.length - 1
+              ? true
+              : trackIndex < array[index + 1]))
+      : -1);
+
+  const sidebarTrackLink =
+    (trackIndex >= 0
+      ? info.sidebarTrackLinks[trackIndex]
+      : null);
+
+  const sidebarTrackSection =
+    (sectionIndex >= 0
+      ? info.sidebarTrackSections[sectionIndex]
+      : null);
+
+  state.currentTrackLink?.classList?.remove('current');
+  state.currentTrackLink = sidebarTrackLink;
+  state.currentTrackLink?.classList?.add('current');
+
+  if (sidebarTrackSection !== state.currentTrackSection) {
+    if (sidebarTrackSection && !sidebarTrackSection.open) {
+      if (state.currentTrackSection) {
+        state.currentTrackSection.open = false;
+      }
+
+      sidebarTrackSection.open = true;
+    }
+
+    state.currentTrackSection?.classList?.remove('current');
+    state.currentTrackSection = sidebarTrackSection;
+    state.currentTrackSection?.classList?.add('current');
+    state.justChangedTrackSection = true;
+  } else {
+    state.justChangedTrackSection = false;
+  }
+}
+
+export function addInternalListeners() {
+  if (!info.sidebar) {
+    return;
+  }
+
+  const mainContentIndex =
+    (stickyHeadingInfo.contentContainers ?? [])
+      .findIndex(({id}) => id === 'content');
+
+  if (mainContentIndex === -1) return;
+
+  stickyHeadingInfo.event.whenDisplayedHeadingChanges.push((index, {newHeading}) => {
+    if (index !== mainContentIndex) return;
+    if (hashLinkInfo.state.scrollingAfterClick) return;
+
+    const trackDirectory =
+      (newHeading
+        ? newHeading.id
+        : null);
+
+    markDirectoryAsCurrentForAlbumCommentary(trackDirectory);
+    scrollAlbumCommentarySidebar();
+  });
+
+  hashLinkInfo.event.whenHashLinkClicked.push(({link}) => {
+    const hash = link.getAttribute('href').slice(1);
+    if (!info.sidebarTrackDirectories.includes(hash)) return;
+    markDirectoryAsCurrentForAlbumCommentary(hash);
+  });
+}
diff --git a/src/static/js/client/art-tag-gallery-filter.js b/src/static/js/client/art-tag-gallery-filter.js
new file mode 100644
index 00000000..fd40d1a2
--- /dev/null
+++ b/src/static/js/client/art-tag-gallery-filter.js
@@ -0,0 +1,151 @@
+/* eslint-env browser */
+
+export const info = {
+  id: 'artTagGalleryFilterInfo',
+
+  featuredAllLine: null,
+  showingAllLine: null,
+  showingAllLink: null,
+
+  featuredDirectLine: null,
+  showingDirectLine: null,
+  showingDirectLink: null,
+
+  featuredIndirectLine: null,
+  showingIndirectLine: null,
+  showingIndirectLink: null,
+
+  gridItems: null,
+  gridItemsOnlyFeaturedIndirectly: null,
+  gridItemsFeaturedDirectly: null,
+};
+
+export function getPageReferences() {
+  if (document.documentElement.dataset.urlKey !== 'localized.artTagGallery') {
+    return;
+  }
+
+  info.featuredAllLine =
+    document.getElementById('featured-all-line');
+
+  info.featuredDirectLine =
+    document.getElementById('featured-direct-line');
+
+  info.featuredIndirectLine =
+    document.getElementById('featured-indirect-line');
+
+  info.showingAllLine =
+    document.getElementById('showing-all-line');
+
+  info.showingDirectLine =
+    document.getElementById('showing-direct-line');
+
+  info.showingIndirectLine =
+    document.getElementById('showing-indirect-line');
+
+  info.showingAllLink =
+    info.showingAllLine?.querySelector('a') ?? null;
+
+  info.showingDirectLink =
+    info.showingDirectLine?.querySelector('a') ?? null;
+
+  info.showingIndirectLink =
+    info.showingIndirectLine?.querySelector('a') ?? null;
+
+  info.gridItems =
+    Array.from(
+      document.querySelectorAll('#content .grid-listing .grid-item'));
+
+  info.gridItemsOnlyFeaturedIndirectly =
+    info.gridItems
+      .filter(gridItem => gridItem.classList.contains('featured-indirectly'));
+
+  info.gridItemsFeaturedDirectly =
+    info.gridItems
+      .filter(gridItem => !gridItem.classList.contains('featured-indirectly'));
+}
+
+function filterArtTagGallery(showing) {
+  let gridItemsToShow;
+
+  switch (showing) {
+    case 'all':
+      gridItemsToShow = info.gridItems;
+      break;
+
+    case 'direct':
+      gridItemsToShow = info.gridItemsFeaturedDirectly;
+      break;
+
+    case 'indirect':
+      gridItemsToShow = info.gridItemsOnlyFeaturedIndirectly;
+      break;
+  }
+
+  for (const gridItem of info.gridItems) {
+    if (gridItemsToShow.includes(gridItem)) {
+      gridItem.style.removeProperty('display');
+    } else {
+      gridItem.style.display = 'none';
+    }
+  }
+}
+
+export function addPageListeners() {
+  const orderShowing = [
+    'all',
+    'direct',
+    'indirect',
+  ];
+
+  const orderFeaturedLines = [
+    info.featuredAllLine,
+    info.featuredDirectLine,
+    info.featuredIndirectLine,
+  ];
+
+  const orderShowingLines = [
+    info.showingAllLine,
+    info.showingDirectLine,
+    info.showingIndirectLine,
+  ];
+
+  const orderShowingLinks = [
+    info.showingAllLink,
+    info.showingDirectLink,
+    info.showingIndirectLink,
+  ];
+
+  for (let index = 0; index < orderShowing.length; index++) {
+    if (!orderShowingLines[index]) continue;
+
+    let nextIndex = index;
+    do {
+      if (nextIndex === orderShowing.length) {
+        nextIndex = 0;
+      } else {
+        nextIndex++;
+      }
+    } while (!orderShowingLinks[nextIndex]);
+
+    const currentFeaturedLine = orderFeaturedLines[index];
+    const currentShowingLine = orderShowingLines[index];
+    const currentShowingLink = orderShowingLinks[index];
+
+    const nextFeaturedLine = orderFeaturedLines[nextIndex];
+    const nextShowingLine = orderShowingLines[nextIndex];
+    const nextShowing = orderShowing[nextIndex];
+
+    currentShowingLink.addEventListener('click', event => {
+      event.preventDefault();
+
+      currentFeaturedLine.style.display = 'none';
+      currentShowingLine.style.display = 'none';
+
+      nextFeaturedLine.style.display = 'block';
+      nextShowingLine.style.display = 'block';
+
+      filterArtTagGallery(nextShowing);
+    });
+  }
+}
diff --git a/src/static/js/client/art-tag-network.js b/src/static/js/client/art-tag-network.js
new file mode 100644
index 00000000..44e10c11
--- /dev/null
+++ b/src/static/js/client/art-tag-network.js
@@ -0,0 +1,147 @@
+/* eslint-env browser */
+
+import {cssProp} from '../client-util.js';
+
+import {atOffset, stitchArrays} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'artTagNetworkInfo',
+
+  noneStatLink: null,
+  totalUsesStatLink: null,
+  directUsesStatLink: null,
+  descendantsStatLink: null,
+  leavesStatLink: null,
+
+  tagsWithoutStats: null,
+  tagsWithStats: null,
+
+  totalUsesStats: null,
+  directUsesStats: null,
+  descendantsStats: null,
+  leavesStats: null,
+};
+
+export function getPageReferences() {
+  if (
+    document.documentElement.dataset.urlKey !== 'localized.listing' ||
+    document.documentElement.dataset.urlValue0 !== 'tags/network'
+  ) {
+    return;
+  }
+
+  info.noneStatLink =
+    document.getElementById('network-stat-none');
+
+  info.totalUsesStatLink =
+    document.getElementById('network-stat-total-uses');
+
+  info.directUsesStatLink =
+    document.getElementById('network-stat-direct-uses');
+
+  info.descendantsStatLink =
+    document.getElementById('network-stat-descendants');
+
+  info.leavesStatLink =
+    document.getElementById('network-stat-leaves');
+
+  info.tagsWithoutStats =
+    document.querySelectorAll('.network-tag:not(.with-stat)');
+
+  info.tagsWithStats =
+    document.querySelectorAll('.network-tag.with-stat');
+
+  info.totalUsesStats =
+    Array.from(document.getElementsByClassName('network-tag-total-uses-stat'));
+
+  info.directUsesStats =
+    Array.from(document.getElementsByClassName('network-tag-direct-uses-stat'));
+
+  info.descendantsStats =
+    Array.from(document.getElementsByClassName('network-tag-descendants-stat'));
+
+  info.leavesStats =
+    Array.from(document.getElementsByClassName('network-tag-leaves-stat'));
+}
+
+export function addPageListeners() {
+  if (!info.noneStatLink) return;
+
+  const linkOrder = [
+    info.noneStatLink,
+    info.totalUsesStatLink,
+    info.directUsesStatLink,
+    info.descendantsStatLink,
+    info.leavesStatLink,
+  ];
+
+  const statsOrder = [
+    null,
+    info.totalUsesStats,
+    info.directUsesStats,
+    info.descendantsStats,
+    info.leavesStats,
+  ];
+
+  const stitched =
+    stitchArrays({
+      link: linkOrder,
+      stats: statsOrder,
+    });
+
+  for (const [index, {link}] of stitched.entries()) {
+    const next = atOffset(stitched, index, +1, {wrap: true});
+
+    link.addEventListener('click', domEvent => {
+      domEvent.preventDefault();
+
+      cssProp(link, 'display', 'none');
+      cssProp(next.link, 'display', null);
+
+      if (next.stats === null) {
+        hideArtTagNetworkStats();
+      } else {
+        showArtTagNetworkStats(next.stats);
+      }
+    });
+  }
+}
+
+function showArtTagNetworkStats(stats) {
+  for (const tagElement of info.tagsWithoutStats) {
+    cssProp(tagElement, 'display', 'none');
+  }
+
+  for (const tagElement of info.tagsWithStats) {
+    cssProp(tagElement, 'display', null);
+  }
+
+  const allStats = [
+    ...info.totalUsesStats,
+    ...info.directUsesStats,
+    ...info.descendantsStats,
+    ...info.leavesStats,
+  ];
+
+  const otherStats =
+    allStats
+      .filter(stat => !stats.includes(stat));
+
+  for (const statElement of otherStats) {
+    cssProp(statElement, 'display', 'none');
+  }
+
+  for (const statElement of stats) {
+    cssProp(statElement, 'display', null);
+  }
+}
+
+function hideArtTagNetworkStats() {
+  for (const tagElement of info.tagsWithoutStats) {
+    cssProp(tagElement, 'display', null);
+  }
+
+  for (const tagElement of info.tagsWithStats) {
+    cssProp(tagElement, 'display', 'none');
+  }
+}
diff --git a/src/static/js/client/artist-external-link-tooltip.js b/src/static/js/client/artist-external-link-tooltip.js
new file mode 100644
index 00000000..21ddfb91
--- /dev/null
+++ b/src/static/js/client/artist-external-link-tooltip.js
@@ -0,0 +1,196 @@
+/* eslint-env browser */
+
+import {accumulateSum, empty} from '../../shared-util/sugar.js';
+
+import {info as hoverableTooltipInfo, repositionCurrentTooltip}
+  from './hoverable-tooltip.js';
+
+// These don't need to have tooltip events specially added as
+// they're implemented with "text with tooltip" components.
+
+export const info = {
+  id: 'artistExternalLinkTooltipInfo',
+
+  tooltips: null,
+  tooltipRows: null,
+
+  settings: {
+    // This is the maximum distance, in CSS pixels, that the mouse
+    // can appear to be moving per second while still considered
+    // "idle". A greater value means higher tolerance for small
+    // movements.
+    maximumIdleSpeed: 40,
+
+    // Leaving the mouse idle for this amount of time, over a single
+    // row of the tooltip, will cause a column of supplemental info
+    // to display.
+    mouseIdleShowInfoDelay: 1000,
+
+    // If none of these tooltips are visible for this amount of time,
+    // the supplemental info column is hidden. It'll never disappear
+    // while a tooltip is actually visible.
+    hideInfoAfterTooltipHiddenDelay: 2250,
+  },
+
+  state: {
+    // This is shared by all tooltips.
+    showingTooltipInfo: false,
+
+    mouseIdleTimeout: null,
+    hideInfoTimeout: null,
+
+    mouseMovementPositions: [],
+    mouseMovementTimestamps: [],
+  },
+};
+
+export function getPageReferences() {
+  info.tooltips =
+    Array.from(document.getElementsByClassName('contribution-tooltip'));
+
+  info.tooltipRows =
+    info.tooltips.map(tooltip =>
+      Array.from(tooltip.getElementsByClassName('icon')));
+}
+
+export function addInternalListeners() {
+  hoverableTooltipInfo.event.whenTooltipShows.push(({tooltip}) => {
+    const {state} = info;
+
+    if (info.tooltips.includes(tooltip)) {
+      clearTimeout(state.hideInfoTimeout);
+      state.hideInfoTimeout = null;
+    }
+  });
+
+  hoverableTooltipInfo.event.whenTooltipHides.push(() => {
+    const {settings, state} = info;
+
+    if (state.showingTooltipInfo) {
+      state.hideInfoTimeout =
+        setTimeout(() => {
+          state.hideInfoTimeout = null;
+          hideArtistExternalLinkTooltipInfo();
+        }, settings.hideInfoAfterTooltipHiddenDelay);
+    } else {
+      clearTimeout(state.mouseIdleTimeout);
+      state.mouseIdleTimeout = null;
+    }
+  });
+}
+
+export function addPageListeners() {
+  for (const tooltip of info.tooltips) {
+    tooltip.addEventListener('mousemove', domEvent => {
+      handleArtistExternalLinkTooltipMouseMoved(domEvent);
+    });
+
+    tooltip.addEventListener('mouseout', () => {
+      const {state} = info;
+
+      clearTimeout(state.mouseIdleTimeout);
+      state.mouseIdleTimeout = null;
+    });
+  }
+
+  for (const tooltipRow of info.tooltipRows.flat()) {
+    tooltipRow.addEventListener('mouseover', () => {
+      const {state} = info;
+
+      clearTimeout(state.mouseIdleTimeout);
+      state.mouseIdleTimeout = null;
+    });
+  }
+}
+
+function handleArtistExternalLinkTooltipMouseMoved(domEvent) {
+  const {settings, state} = info;
+
+  if (state.showingTooltipInfo) {
+    return;
+  }
+
+  // Clean out expired mouse movements
+
+  const expiryTime = 1000;
+
+  if (!empty(state.mouseMovementTimestamps)) {
+    const firstRecentMovementIndex =
+      state.mouseMovementTimestamps
+        .findIndex(value => Date.now() - value <= expiryTime);
+
+    if (firstRecentMovementIndex === -1) {
+      state.mouseMovementTimestamps.splice(0);
+      state.mouseMovementPositions.splice(0);
+    } else if (firstRecentMovementIndex > 0) {
+      state.mouseMovementTimestamps.splice(0, firstRecentMovementIndex - 1);
+      state.mouseMovementPositions.splice(0, firstRecentMovementIndex - 1);
+    }
+  }
+
+  state.mouseMovementTimestamps.push(Date.now());
+  state.mouseMovementPositions.push([domEvent.screenX, domEvent.screenY]);
+
+  // We can't really compute speed without having
+  // at least two data points!
+  if (state.mouseMovementPositions.length < 2) {
+    return;
+  }
+
+  const movementTravelDistances =
+    state.mouseMovementPositions.map((current, index, array) => {
+      if (index === 0) return 0;
+
+      const previous = array[index - 1];
+      const deltaX = current[0] - previous[0];
+      const deltaY = current[1] - previous[1];
+      return Math.sqrt(deltaX ** 2 + deltaY ** 2);
+    });
+
+  const totalTravelDistance =
+    accumulateSum(movementTravelDistances);
+
+  // In seconds rather than milliseconds.
+  const timeSinceFirstMovement =
+    (Date.now() - state.mouseMovementTimestamps[0]) / 1000;
+
+  const averageSpeed =
+    Math.floor(totalTravelDistance / timeSinceFirstMovement);
+
+  if (averageSpeed > settings.maximumIdleSpeed) {
+    clearTimeout(state.mouseIdleTimeout);
+    state.mouseIdleTimeout = null;
+  }
+
+  if (state.mouseIdleTimeout) {
+    return;
+  }
+
+  state.mouseIdleTimeout =
+    setTimeout(() => {
+      state.mouseIdleTimeout = null;
+      showArtistExternalLinkTooltipInfo();
+    }, settings.mouseIdleShowInfoDelay);
+}
+
+function showArtistExternalLinkTooltipInfo() {
+  const {state} = info;
+
+  state.showingTooltipInfo = true;
+
+  for (const tooltip of info.tooltips) {
+    tooltip.classList.add('show-info');
+  }
+
+  repositionCurrentTooltip();
+}
+
+function hideArtistExternalLinkTooltipInfo() {
+  const {state} = info;
+
+  state.showingTooltipInfo = false;
+
+  for (const tooltip of info.tooltips) {
+    tooltip.classList.remove('show-info');
+  }
+}
diff --git a/src/static/js/client/css-compatibility-assistant.js b/src/static/js/client/css-compatibility-assistant.js
new file mode 100644
index 00000000..aa637cc4
--- /dev/null
+++ b/src/static/js/client/css-compatibility-assistant.js
@@ -0,0 +1,30 @@
+/* eslint-env browser */
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'cssCompatibilityAssistantInfo',
+
+  coverArtworks: null,
+  coverArtworkImageDetails: null,
+};
+
+export function getPageReferences() {
+  info.coverArtworks =
+    Array.from(document.querySelectorAll('.cover-artwork'));
+
+  info.coverArtworkImageDetails =
+    info.coverArtworks
+      .map(artwork => artwork.querySelector('.image-details'));
+}
+
+export function mutatePageContent() {
+  stitchArrays({
+    coverArtwork: info.coverArtworks,
+    imageDetails: info.coverArtworkImageDetails,
+  }).forEach(({coverArtwork, imageDetails}) => {
+      if (imageDetails) {
+        coverArtwork.classList.add('has-image-details');
+      }
+    });
+}
diff --git a/src/static/js/client/datetimestamp-tooltip.js b/src/static/js/client/datetimestamp-tooltip.js
new file mode 100644
index 00000000..46d1cd5b
--- /dev/null
+++ b/src/static/js/client/datetimestamp-tooltip.js
@@ -0,0 +1,36 @@
+/* eslint-env browser */
+
+// TODO: Maybe datetimestamps can just be incorporated into text-with-tooltip?
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+import {registerTooltipElement, registerTooltipHoverableElement}
+  from './hoverable-tooltip.js';
+
+export const info = {
+  id: 'datetimestampTooltipInfo',
+
+  hoverables: null,
+  tooltips: null,
+};
+
+export function getPageReferences() {
+  const spans =
+    Array.from(document.querySelectorAll('span.datetimestamp.has-tooltip'));
+
+  info.hoverables =
+    spans.map(span => span.querySelector('time'));
+
+  info.tooltips =
+    spans.map(span => span.querySelector('span.datetimestamp-tooltip'));
+}
+
+export function addPageListeners() {
+  for (const {hoverable, tooltip} of stitchArrays({
+    hoverable: info.hoverables,
+    tooltip: info.tooltips,
+  })) {
+    registerTooltipElement(tooltip);
+    registerTooltipHoverableElement(hoverable, tooltip);
+  }
+}
diff --git a/src/static/js/client/dragged-link.js b/src/static/js/client/dragged-link.js
new file mode 100644
index 00000000..56021e7f
--- /dev/null
+++ b/src/static/js/client/dragged-link.js
@@ -0,0 +1,62 @@
+/* eslint-env browser */
+
+export const info = {
+  id: `draggedLinkInfo`,
+
+  state: {
+    latestDraggedLink: null,
+    observedLinks: new WeakSet(),
+  },
+};
+
+export function getPageReferences() {
+  // First start handling all the links that currently exist.
+
+  for (const a of document.getElementsByTagName('a')) {
+    observeLink(a);
+    addDragListener(a);
+  }
+
+  // Then add a mutation observer to track new links.
+
+  const observer = new MutationObserver(records => {
+    for (const record of records) {
+      for (const node of record.addedNodes) {
+        if (node.nodeName !== 'A') continue;
+        observeLink(node);
+      }
+    }
+  });
+
+  observer.observe(document.body, {
+    subtree: true,
+    childList: true,
+  });
+}
+
+export function getLatestDraggedLink() {
+  const {state} = info;
+
+  if (state.latestDraggedLink) {
+    return state.latestDraggedLink.deref() ?? null;
+  } else {
+    return null;
+  }
+}
+
+function observeLink(link) {
+  const {state} = info;
+
+  if (state.observedLinks.has(link)) return;
+
+  state.observedLinks.add(link);
+  addDragListener(link);
+}
+
+function addDragListener(link) {
+  const {state} = info;
+
+  link.addEventListener('dragstart', _domEvent => {
+    state.latestDraggedLink = new WeakRef(link);
+  });
+}
diff --git a/src/static/js/client/hash-link.js b/src/static/js/client/hash-link.js
new file mode 100644
index 00000000..27035e29
--- /dev/null
+++ b/src/static/js/client/hash-link.js
@@ -0,0 +1,146 @@
+/* eslint-env browser */
+
+import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js';
+
+import {dispatchInternalEvent} from '../client-util.js';
+
+export const info = {
+  id: 'hashLinkInfo',
+
+  links: null,
+  hrefs: null,
+  targets: null,
+
+  state: {
+    highlightedTarget: null,
+    scrollingAfterClick: false,
+    concludeScrollingStateInterval: null,
+  },
+
+  event: {
+    beforeHashLinkScrolls: [],
+    whenHashLinkClicked: [],
+  },
+};
+
+export function getPageReferences() {
+  info.links =
+    Array.from(document.querySelectorAll('a[href^="#"]:not([href="#"])'));
+
+  info.hrefs =
+    info.links
+      .map(link => link.getAttribute('href'));
+
+  info.targets =
+    info.hrefs
+      .map(href => document.getElementById(href.slice(1)));
+
+  filterMultipleArrays(
+    info.links,
+    info.hrefs,
+    info.targets,
+    (_link, _href, target) => target);
+}
+
+function processScrollingAfterHashLinkClicked() {
+  const {state} = info;
+
+  if (state.concludeScrollingStateInterval) return;
+
+  let lastScroll = window.scrollY;
+  state.scrollingAfterClick = true;
+  state.concludeScrollingStateInterval = setInterval(() => {
+    if (Math.abs(window.scrollY - lastScroll) < 10) {
+      clearInterval(state.concludeScrollingStateInterval);
+      state.scrollingAfterClick = false;
+      state.concludeScrollingStateInterval = null;
+    } else {
+      lastScroll = window.scrollY;
+    }
+  }, 200);
+}
+
+export function addPageListeners() {
+  // Instead of defining a scroll offset (to account for the sticky heading)
+  // in JavaScript, we interface with the CSS property 'scroll-margin-top'.
+  // This lets the scroll offset be consolidated where it makes sense, and
+  // sets an appropriate offset when (re)loading a page with hash for free!
+
+  const {state, event} = info;
+
+  for (const {hashLink, href, target} of stitchArrays({
+    hashLink: info.links,
+    href: info.hrefs,
+    target: info.targets,
+  })) {
+    hashLink.addEventListener('click', evt => {
+      if (evt.metaKey || evt.shiftKey || evt.ctrlKey || evt.altKey) {
+        return;
+      }
+
+      // Don't do anything if the target element isn't actually visible!
+      if (target.offsetParent === null) {
+        return;
+      }
+
+      // Allow event handlers to prevent scrolling.
+      const listenerResults =
+        dispatchInternalEvent(event, 'beforeHashLinkScrolls', {
+          link: hashLink,
+          target,
+        });
+
+      if (listenerResults.includes(false)) {
+        return;
+      }
+
+      // Hide skipper box right away, so the layout is updated on time for the
+      // math operations coming up next.
+      const skipper = document.getElementById('skippers');
+      skipper.style.display = 'none';
+      setTimeout(() => skipper.style.display = '');
+
+      const box = target.getBoundingClientRect();
+      const style = window.getComputedStyle(target);
+
+      const scrollY =
+          window.scrollY
+        + box.top
+        - style['scroll-margin-top'].replace('px', '');
+
+      evt.preventDefault();
+      history.pushState({}, '', href);
+      window.scrollTo({top: scrollY, behavior: 'smooth'});
+      target.focus({preventScroll: true});
+
+      const maxScroll =
+          document.body.scrollHeight
+        - window.innerHeight;
+
+      if (scrollY > maxScroll && target.classList.contains('content-heading')) {
+        if (state.highlightedTarget) {
+          state.highlightedTarget.classList.remove('highlight-hash-link');
+        }
+
+        target.classList.add('highlight-hash-link');
+        state.highlightedTarget = target;
+      }
+
+      processScrollingAfterHashLinkClicked();
+
+      dispatchInternalEvent(event, 'whenHashLinkClicked', {
+        link: hashLink,
+        target,
+      });
+    });
+  }
+
+  for (const target of info.targets) {
+    target.addEventListener('animationend', evt => {
+      if (evt.animationName !== 'highlight-hash-link') return;
+      target.classList.remove('highlight-hash-link');
+      if (target !== state.highlightedTarget) return;
+      state.highlightedTarget = null;
+    });
+  }
+}
diff --git a/src/static/js/client/hoverable-tooltip.js b/src/static/js/client/hoverable-tooltip.js
new file mode 100644
index 00000000..9569de3e
--- /dev/null
+++ b/src/static/js/client/hoverable-tooltip.js
@@ -0,0 +1,1102 @@
+/* eslint-env browser */
+
+import {empty, filterMultipleArrays} from '../../shared-util/sugar.js';
+
+import {WikiRect} from '../rectangles.js';
+
+import {
+  cssProp,
+  dispatchInternalEvent,
+  getVisuallyContainingElement,
+  pointIsOverAnyOf,
+} from '../client-util.js';
+
+import {info as stickyHeadingInfo} from './sticky-heading.js';
+
+export const info = {
+  id: 'hoverableTooltipInfo',
+
+  settings: {
+    // Hovering has two speed settings. The normal setting is used by default,
+    // and once a tooltip is displayed as a result of hover, the entire tooltip
+    // system will enter a "fast hover mode" - hovering will activate tooltips
+    // sooner. "Fast hover mode" is disabled after a sustained duration of not
+    // hovering over any hoverables; it's meant only to accelerate switching
+    // tooltips while still deciding, or getting a quick overview across more
+    // than one tooltip.
+    normalHoverInfoDelay: 400,
+    fastHoveringInfoDelay: 150,
+    endFastHoveringDelay: 500,
+
+    // Focusing has a single speed setting, which is how long it will take to
+    // enter a functional "focus mode" (though it's not actually implemented
+    // in terms of this state). As soon as "focus mode" is entered, the tooltip
+    // for the current hoverable is displayed, and focusing another hoverable
+    // will cause the current tooltip to be swapped for that one immediately.
+    // "Focus mode" ends as soon as anything apart from a tooltip or hoverable
+    // is focused, and it will be necessary to wait on this delay again.
+    focusInfoDelay: 750,
+
+    hideTooltipDelay: 500,
+
+    // If a tooltip that's transitioning to hidden is hovered during the grace
+    // period (or the corresponding hoverable is hovered at any point in the
+    // transition), it'll cancel out of this animation immediately.
+    transitionHiddenDuration: 300,
+    inertGracePeriod: 100,
+  },
+
+  state: {
+    // These maps store a record for each registered element and related state
+    // and registration info, if applicable.
+    registeredTooltips: new Map(),
+    registeredHoverables: new Map(),
+
+    // These are common across all tooltips, rather than stored individually,
+    // based on the principles that 1) only a single tooltip can be displayed
+    // at once, and 2) likewise, only a single hoverable can be hovered,
+    // focused, or otherwise active at once.
+    hoverTimeout: null,
+    focusTimeout: null,
+    touchTimeout: null,
+    hideTimeout: null,
+    transitionHiddenTimeout: null,
+    inertGracePeriodTimeout: null,
+    currentlyShownTooltip: null,
+    currentlyActiveHoverable: null,
+    currentlyTransitioningHiddenTooltip: null,
+    previouslyActiveHoverable: null,
+    tooltipWasJustHidden: false,
+    hoverableWasRecentlyTouched: false,
+
+    // Fast hovering is a global mode which is activated as soon as any tooltip
+    // is displayed and turns off after a delay of no hoverables being hovered.
+    // Note that fast hovering may be turned off while hovering a tooltip, but
+    // it will never be turned off while idling over a hoverable.
+    fastHovering: false,
+    endFastHoveringTimeout: false,
+
+    // These track the identifiers of current touches and a record of current
+    // identifiers that are "banished" by scrolling - that is, touches which
+    // existed while the page scrolled and were probably responsible for that
+    // scrolling. This is a bit loose (we can't actually tell which touches
+    // caused the page to scroll) but it's intended to keep scrolling the page
+    // from causing the current tooltip to be hidden.
+    currentTouchIdentifiers: new Set(),
+    touchIdentifiersBanishedByScrolling: new Set(),
+
+    // This is a two-item array that tracks the direction we've already
+    // dynamically placed the current tooltip. If we *reposition* the tooltip
+    // (because its dimensions changed), we'll try to follow this anchor first.
+    dynamicTooltipAnchorDirection: null,
+  },
+
+  event: {
+    whenTooltipShows: [],
+    whenTooltipHides: [],
+  },
+};
+
+// Adds DOM event listeners, so must be called during addPageListeners step.
+export function registerTooltipElement(tooltip) {
+  const {state} = info;
+
+  if (!tooltip)
+    throw new Error(`Expected tooltip`);
+
+  if (state.registeredTooltips.has(tooltip))
+    throw new Error(`This tooltip is already registered`);
+
+  // No state or registration info here.
+  state.registeredTooltips.set(tooltip, {});
+
+  tooltip.addEventListener('mouseenter', () => {
+    handleTooltipMouseEntered(tooltip);
+  });
+
+  tooltip.addEventListener('mouseleave', () => {
+    handleTooltipMouseLeft(tooltip);
+  });
+
+  tooltip.addEventListener('focusin', event => {
+    handleTooltipReceivedFocus(tooltip, event.relatedTarget);
+  });
+
+  tooltip.addEventListener('focusout', event => {
+    // This event gets activated for tabbing *between* links inside the
+    // tooltip, which is no good and certainly doesn't represent the focus
+    // leaving the tooltip.
+    if (currentlyShownTooltipHasFocus(event.relatedTarget)) return;
+
+    handleTooltipLostFocus(tooltip, event.relatedTarget);
+  });
+}
+
+// Adds DOM event listeners, so must be called during addPageListeners step.
+export function registerTooltipHoverableElement(hoverable, tooltip) {
+  const {state} = info;
+
+  if (!hoverable || !tooltip)
+    if (hoverable)
+      throw new Error(`Expected hoverable and tooltip, got only hoverable`);
+    else
+      throw new Error(`Expected hoverable and tooltip, got neither`);
+
+  if (!state.registeredTooltips.has(tooltip))
+    throw new Error(`Register tooltip before registering hoverable`);
+
+  if (state.registeredHoverables.has(hoverable))
+    throw new Error(`This hoverable is already registered`);
+
+  state.registeredHoverables.set(hoverable, {tooltip});
+
+  hoverable.addEventListener('mouseenter', () => {
+    handleTooltipHoverableMouseEntered(hoverable);
+  });
+
+  hoverable.addEventListener('mouseleave', () => {
+    handleTooltipHoverableMouseLeft(hoverable);
+  });
+
+  hoverable.addEventListener('focusin', event => {
+    handleTooltipHoverableReceivedFocus(hoverable, event);
+  });
+
+  hoverable.addEventListener('focusout', event => {
+    handleTooltipHoverableLostFocus(hoverable, event);
+  });
+
+  hoverable.addEventListener('touchend', event => {
+    handleTooltipHoverableTouchEnded(hoverable, event);
+  });
+
+  hoverable.addEventListener('click', event => {
+    handleTooltipHoverableClicked(hoverable, event);
+  });
+}
+
+function handleTooltipMouseEntered(tooltip) {
+  const {state} = info;
+
+  if (state.currentlyTransitioningHiddenTooltip) {
+    cancelTransitioningTooltipHidden(true);
+    return;
+  }
+
+  if (state.currentlyShownTooltip !== tooltip) return;
+
+  // Don't time out the current tooltip while hovering it.
+
+  if (state.hideTimeout) {
+    clearTimeout(state.hideTimeout);
+    state.hideTimeout = null;
+  }
+}
+
+function handleTooltipMouseLeft(tooltip) {
+  const {settings, state} = info;
+
+  if (state.currentlyShownTooltip !== tooltip) return;
+
+  // Start timing out the current tooltip when it's left. This could be
+  // canceled by mousing over a hoverable, or back over the tooltip again.
+  if (!state.hideTimeout) {
+    state.hideTimeout =
+      setTimeout(() => {
+        state.hideTimeout = null;
+        hideCurrentlyShownTooltip();
+      }, settings.hideTooltipDelay);
+  }
+}
+
+function handleTooltipReceivedFocus(_tooltip) {
+  const {state} = info;
+
+  // Cancel the tooltip-hiding timeout if it exists. The tooltip will never
+  // be hidden while it contains the focus anyway, but this ensures the timeout
+  // will be suitably reset when the tooltip loses focus.
+  if (state.hideTimeout) {
+    clearTimeout(state.hideTimeout);
+    state.hideTimeout = null;
+  }
+}
+
+function handleTooltipLostFocus(_tooltip) {
+  // Hide the current tooltip right away when it loses focus. Specify intent
+  // to replace - while we don't strictly know if another tooltip is going to
+  // immediately replace it, the mode of navigating with tab focus (once one
+  // tooltip has been activated) is a "switch focus immediately" kind of
+  // interaction in its nature.
+  hideCurrentlyShownTooltip(true);
+}
+
+function handleTooltipHoverableMouseEntered(hoverable) {
+  const {settings, state} = info;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  // If this tooltip was transitioning to hidden, hovering should cancel that
+  // animation and show it immediately.
+
+  if (tooltip === state.currentlyTransitioningHiddenTooltip) {
+    cancelTransitioningTooltipHidden(true);
+    return;
+  }
+
+  // Start a timer to show the corresponding tooltip, with the delay depending
+  // on whether fast hovering or not. This could be canceled by mousing out of
+  // the hoverable.
+
+  const hoverTimeoutDelay =
+    (state.fastHovering
+      ? settings.fastHoveringInfoDelay
+      : settings.normalHoverInfoDelay);
+
+  state.hoverTimeout =
+    setTimeout(() => {
+      state.hoverTimeout = null;
+      state.fastHovering = true;
+      showTooltipFromHoverable(hoverable);
+    }, hoverTimeoutDelay);
+
+  // Don't stop fast hovering while over any hoverable.
+  if (state.endFastHoveringTimeout) {
+    clearTimeout(state.endFastHoveringTimeout);
+    state.endFastHoveringTimeout = null;
+  }
+
+  // Don't time out the current tooltip while over any hoverable.
+  if (state.hideTimeout) {
+    clearTimeout(state.hideTimeout);
+    state.hideTimeout = null;
+  }
+}
+
+function handleTooltipHoverableMouseLeft(_hoverable) {
+  const {settings, state} = info;
+
+  // Don't show a tooltip when not over a hoverable!
+  if (state.hoverTimeout) {
+    clearTimeout(state.hoverTimeout);
+    state.hoverTimeout = null;
+  }
+
+  // Start timing out fast hovering (if active) when not over a hoverable.
+  // This will only be canceled by mousing over another hoverable.
+  if (state.fastHovering && !state.endFastHoveringTimeout) {
+    state.endFastHoveringTimeout =
+      setTimeout(() => {
+        state.endFastHoveringTimeout = null;
+        state.fastHovering = false;
+      }, settings.endFastHoveringDelay);
+  }
+
+  // Start timing out the current tooltip when mousing not over a hoverable.
+  // This could be canceled by mousing over another hoverable, or over the
+  // currently shown tooltip.
+  if (state.currentlyShownTooltip && !state.hideTimeout) {
+    state.hideTimeout =
+      setTimeout(() => {
+        state.hideTimeout = null;
+        hideCurrentlyShownTooltip();
+      }, settings.hideTooltipDelay);
+  }
+}
+
+function handleTooltipHoverableReceivedFocus(hoverable) {
+  const {settings, state} = info;
+
+  // By default, display the corresponding tooltip after a delay.
+
+  state.focusTimeout =
+    setTimeout(() => {
+      state.focusTimeout = null;
+      showTooltipFromHoverable(hoverable);
+    }, settings.focusInfoDelay);
+
+  // If a tooltip was just hidden - which is almost certainly a result of the
+  // focus changing - then display this tooltip immediately, canceling the
+  // above timeout.
+
+  if (state.tooltipWasJustHidden) {
+    clearTimeout(state.focusTimeout);
+    state.focusTimeout = null;
+
+    showTooltipFromHoverable(hoverable);
+  }
+}
+
+function handleTooltipHoverableLostFocus(hoverable, domEvent) {
+  const {state} = info;
+
+  // Don't show a tooltip from focusing a hoverable if it isn't focused
+  // anymore! If another hoverable is receiving focus, that will be evaluated
+  // and set its own focus timeout after we clear the previous one here.
+  if (state.focusTimeout) {
+    clearTimeout(state.focusTimeout);
+    state.focusTimeout = null;
+  }
+
+  // Unless focus is entering the tooltip itself, hide the tooltip immediately.
+  // This will set the tooltipWasJustHidden flag, which is detected by a newly
+  // focused hoverable, if applicable. Always specify intent to replace when
+  // navigating via tab focus. (Check `handleTooltipLostFocus` for details.)
+  if (!currentlyShownTooltipHasFocus(domEvent.relatedTarget)) {
+    hideCurrentlyShownTooltip(true);
+  }
+}
+
+function handleTooltipHoverableTouchEnded(hoverable, domEvent) {
+  const {state} = info;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  // Don't proceed if this hoverable's tooltip is already visible - in that
+  // case touching the hoverable again should behave just like a normal click.
+  if (state.currentlyShownTooltip === tooltip) {
+    // If the hoverable was *recently* touched - meaning that this is a second
+    // touchend in short succession - then just letting the click come through
+    // naturally would (depending on timing) not actually navigate anywhere,
+    // because we've deliberately banished the *first* touch from navigation.
+    // We do want the second touch to navigate, so clear that recently-touched
+    // state, allowing this touch's click to behave as normal.
+    if (state.hoverableWasRecentlyTouched) {
+      clearTimeout(state.touchTimeout);
+      state.touchTimeout = null;
+      state.hoverableWasRecentlyTouched = false;
+    }
+
+    // Otherwise, this is just a second touch after enough time has passed
+    // that the one which showed the tooltip is no longer "recent", and we're
+    // not in any special state. The link will navigate to its page just like
+    // normal.
+    return;
+  }
+
+  const touches = Array.from(domEvent.changedTouches);
+  const identifiers = touches.map(touch => touch.identifier);
+
+  // Don't process touch events that were "banished" because the page was
+  // scrolled while those touches were active, and most likely as a result of
+  // them.
+  filterMultipleArrays(touches, identifiers,
+    (_touch, identifier) =>
+      !state.touchIdentifiersBanishedByScrolling.has(identifier));
+
+  if (empty(touches)) return;
+
+  // Don't proceed if none of the (just-ended) touches ended over the
+  // hoverable.
+
+  const pointIsOverThisHoverable = pointIsOverAnyOf([hoverable]);
+
+  const anyTouchEndedOverHoverable =
+    touches.some(({clientX, clientY}) =>
+      pointIsOverThisHoverable(clientX, clientY));
+
+  if (!anyTouchEndedOverHoverable) {
+    return;
+  }
+
+  if (state.touchTimeout) {
+    clearTimeout(state.touchTimeout);
+    state.touchTimeout = null;
+  }
+
+  // Show the tooltip right away.
+  showTooltipFromHoverable(hoverable);
+
+  // Set a state, for a brief but not instantaneous period, indicating that a
+  // hoverable was recently touched. The touchend event may precede the click
+  // event by some time, and we don't want to navigate away from the page as
+  // a result of the click event which this touch precipitated.
+  state.hoverableWasRecentlyTouched = true;
+  state.touchTimeout =
+    setTimeout(() => {
+      state.touchTimeout = null;
+      state.hoverableWasRecentlyTouched = false;
+    }, 1200);
+}
+
+function handleTooltipHoverableClicked(hoverable) {
+  const {state} = info;
+
+  // Don't navigate away from the page if the this hoverable was recently
+  // touched (and had its tooltip activated). That flag won't be set if its
+  // tooltip was already open before the touch.
+  if (
+    state.currentlyActiveHoverable === hoverable &&
+    state.hoverableWasRecentlyTouched
+  ) {
+    event.preventDefault();
+  }
+}
+
+export function currentlyShownTooltipHasFocus(focusElement = document.activeElement) {
+  const {state} = info;
+
+  const {
+    currentlyShownTooltip: tooltip,
+    currentlyActiveHoverable: hoverable,
+  } = state;
+
+  // If there's no tooltip, it can't possibly have focus.
+  if (!tooltip) return false;
+
+  // If the tooltip literally contains (or is) the focused element, then that's
+  // the principle condition we're looking for.
+  if (tooltip.contains(focusElement)) return true;
+
+  // If the hoverable *which opened the tooltip* is focused, then that also
+  // represents the tooltip being focused (in its currently shown state).
+  if (hoverable.contains(focusElement)) return true;
+
+  return false;
+}
+
+export function beginTransitioningTooltipHidden(tooltip) {
+  const {settings, state} = info;
+
+  if (state.currentlyTransitioningHiddenTooltip) {
+    cancelTransitioningTooltipHidden();
+  }
+
+  cssProp(tooltip, {
+    'display': 'block',
+    'opacity': '0',
+
+    'transition-property': 'opacity',
+    'transition-timing-function':
+      `steps(${Math.ceil(settings.transitionHiddenDuration / 60)}, end)`,
+    'transition-duration':
+      `${settings.transitionHiddenDuration / 1000}s`,
+  });
+
+  state.currentlyTransitioningHiddenTooltip = tooltip;
+  state.transitionHiddenTimeout =
+    setTimeout(() => {
+      endTransitioningTooltipHidden();
+    }, settings.transitionHiddenDuration);
+}
+
+export function cancelTransitioningTooltipHidden(andShow = false) {
+  const {state} = info;
+
+  endTransitioningTooltipHidden();
+
+  if (andShow) {
+    showTooltipFromHoverable(state.previouslyActiveHoverable);
+  }
+}
+
+export function endTransitioningTooltipHidden() {
+  const {state} = info;
+  const {currentlyTransitioningHiddenTooltip: tooltip} = state;
+
+  if (!tooltip) return;
+
+  cssProp(tooltip, {
+    'display': null,
+    'opacity': null,
+    'transition-property': null,
+    'transition-timing-function': null,
+    'transition-duration': null,
+  });
+
+  state.currentlyTransitioningHiddenTooltip = null;
+
+  if (state.inertGracePeriodTimeout) {
+    clearTimeout(state.inertGracePeriodTimeout);
+    state.inertGracePeriodTimeout = null;
+  }
+
+  if (state.transitionHiddenTimeout) {
+    clearTimeout(state.transitionHiddenTimeout);
+    state.transitionHiddenTimeout = null;
+  }
+}
+
+export function hideCurrentlyShownTooltip(intendingToReplace = false) {
+  const {settings, state, event} = info;
+  const {currentlyShownTooltip: tooltip} = state;
+
+  // If there was no tooltip to begin with, we're functionally in the desired
+  // state already, so return true.
+  if (!tooltip) return true;
+
+  // Never hide the tooltip if it's focused.
+  if (currentlyShownTooltipHasFocus()) return false;
+
+  state.currentlyActiveHoverable.classList.remove('has-visible-tooltip');
+
+  // If there's no intent to replace this tooltip, it's the last one currently
+  // apparent in the interaction, and should be hidden with a transition.
+  if (intendingToReplace) {
+    cssProp(tooltip, 'display', 'none');
+  } else {
+    beginTransitioningTooltipHidden(state.currentlyShownTooltip);
+  }
+
+  // Wait just a moment before making the tooltip inert. You might react
+  // (to the ghosting, or just to time passing) and realize you wanted
+  // to look at the tooltip after all - this delay gives a little buffer
+  // to second guess letting it disappear.
+  state.inertGracePeriodTimeout =
+    setTimeout(() => {
+      tooltip.inert = true;
+    }, settings.inertGracePeriod);
+
+  state.previouslyActiveHoverable = state.currentlyActiveHoverable;
+
+  state.currentlyShownTooltip = null;
+  state.currentlyActiveHoverable = null;
+
+  state.dynamicTooltipAnchorDirection = null;
+
+  // Set this for one tick of the event cycle.
+  state.tooltipWasJustHidden = true;
+  setTimeout(() => {
+    state.tooltipWasJustHidden = false;
+  });
+
+  dispatchInternalEvent(event, 'whenTooltipHides', {
+    tooltip,
+  });
+
+  return true;
+}
+
+export function showTooltipFromHoverable(hoverable) {
+  const {state, event} = info;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  if (!hideCurrentlyShownTooltip(true)) return false;
+
+  // Cancel out another tooltip that's transitioning hidden, if that's going
+  // on - it's a distraction that this tooltip is now replacing.
+  cancelTransitioningTooltipHidden();
+
+  hoverable.classList.add('has-visible-tooltip');
+
+  const isolator =
+    hoverable.closest('.isolate-tooltip-z-indexing > *');
+
+  if (isolator) {
+    for (const child of isolator.parentElement.children) {
+      cssProp(child, 'z-index', null);
+    }
+
+    cssProp(isolator, 'z-index', '1');
+  }
+
+  positionTooltipFromHoverableWithBrains(hoverable);
+
+  cssProp(tooltip, 'display', 'block');
+  tooltip.inert = false;
+
+  state.currentlyShownTooltip = tooltip;
+  state.currentlyActiveHoverable = hoverable;
+
+  state.tooltipWasJustHidden = false;
+
+  dispatchInternalEvent(event, 'whenTooltipShows', {
+    tooltip,
+  });
+
+  return true;
+}
+
+export function peekTooltipClientRect(tooltip) {
+  const oldDisplayStyle = cssProp(tooltip, 'display');
+  cssProp(tooltip, 'display', 'block');
+
+  // Tooltips have a bit of padding that makes the interactive
+  // area wider, so that you're less likely to accidentally let
+  // the tooltip disappear (by hovering outside it). But this
+  // isn't visual at all, so for placement we only care about
+  // the content element.
+  const content =
+    tooltip.querySelector('.tooltip-content');
+
+  try {
+    return WikiRect.fromElement(content);
+  } finally {
+    cssProp(tooltip, 'display', oldDisplayStyle);
+  }
+}
+
+export function repositionCurrentTooltip() {
+  const {state} = info;
+  const {currentlyActiveHoverable} = state;
+
+  if (!currentlyActiveHoverable) {
+    throw new Error(`No hoverable active to reposition tooltip from`);
+  }
+
+  positionTooltipFromHoverableWithBrains(currentlyActiveHoverable);
+}
+
+export function positionTooltipFromHoverableWithBrains(hoverable) {
+  const {state} = info;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  const anchorDirection = state.dynamicTooltipAnchorDirection;
+
+  // Reset before doing anything else. We're going to adapt to
+  // its natural placement, adjusted by CSS, which otherwise
+  // could be obscured by a placement we've previously provided.
+  resetDynamicTooltipPositioning(tooltip);
+
+  const opportunities =
+    getTooltipFromHoverablePlacementOpportunityAreas(hoverable);
+
+  const tooltipRect =
+    peekTooltipClientRect(tooltip);
+
+  // If the tooltip is already in the baseline containing area,
+  // prefer to keep it positioned naturally, adjusted by CSS
+  // instead of JavaScript.
+
+  const {numBaselineRects, idealBaseline: baselineRect} = opportunities;
+
+  if (baselineRect.contains(tooltipRect)) {
+    return;
+  }
+
+  const tryDirection = (dir1, dir2, i) => {
+    selectedRect = opportunities[dir1][dir2][i];
+    return !!selectedRect;
+  };
+
+  let selectedRect = null;
+  selectRect: {
+    if (anchorDirection) {
+      for (let i = 0; i < numBaselineRects; i++) {
+        if (tryDirection(...anchorDirection, i)) {
+          break selectRect;
+        }
+      }
+    }
+
+    for (let i = 0; i < numBaselineRects; i++) {
+      for (const [dir1, dir2] of [
+        ['down', 'right'],
+        ['down', 'left'],
+        ['right', 'down'],
+        ['left', 'down'],
+        ['right', 'up'],
+        ['left', 'up'],
+        ['up', 'right'],
+        ['up', 'left'],
+      ]) {
+        if (tryDirection(dir1, dir2, i)) {
+          state.dynamicTooltipAnchorDirection = [dir1, dir2];
+          break selectRect;
+        }
+      }
+    }
+
+    selectedRect = baselineRect;
+  }
+
+  positionTooltip(tooltip, selectedRect.x, selectedRect.y);
+}
+
+export function positionTooltip(tooltip, x, y) {
+  // Imagine what it'd be like if the tooltip were positioned
+  // with zero left/top offset, and calculate its actual offsets
+  // based on that.
+
+  cssProp(tooltip, {
+    left: `0`,
+    top: `0`,
+  });
+
+  const tooltipRect =
+    peekTooltipClientRect(tooltip);
+
+  cssProp(tooltip, {
+    left: `${x - tooltipRect.x}px`,
+    top: `${y - tooltipRect.y}px`,
+  });
+}
+
+export function resetDynamicTooltipPositioning(tooltip) {
+  cssProp(tooltip, {
+    left: null,
+    top: null,
+  });
+}
+
+export function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) {
+  const {state} = info;
+  const {tooltip} = state.registeredHoverables.get(hoverable);
+
+  const baselineRects =
+    getTooltipBaselineOpportunityAreas(tooltip);
+
+  const hoverableRect =
+    WikiRect.fromElementUnderMouse(hoverable).toExtended(5, 10);
+
+  const tooltipRect =
+    peekTooltipClientRect(tooltip);
+
+  // Get placements relative to the hoverable. Make these available by key,
+  // allowing the caller to choose by preferred orientation. Each value is
+  // an array which corresponds to the baseline areas - placement closer to
+  // front of the array indicates stronger preference. Since not all relative
+  // placements cooperate with all baseline areas, any of these arrays may
+  // include (or be entirely made of) null.
+
+  const keepIfFits = (rect) =>
+    (rect?.fits(tooltipRect)
+      ? rect
+      : null);
+
+  const prepareRegionRects = (relationalRect, direct) =>
+    baselineRects
+      .map(rect => rect.intersectionWith(relationalRect))
+      .map(direct)
+      .map(keepIfFits);
+
+  const regionRects = {
+    left:
+      prepareRegionRects(
+        WikiRect.leftOf(hoverableRect),
+        rect => WikiRect.fromRect({
+          x: rect.right,
+          y: rect.y,
+          width: -rect.width,
+          height: rect.height,
+        })),
+
+    right:
+      prepareRegionRects(
+        WikiRect.rightOf(hoverableRect),
+        rect => rect),
+
+    top:
+      prepareRegionRects(
+        WikiRect.above(hoverableRect),
+        rect => WikiRect.fromRect({
+          x: rect.x,
+          y: rect.bottom,
+          width: rect.width,
+          height: -rect.height,
+        })),
+
+    bottom:
+      prepareRegionRects(
+        WikiRect.beneath(hoverableRect),
+        rect => rect),
+  };
+
+  const neededVerticalOverlap = 30;
+  const neededHorizontalOverlap = 30;
+
+  const upTopDown =
+    WikiRect.beneath(
+      hoverableRect.top + neededVerticalOverlap - tooltipRect.height);
+
+  const downBottomUp =
+    WikiRect.above(
+      hoverableRect.bottom - neededVerticalOverlap + tooltipRect.height);
+
+  // Please don't ask us to make this but horizontal?
+  const prepareVerticalOrientationRects = (regionRects) => {
+    const orientations = {};
+
+    const orientHorizontally = (rect, i) => {
+      if (!rect) return null;
+
+      const regionRect = regionRects[i];
+      if (regionRect.width > 0) {
+        return rect;
+      } else {
+        return WikiRect.fromRect({
+          x: regionRect.right - tooltipRect.width,
+          y: rect.y,
+          width: rect.width,
+          height: rect.height,
+        });
+      }
+    };
+
+    orientations.up =
+      regionRects
+        .map(rect => rect?.intersectionWith(upTopDown))
+        .map(orientHorizontally)
+        .map(keepIfFits);
+
+    orientations.down =
+      regionRects
+        .map(rect => rect?.intersectionWith(downBottomUp))
+        .map(rect =>
+          (rect
+            ? rect.intersectionWith(WikiRect.fromRect({
+                x: rect.x,
+                y: rect.bottom - tooltipRect.height,
+                width: rect.width,
+                height: tooltipRect.height,
+              }))
+            : null))
+        .map(orientHorizontally)
+        .map(keepIfFits);
+
+    const centerRect =
+      WikiRect.fromRect({
+        x: -Infinity, width: Infinity,
+        y: hoverableRect.top
+         + hoverableRect.height / 2
+         - tooltipRect.height / 2,
+        height: tooltipRect.height,
+      });
+
+    orientations.center =
+      regionRects
+        .map(rect => rect?.intersectionWith(centerRect))
+        .map(orientHorizontally)
+        .map(keepIfFits);
+
+    return orientations;
+  };
+
+  const rightRightLeft =
+    WikiRect.leftOf(
+      hoverableRect.left - neededHorizontalOverlap + tooltipRect.width);
+
+  const leftLeftRight =
+    WikiRect.rightOf(
+      hoverableRect.left + neededHorizontalOverlap - tooltipRect.width);
+
+  // Oops.
+  const prepareHorizontalOrientationRects = (regionRects) => {
+    const orientations = {};
+
+    const orientVertically = (rect, i) => {
+      if (!rect) return null;
+
+      const regionRect = regionRects[i];
+
+      if (regionRect.height > 0) {
+        return rect;
+      } else {
+        return WikiRect.fromRect({
+          x: rect.x,
+          y: regionRect.bottom - tooltipRect.height,
+          width: rect.width,
+          height: rect.height,
+        });
+      }
+    };
+
+    orientations.left =
+      regionRects
+        .map(rect => rect?.intersectionWith(leftLeftRight))
+        .map(orientVertically)
+        .map(keepIfFits);
+
+    orientations.right =
+      regionRects
+        .map(rect => rect?.intersectionWith(rightRightLeft))
+        .map(rect =>
+          (rect
+            ? rect.intersectionWith(WikiRect.fromRect({
+                x: rect.right - tooltipRect.width,
+                y: rect.y,
+                width: rect.width,
+                height: tooltipRect.height,
+              }))
+            : null))
+        .map(orientVertically)
+        .map(keepIfFits);
+
+    // No analogous center because we don't actually use
+    // center alignment...
+
+    return orientations;
+  };
+
+  const orientationRects = {
+    left: prepareVerticalOrientationRects(regionRects.left),
+    right: prepareVerticalOrientationRects(regionRects.right),
+    down: prepareHorizontalOrientationRects(regionRects.bottom),
+    up: prepareHorizontalOrientationRects(regionRects.top),
+  };
+
+  return {
+    numBaselineRects: baselineRects.length,
+    idealBaseline: baselineRects[0],
+    ...orientationRects,
+  };
+}
+
+export function getTooltipBaselineOpportunityAreas(tooltip) {
+  // Returns multiple basic areas in order of preference, with front of the
+  // array representing greater preference.
+
+  const {stickyContainers} = stickyHeadingInfo;
+  const results = [];
+
+  const windowRect =
+    WikiRect.fromWindow().toInset(10);
+
+  const workingRect =
+    WikiRect.fromRect(windowRect);
+
+  const tooltipRect =
+    peekTooltipClientRect(tooltip);
+
+  // As a baseline, always treat the window rect as fitting the tooltip.
+  results.unshift(WikiRect.fromRect(workingRect));
+
+  const containingParent =
+    getVisuallyContainingElement(tooltip);
+
+  if (containingParent) {
+    const containingRect =
+      WikiRect.fromElement(containingParent);
+
+    // Only respect a portion of the container's padding, giving
+    // the tooltip the impression of a "raised" element.
+    const padding = side =>
+      0.5 *
+      parseFloat(cssProp(containingParent, 'padding-' + side));
+
+    const insetContainingRect =
+      containingRect.toInset({
+        left: padding('left'),
+        right: padding('right'),
+        top: padding('top'),
+        bottom: padding('bottom'),
+      });
+
+    workingRect.chopExtendingOutside(insetContainingRect);
+
+    if (!workingRect.fits(tooltipRect)) {
+      return results;
+    }
+
+    results.unshift(WikiRect.fromRect(workingRect));
+  }
+
+  // This currently assumes a maximum of one sticky container
+  // per visually containing element.
+
+  const stickyContainer =
+    stickyContainers
+      .find(el => el.parentElement === containingParent);
+
+  if (stickyContainer) {
+    const stickyRect =
+      stickyContainer.getBoundingClientRect()
+
+    // Add some padding so the tooltip doesn't line up exactly
+    // with the edge of the sticky container.
+    const beneathStickyContainer =
+      WikiRect.beneath(stickyRect, 10);
+
+    workingRect.chopExtendingOutside(beneathStickyContainer);
+
+    if (!workingRect.fits(tooltipRect)) {
+      return results;
+    }
+
+    results.unshift(WikiRect.fromRect(workingRect));
+  }
+
+  return results;
+}
+
+export function mutatePageContent() {
+  for (const isolatorRoot of document.querySelectorAll('.isolate-tooltip-z-indexing')) {
+    if (isolatorRoot.firstElementChild) {
+      cssProp(isolatorRoot.firstElementChild, 'z-index', '1');
+    }
+  }
+}
+
+export function addPageListeners() {
+  const {state} = info;
+
+  const getTouchIdentifiers = domEvent =>
+    Array.from(domEvent.changedTouches)
+      .map(touch => touch.identifier)
+      .filter(identifier => typeof identifier !== 'undefined');
+
+  document.body.addEventListener('touchstart', domEvent => {
+    for (const identifier of getTouchIdentifiers(domEvent)) {
+      state.currentTouchIdentifiers.add(identifier);
+    }
+  });
+
+  window.addEventListener('scroll', () => {
+    for (const identifier of state.currentTouchIdentifiers) {
+      state.touchIdentifiersBanishedByScrolling.add(identifier);
+    }
+  });
+
+  document.body.addEventListener('touchend', domEvent => {
+    setTimeout(() => {
+      for (const identifier of getTouchIdentifiers(domEvent)) {
+        state.currentTouchIdentifiers.delete(identifier);
+        state.touchIdentifiersBanishedByScrolling.delete(identifier);
+      }
+    });
+  });
+
+  const getHoverablesAndTooltips = () => [
+    ...Array.from(state.registeredHoverables.keys()),
+    ...Array.from(state.registeredTooltips.keys()),
+  ];
+
+  document.body.addEventListener('touchend', domEvent => {
+    const touches = Array.from(domEvent.changedTouches);
+    const identifiers = touches.map(touch => touch.identifier);
+
+    // Don't process touch events that were "banished" because the page was
+    // scrolled while those touches were active, and most likely as a result of
+    // them.
+    filterMultipleArrays(touches, identifiers,
+      (_touch, identifier) =>
+        !state.touchIdentifiersBanishedByScrolling.has(identifier));
+
+    if (empty(touches)) return;
+
+    const pointIsOverHoverableOrTooltip =
+      pointIsOverAnyOf(getHoverablesAndTooltips());
+
+    const anyTouchOverAnyHoverableOrTooltip =
+      touches.some(({clientX, clientY}) =>
+        pointIsOverHoverableOrTooltip(clientX, clientY));
+
+    if (!anyTouchOverAnyHoverableOrTooltip) {
+      hideCurrentlyShownTooltip();
+    }
+  });
+
+  document.body.addEventListener('click', domEvent => {
+    const {clientX, clientY} = domEvent;
+
+    const pointIsOverHoverableOrTooltip =
+      pointIsOverAnyOf(getHoverablesAndTooltips());
+
+    if (!pointIsOverHoverableOrTooltip(clientX, clientY)) {
+      // Hide with "intent to replace" - we aren't actually going to replace
+      // the tooltip with a new one, but this intent indicates that it should
+      // be hidden right away, instead of showing. What we're really replacing,
+      // or rather removing, is the state of interacting with tooltips at all.
+      hideCurrentlyShownTooltip(true);
+
+      // Part of that state is fast hovering, which should be canceled out.
+      state.fastHovering = false;
+      if (state.endFastHoveringTimeout) {
+        clearTimeout(state.endFastHoveringTimeout);
+        state.endFastHoveringTimeout = null;
+      }
+
+      // Also cancel out of transitioning a tooltip hidden - this isn't caught
+      // by `hideCurrentlyShownTooltip` because a transitioning-hidden tooltip
+      // doesn't count as "shown" anymore.
+      cancelTransitioningTooltipHidden();
+    }
+  });
+}
diff --git a/src/static/js/client/image-overlay.js b/src/static/js/client/image-overlay.js
new file mode 100644
index 00000000..e9e2708d
--- /dev/null
+++ b/src/static/js/client/image-overlay.js
@@ -0,0 +1,385 @@
+/* eslint-env browser */
+
+import {getColors} from '../../shared-util/colors.js';
+
+import {cssProp} from '../client-util.js';
+import {fetchWithProgress} from '../xhr-util.js';
+
+export const info = {
+  id: 'imageOverlayInfo',
+
+  container: null,
+  actionContainer: null,
+
+  viewOriginalLinks: null,
+  mainImage: null,
+  thumbImage: null,
+
+  actionContentWithoutSize: null,
+  actionContentWithSize: null,
+
+  megabytesContainer: null,
+  kilobytesContainer: null,
+  megabytesContent: null,
+  kilobytesContent: null,
+  fileSizeWarning: null,
+
+  links: null,
+};
+
+export function getPageReferences() {
+  info.container =
+    document.getElementById('image-overlay-container');
+
+  if (!info.container) return;
+
+  info.actionContainer =
+    document.getElementById('image-overlay-action-container');
+
+  info.viewOriginalLinks =
+    document.getElementsByClassName('image-overlay-view-original');
+
+  info.mainImage =
+    document.getElementById('image-overlay-image');
+
+  info.thumbImage =
+    document.getElementById('image-overlay-image-thumb');
+
+  info.actionContentWithoutSize =
+    document.getElementById('image-overlay-action-content-without-size');
+
+  info.actionContentWithSize =
+    document.getElementById('image-overlay-action-content-with-size');
+
+  info.megabytesContainer =
+    document.getElementById('image-overlay-file-size-megabytes');
+
+  info.kilobytesContainer =
+    document.getElementById('image-overlay-file-size-kilobytes');
+
+  info.megabytesContent =
+    info.megabytesContainer.querySelector('.image-overlay-file-size-count');
+
+  info.kilobytesContent =
+    info.kilobytesContainer.querySelector('.image-overlay-file-size-count');
+
+  info.fileSizeWarning =
+    document.getElementById('image-overlay-file-size-warning');
+
+  const linkQuery = [
+    '.image-link',
+    '.image-media-link',
+  ].join(', ');
+
+  info.links =
+    Array.from(document.querySelectorAll(linkQuery))
+      .filter(link => !link.closest('.no-image-preview'));
+}
+
+export function addPageListeners() {
+  if (!info.container) return;
+
+  for (const link of info.links) {
+    link.addEventListener('click', handleImageLinkClicked);
+  }
+
+  info.container.addEventListener('click', handleContainerClicked);
+  document.body.addEventListener('keydown', handleKeyDown);
+}
+
+function handleContainerClicked(evt) {
+  // Only hide the image overlay if actually clicking the background.
+  if (evt.target !== info.container) {
+    return;
+  }
+
+  // If you clicked anything near the action bar, don't hide the
+  // image overlay.
+  const rect = info.actionContainer.getBoundingClientRect();
+  if (
+    evt.clientY >= rect.top - 40 && evt.clientY <= rect.bottom + 40 &&
+    evt.clientX >= rect.left + 20 && evt.clientX <= rect.right - 20
+  ) {
+    return;
+  }
+
+  info.container.classList.remove('visible');
+}
+
+function handleKeyDown(evt) {
+  if (evt.key === 'Escape' || evt.key === 'Esc' || evt.keyCode === 27) {
+    info.container.classList.remove('visible');
+  }
+}
+
+async function handleImageLinkClicked(evt) {
+  if (evt.metaKey || evt.shiftKey || evt.altKey) {
+    return;
+  }
+
+  evt.preventDefault();
+
+  // Don't show the overlay if the image still needs to be revealed.
+  if (evt.target.closest('.reveal:not(.revealed)')) {
+    return;
+  }
+
+  info.container.classList.add('visible');
+  info.container.classList.remove('loaded');
+  info.container.classList.remove('errored');
+
+  const details = getImageLinkDetails(evt.target);
+
+  updateImageOverlayColors(details);
+  updateFileSizeInformation(details.originalFileSize);
+
+  for (const link of info.viewOriginalLinks) {
+    link.href = details.originalSrc;
+  }
+
+  await loadOverlayImage(details);
+}
+
+function getImageLinkDetails(imageLink) {
+  const a = imageLink.closest('a');
+  const img = a.querySelector('img');
+
+  const details = {
+    originalSrc:
+      a.href,
+
+    embeddedSrc:
+      img?.src ??
+      a.dataset.embedSrc,
+
+    originalFileSize:
+      img?.dataset.originalSize ??
+      a.dataset.originalSize ??
+      null,
+
+    availableThumbList:
+      img?.dataset.thumbs ??
+      a.dataset.thumbs ??
+      null,
+
+    dimensions:
+      img?.dataset.dimensions?.split('x') ??
+      a.dataset.dimensions?.split('x') ??
+      null,
+
+    color:
+      cssProp(imageLink, '--primary-color'),
+  };
+
+  Object.assign(details, getImageSources(details));
+
+  return details;
+}
+
+function getImageSources(details) {
+  if (details.availableThumbList) {
+    const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(details.availableThumbList);
+    const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(details.availableThumbList);
+    return {
+      mainSrc: details.embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${mainThumb}.jpg`),
+      thumbSrc: details.embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${smallThumb}.jpg`),
+      mainThumb: `${mainThumb}:${mainLength}`,
+      thumbThumb: `${smallThumb}:${smallLength}`,
+    };
+  } else {
+    return {
+      mainSrc: details.originalSrc,
+      thumbSrc: null,
+      mainThumb: '',
+      thumbThumb: '',
+    };
+  }
+}
+
+function updateImageOverlayColors(details) {
+  if (details.color) {
+    let colors;
+    try {
+      colors =
+        getColors(details.color, {
+          chroma: window.chroma,
+        });
+    } catch (error) {
+      console.warn(error);
+      return;
+    }
+
+    cssProp(info.container, {
+      '--primary-color': colors.primary,
+      '--deep-color': colors.deep,
+      '--deep-ghost-color': colors.deepGhost,
+      '--bg-black-color': colors.bgBlack,
+    });
+  } else {
+    cssProp(info.container, {
+      '--primary-color': null,
+      '--deep-color': null,
+      '--deep-ghost-color': null,
+      '--bg-black-color': null,
+    });
+  }
+}
+
+async function loadOverlayImage(details) {
+  if (details.thumbSrc) {
+    info.thumbImage.src = details.thumbSrc;
+    info.thumbImage.style.display = null;
+    info.container.classList.remove('no-thumb');
+  } else {
+    info.thumbImage.src = '';
+    info.thumbImage.style.display = 'none';
+    info.container.classList.add('no-thumb');
+  }
+
+  // Show the thumbnail size on each <img> element's data attributes.
+  // Y'know, just for debugging convenience.
+  info.mainImage.dataset.displayingThumb = details.mainThumb;
+  info.thumbImage.dataset.displayingThumb = details.thumbThumb;
+
+  if (details.dimensions) {
+    info.mainImage.width = details.dimensions[0];
+    info.mainImage.height = details.dimensions[1];
+    info.thumbImage.width = details.dimensions[0];
+    info.thumbImage.height = details.dimensions[1];
+    cssProp(info.thumbImage, 'aspect-ratio', details.dimensions.join('/'));
+  } else {
+    info.mainImage.removeAttribute('width');
+    info.mainImage.removeAttribute('height');
+    info.thumbImage.removeAttribute('width');
+    info.thumbImage.removeAttribute('height');
+    cssProp(info.thumbImage, 'aspect-ratio', null);
+  }
+
+  info.mainImage.addEventListener('load', handleMainImageLoaded);
+  info.mainImage.addEventListener('error', handleMainImageErrored);
+
+  const showProgress = amount => {
+    cssProp(info.container, '--download-progress', `${amount * 100}%`);
+  };
+
+  showProgress(0.00);
+
+  const response =
+    await fetchWithProgress(details.mainSrc, progress => {
+      if (progress === -1) {
+        // TODO: Indeterminate response progress cue
+        showProgress(0.00);
+      } else {
+        showProgress(0.20 + 0.80 * progress);
+      }
+    });
+
+  if (!response.status.toString().startsWith('2')) {
+    handleMainImageErrored();
+    return;
+  }
+
+  const blob = await response.blob();
+  const blobSrc = URL.createObjectURL(blob);
+
+  info.mainImage.src = blobSrc;
+  showProgress(1.00);
+
+  function handleMainImageLoaded() {
+    info.container.classList.add('loaded');
+    removeEventListeners();
+  }
+
+  function handleMainImageErrored() {
+    info.container.classList.add('errored');
+    removeEventListeners();
+  }
+
+  function removeEventListeners() {
+    info.mainImage.removeEventListener('load', handleMainImageLoaded);
+    info.mainImage.removeEventListener('error', handleMainImageErrored);
+  }
+}
+
+function parseThumbList(availableThumbList) {
+  // Parse all the available thumbnail sizes! These are provided by the actual
+  // content generation on each image.
+  const defaultThumbList = 'huge:1400 semihuge:1200 large:800 medium:400 small:250'
+  const availableSizes =
+    (availableThumbList || defaultThumbList)
+      .split(' ')
+      .map(part => part.split(':'))
+      .map(([thumb, length]) => ({thumb, length: parseInt(length)}))
+      .sort((a, b) => a.length - b.length);
+
+  return availableSizes;
+}
+
+function getPreferredThumbSize(availableThumbList) {
+  // Assuming a square, the image will be constrained to the lesser window
+  // dimension. Coefficient here matches CSS dimensions for image overlay.
+  const constrainedLength = Math.floor(Math.min(
+    0.80 * window.innerWidth,
+    0.80 * window.innerHeight));
+
+  // Match device pixel ratio, which is 2x for "retina" displays and certain
+  // device configurations.
+  const visualLength = window.devicePixelRatio * constrainedLength;
+
+  const availableSizes = parseThumbList(availableThumbList);
+
+  // Starting from the smallest dimensions, find (and return) the first
+  // available length which hits a "good enough" threshold - it's got to be
+  // at least that percent of the way to the actual displayed dimensions.
+  const goodEnoughThreshold = 0.90;
+
+  // (The last item is skipped since we'd be falling back to it anyway.)
+  for (const {thumb, length} of availableSizes.slice(0, -1)) {
+    if (Math.floor(visualLength * goodEnoughThreshold) <= length) {
+      return {thumb, length};
+    }
+  }
+
+  // If none of the items in the list were big enough to hit the "good enough"
+  // threshold, just use the largest size available.
+  return availableSizes[availableSizes.length - 1];
+}
+
+function getSmallestThumbSize(availableThumbList) {
+  // Just snag the smallest size. This'll be used for displaying the "preview"
+  // as the bigger one is loading.
+  const availableSizes = parseThumbList(availableThumbList);
+  return availableSizes[0];
+}
+
+function updateFileSizeInformation(fileSize) {
+  const fileSizeWarningThreshold = 8 * 10 ** 6;
+
+  if (!fileSize) {
+    info.actionContentWithSize.classList.remove('visible');
+    info.actionContentWithoutSize.classList.add('visible');
+    return;
+  }
+
+  info.actionContentWithoutSize.classList.remove('visible');
+  info.actionContentWithSize.classList.add('visible');
+
+  fileSize = parseInt(fileSize);
+  const round = (exp) => Math.round(fileSize / 10 ** (exp - 1)) / 10;
+
+  if (fileSize > fileSizeWarningThreshold) {
+    info.fileSizeWarning.classList.add('visible');
+  } else {
+    info.fileSizeWarning.classList.remove('visible');
+  }
+
+  if (fileSize > 10 ** 6) {
+    info.megabytesContainer.classList.add('visible');
+    info.kilobytesContainer.classList.remove('visible');
+    info.megabytesContent.innerText = round(6);
+  } else {
+    info.megabytesContainer.classList.remove('visible');
+    info.kilobytesContainer.classList.add('visible');
+    info.kilobytesContent.innerText = round(3);
+  }
+}
diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js
new file mode 100644
index 00000000..b2343f07
--- /dev/null
+++ b/src/static/js/client/index.js
@@ -0,0 +1,237 @@
+/* eslint-env browser */
+
+import '../group-contributions-table.js';
+
+import * as additionalNamesBoxModule from './additional-names-box.js';
+import * as albumCommentarySidebarModule from './album-commentary-sidebar.js';
+import * as artTagGalleryFilterModule from './art-tag-gallery-filter.js';
+import * as artTagNetworkModule from './art-tag-network.js';
+import * as artistExternalLinkTooltipModule from './artist-external-link-tooltip.js';
+import * as cssCompatibilityAssistantModule from './css-compatibility-assistant.js';
+import * as datetimestampTooltipModule from './datetimestamp-tooltip.js';
+import * as draggedLinkModule from './dragged-link.js';
+import * as hashLinkModule from './hash-link.js';
+import * as hoverableTooltipModule from './hoverable-tooltip.js';
+import * as imageOverlayModule from './image-overlay.js';
+import * as intrapageDotSwitcherModule from './intrapage-dot-switcher.js';
+import * as liveMousePositionModule from './live-mouse-position.js';
+import * as lyricsSwitcherModule from './lyrics-switcher.js';
+import * as quickDescriptionModule from './quick-description.js';
+import * as scriptedLinkModule from './scripted-link.js';
+import * as sidebarSearchModule from './sidebar-search.js';
+import * as stickyHeadingModule from './sticky-heading.js';
+import * as summaryNestedLinkModule from './summary-nested-link.js';
+import * as textWithTooltipModule from './text-with-tooltip.js';
+import * as wikiSearchModule from './wiki-search.js';
+
+export const modules = [
+  additionalNamesBoxModule,
+  albumCommentarySidebarModule,
+  artTagGalleryFilterModule,
+  artTagNetworkModule,
+  artistExternalLinkTooltipModule,
+  cssCompatibilityAssistantModule,
+  datetimestampTooltipModule,
+  draggedLinkModule,
+  hashLinkModule,
+  hoverableTooltipModule,
+  imageOverlayModule,
+  intrapageDotSwitcherModule,
+  liveMousePositionModule,
+  lyricsSwitcherModule,
+  quickDescriptionModule,
+  scriptedLinkModule,
+  sidebarSearchModule,
+  stickyHeadingModule,
+  summaryNestedLinkModule,
+  textWithTooltipModule,
+  wikiSearchModule,
+];
+
+const clientInfo = window.hsmusicClientInfo = Object.create(null);
+
+const clientSteps = {
+  getPageReferences: [],
+  addInternalListeners: [],
+  mutatePageContent: [],
+  initializeState: [],
+  addPageListeners: [],
+};
+
+for (const module of modules) {
+  const {info} = module;
+
+  if (!info) {
+    throw new Error(`Module missing info`);
+  }
+
+  const {id: infoKey} = info;
+
+  if (!infoKey) {
+    throw new Error(`Module info missing id: ` + JSON.stringify(info));
+  }
+
+  clientInfo[infoKey] = info;
+
+  for (const obj of [
+    info,
+    info.state,
+    info.settings,
+    info.event,
+  ]) {
+    if (!obj) continue;
+
+    if (obj !== info) {
+      obj[Symbol.for('hsmusic.clientInfo')] = info;
+    }
+
+    Object.preventExtensions(obj);
+  }
+
+  if (info.session) {
+    const sessionSpecs = info.session;
+
+    info.session = {};
+
+    for (const [key, spec] of Object.entries(sessionSpecs)) {
+      const hasSpec =
+        typeof spec === 'object' && spec !== null;
+
+      const defaultValue =
+        (hasSpec
+          ? spec.default ?? null
+          : spec);
+
+      let formatRead = value => value;
+      let formatWrite = value => value;
+      if (hasSpec && spec.type) {
+        switch (spec.type) {
+          case 'number':
+            formatRead = parseFloat;
+            formatWrite = String;
+            break;
+
+          case 'boolean':
+            formatRead = Boolean;
+            formatWrite = String;
+            break;
+
+          case 'string':
+            formatRead = String;
+            formatWrite = String;
+            break;
+
+          case 'json':
+            formatRead = JSON.parse;
+            formatWrite = JSON.stringify;
+            break;
+
+          default:
+            throw new Error(`Unknown type for session storage spec "${spec.type}"`);
+        }
+      }
+
+      let getMaxLength =
+        (!hasSpec
+          ? () => Infinity
+       : typeof spec.maxLength === 'function'
+          ? (info.settings
+              ? () => spec.maxLength(info.settings)
+              : () => spec.maxLength())
+          : () => spec.maxLength);
+
+      const storageKey = `hsmusic.${infoKey}.${key}`;
+
+      let fallbackValue = defaultValue;
+
+      Object.defineProperty(info.session, key, {
+        get: () => {
+          let value;
+          try {
+            value = sessionStorage.getItem(storageKey) ?? defaultValue;
+          } catch (error) {
+            if (error instanceof DOMException) {
+              value = fallbackValue;
+            } else {
+              throw error;
+            }
+          }
+
+          if (value === null) {
+            return null;
+          }
+
+          return formatRead(value);
+        },
+
+        set: (value) => {
+          if (value !== null && value !== '') {
+            value = formatWrite(value);
+          }
+
+          if (value === null) {
+            value = '';
+          }
+
+          const maxLength = getMaxLength();
+          if (value.length > maxLength) {
+            console.warn(
+              `Requested to set session storage ${storageKey} ` +
+              `beyond maximum length ${maxLength}, ` +
+              `ignoring this value.`);
+            console.trace();
+            return;
+          }
+
+          let operation;
+          if (value === '') {
+            fallbackValue = null;
+            operation = () => {
+              sessionStorage.removeItem(storageKey);
+            };
+          } else {
+            fallbackValue = value;
+            operation = () => {
+              sessionStorage.setItem(storageKey, value);
+            };
+          }
+
+          try {
+            operation();
+          } catch (error) {
+            if (!(error instanceof DOMException)) {
+              throw error;
+            }
+          }
+        },
+      });
+    }
+
+    Object.preventExtensions(info.session);
+  }
+
+  for (const key of Object.keys(clientSteps)) {
+    if (Object.hasOwn(module, key)) {
+      const fn = module[key];
+
+      Object.defineProperty(fn, 'name', {
+        value: `${infoKey}/${fn.name}`,
+      });
+
+      clientSteps[key].push(fn);
+    }
+  }
+}
+
+for (const [key, steps] of Object.entries(clientSteps)) {
+  for (const step of steps) {
+    try {
+      step();
+    } catch (error) {
+      // TODO: Be smarter about not running later steps for the same module!
+      // Or maybe not, since an error is liable to cause explosions anyway.
+      console.error(`During ${key}, failed to run ${step.name}`);
+      console.error(error);
+    }
+  }
+}
diff --git a/src/static/js/client/intrapage-dot-switcher.js b/src/static/js/client/intrapage-dot-switcher.js
new file mode 100644
index 00000000..d06bc5a6
--- /dev/null
+++ b/src/static/js/client/intrapage-dot-switcher.js
@@ -0,0 +1,82 @@
+/* eslint-env browser */
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+import {cssProp} from '../client-util.js';
+
+export const info = {
+  id: 'intrapageDotSwitcherInfo',
+
+  // Each is a two-level array, by switcher.
+  // This is an evil data structure.
+  switcherSpans: null,
+  switcherLinks: null,
+  switcherTargets: null,
+};
+
+export function getPageReferences() {
+  const switchers =
+    Array.from(document.querySelectorAll('.dot-switcher.intrapage'));
+
+  info.switcherSpans =
+    switchers
+      .map(switcher => switcher.querySelectorAll(':scope > span'))
+      .map(spans => Array.from(spans));
+
+  info.switcherLinks =
+    info.switcherSpans
+      .map(spans => spans
+        .map(span => span.querySelector(':scope > a')));
+
+  info.switcherTargets =
+    info.switcherLinks
+      .map(links => links
+        .map(link => {
+          const targetID = link.getAttribute('data-target-id');
+          const target = document.getElementById(targetID);
+          if (target) {
+            return target;
+          } else {
+            console.warn(
+              `An intrapage dot switcher option is targetting an ID that doesn't exist, #${targetID}`,
+              link);
+            link.setAttribute('inert', '');
+            return null;
+          }
+        }));
+}
+
+export function addPageListeners() {
+  for (const {links, spans, targets} of stitchArrays({
+    spans: info.switcherSpans,
+    links: info.switcherLinks,
+    targets: info.switcherTargets,
+  })) {
+    for (const [index, {span, link, target}] of stitchArrays({
+      span: spans,
+      link: links,
+      target: targets,
+    }).entries()) {
+      const otherSpans =
+        [...spans.slice(0, index), ...spans.slice(index + 1)];
+
+      const otherTargets =
+        [...targets.slice(0, index), ...targets.slice(index + 1)];
+
+      link.addEventListener('click', domEvent => {
+        domEvent.preventDefault();
+
+        for (const otherSpan of otherSpans) {
+          otherSpan.classList.remove('current');
+        }
+
+        for (const otherTarget of otherTargets) {
+          cssProp(otherTarget, 'display', 'none');
+        }
+
+        span.classList.add('current');
+        cssProp(target, 'display', 'block');
+      });
+    }
+  }
+}
diff --git a/src/static/js/client/live-mouse-position.js b/src/static/js/client/live-mouse-position.js
new file mode 100644
index 00000000..36a28429
--- /dev/null
+++ b/src/static/js/client/live-mouse-position.js
@@ -0,0 +1,21 @@
+/* eslint-env browser */
+
+export const info = {
+  id: 'liveMousePositionInfo',
+
+  state: {
+    clientX: null,
+    clientY: null,
+  },
+};
+
+export function addPageListeners() {
+  const {state} = info;
+
+  document.body.addEventListener('mousemove', domEvent => {
+    Object.assign(state, {
+      clientX: domEvent.clientX,
+      clientY: domEvent.clientY,
+    });
+  });
+}
diff --git a/src/static/js/client/lyrics-switcher.js b/src/static/js/client/lyrics-switcher.js
new file mode 100644
index 00000000..b350ea50
--- /dev/null
+++ b/src/static/js/client/lyrics-switcher.js
@@ -0,0 +1,70 @@
+/* eslint-env browser */
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+import {cssProp} from '../client-util.js';
+
+export const info = {
+  id: 'lyricsSwitcherInfo',
+
+  entries: null,
+  switchLinks: null,
+  currentLinks: null,
+};
+
+export function getPageReferences() {
+  const content = document.getElementById('content');
+
+  if (!content) return;
+
+  const switcher = content.querySelector('.lyrics-switcher');
+
+  if (!switcher) return;
+
+  info.entries =
+    Array.from(content.querySelectorAll('.lyrics-entry'));
+
+  info.currentLinks =
+    Array.from(switcher.querySelectorAll('a.current'));
+
+  info.switchLinks =
+    Array.from(switcher.querySelectorAll('a:not(.current)'));
+}
+
+export function addPageListeners() {
+  if (!info.switchLinks) return;
+
+  for (const {switchLink, entry} of stitchArrays({
+    switchLink: info.switchLinks,
+    entry: info.entries,
+  })) {
+    switchLink.addEventListener('click', domEvent => {
+      domEvent.preventDefault();
+      showLyricsEntry(entry);
+    });
+  }
+}
+
+function showLyricsEntry(entry) {
+  const entryToShow = entry;
+
+  stitchArrays({
+    entry: info.entries,
+    currentLink: info.currentLinks,
+    switchLink: info.switchLinks,
+  }).forEach(({
+      entry,
+      currentLink,
+      switchLink,
+    }) => {
+      if (entry === entryToShow) {
+        cssProp(entry, 'display', null);
+        cssProp(currentLink, 'display', null);
+        cssProp(switchLink, 'display', 'none');
+      } else {
+        cssProp(entry, 'display', 'none');
+        cssProp(currentLink, 'display', 'none');
+        cssProp(switchLink, 'display', null);
+      }
+    });
+}
diff --git a/src/static/js/client/quick-description.js b/src/static/js/client/quick-description.js
new file mode 100644
index 00000000..cff82252
--- /dev/null
+++ b/src/static/js/client/quick-description.js
@@ -0,0 +1,62 @@
+/* eslint-env browser */
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'quickDescriptionInfo',
+
+  quickDescriptionContainers: null,
+
+  quickDescriptionsAreExpandable: null,
+
+  expandDescriptionLinks: null,
+  collapseDescriptionLinks: null,
+};
+
+export function getPageReferences() {
+  info.quickDescriptionContainers =
+    Array.from(document.querySelectorAll('#content .quick-description'));
+
+  info.quickDescriptionsAreExpandable =
+    info.quickDescriptionContainers
+      .map(container =>
+        container.querySelector('.quick-description-actions.when-expanded'));
+
+  info.expandDescriptionLinks =
+    info.quickDescriptionContainers
+      .map(container =>
+        container.querySelector('.quick-description-actions .expand-link'));
+
+  info.collapseDescriptionLinks =
+    info.quickDescriptionContainers
+      .map(container =>
+        container.querySelector('.quick-description-actions .collapse-link'));
+}
+
+export function addPageListeners() {
+  for (const {
+    isExpandable,
+    container,
+    expandLink,
+    collapseLink,
+  } of stitchArrays({
+    isExpandable: info.quickDescriptionsAreExpandable,
+    container: info.quickDescriptionContainers,
+    expandLink: info.expandDescriptionLinks,
+    collapseLink: info.collapseDescriptionLinks,
+  })) {
+    if (!isExpandable) continue;
+
+    expandLink.addEventListener('click', event => {
+      event.preventDefault();
+      container.classList.add('expanded');
+      container.classList.remove('collapsed');
+    });
+
+    collapseLink.addEventListener('click', event => {
+      event.preventDefault();
+      container.classList.add('collapsed');
+      container.classList.remove('expanded');
+    });
+  }
+}
diff --git a/src/static/js/client/scripted-link.js b/src/static/js/client/scripted-link.js
new file mode 100644
index 00000000..8b8d8a13
--- /dev/null
+++ b/src/static/js/client/scripted-link.js
@@ -0,0 +1,285 @@
+/* eslint-env browser */
+
+import {pick, stitchArrays} from '../../shared-util/sugar.js';
+
+import {
+  cssProp,
+  rebase,
+  openAlbum,
+  openArtist,
+  openTrack,
+} from '../client-util.js';
+
+export const info = {
+  id: 'scriptedLinkInfo',
+
+  randomLinks: null,
+  revealLinks: null,
+  revealContainers: null,
+
+  nextNavLink: null,
+  previousNavLink: null,
+  randomNavLink: null,
+
+  state: {
+    albumDirectories: null,
+    albumTrackDirectories: null,
+    artistDirectories: null,
+    artistNumContributions: null,
+  },
+};
+
+export function getPageReferences() {
+  info.randomLinks =
+    document.querySelectorAll('[data-random]');
+
+  info.revealLinks =
+    document.querySelectorAll('.reveal .image-outer-area > *');
+
+  info.revealContainers =
+    Array.from(info.revealLinks)
+      .map(link => link.closest('.reveal'));
+
+  info.nextNavLink =
+    document.getElementById('next-button');
+
+  info.previousNavLink =
+    document.getElementById('previous-button');
+
+  info.randomNavLink =
+    document.getElementById('random-button');
+}
+
+export function addPageListeners() {
+  addRandomLinkListeners();
+  addNavigationKeyPressListeners();
+  addRevealLinkClickListeners();
+}
+
+function addRandomLinkListeners() {
+  for (const a of info.randomLinks ?? []) {
+    a.addEventListener('click', domEvent => {
+      handleRandomLinkClicked(a, domEvent);
+    });
+  }
+}
+
+function handleRandomLinkClicked(a, domEvent) {
+  const href = determineRandomLinkHref(a);
+
+  if (!href) {
+    domEvent.preventDefault();
+    return;
+  }
+
+  setTimeout(() => {
+    a.href = '#'
+  });
+
+  a.href = href;
+}
+
+function determineRandomLinkHref(a) {
+  const {state} = info;
+
+  const trackDirectoriesFromAlbumDirectories = albumDirectories =>
+    albumDirectories
+      .map(directory => state.albumDirectories.indexOf(directory))
+      .map(index => state.albumTrackDirectories[index])
+      .reduce((acc, trackDirectories) => acc.concat(trackDirectories, []));
+
+  switch (a.dataset.random) {
+    case 'album': {
+      const {albumDirectories} = state;
+      if (!albumDirectories) return null;
+
+      return openAlbum(pick(albumDirectories));
+    }
+
+    case 'track': {
+      const {albumDirectories} = state;
+      if (!albumDirectories) return null;
+
+      const trackDirectories =
+        trackDirectoriesFromAlbumDirectories(
+          albumDirectories);
+
+      return openTrack(pick(trackDirectories));
+    }
+
+    case 'album-in-group-dl': {
+      const albumLinks =
+        Array.from(a
+          .closest('dt')
+          .nextElementSibling
+          .querySelectorAll('li a'))
+
+      const listAlbumDirectories =
+        albumLinks
+          .map(a => cssProp(a, '--album-directory'));
+
+      return openAlbum(pick(listAlbumDirectories));
+    }
+
+    case 'track-in-group-dl': {
+      const {albumDirectories} = state;
+      if (!albumDirectories) return null;
+
+      const albumLinks =
+        Array.from(a
+          .closest('dt')
+          .nextElementSibling
+          .querySelectorAll('li a'))
+
+      const listAlbumDirectories =
+        albumLinks
+          .map(a => cssProp(a, '--album-directory'));
+
+      const trackDirectories =
+        trackDirectoriesFromAlbumDirectories(
+          listAlbumDirectories);
+
+      return openTrack(pick(trackDirectories));
+    }
+
+    case 'track-in-sidebar': {
+      // Note that the container for track links may be <ol> or <ul>, and
+      // they can't be identified by href, since links from one track to
+      // another don't include "track" in the href.
+      const trackLinks =
+        Array.from(document
+          .querySelector('.track-list-sidebar-box')
+          .querySelectorAll('li a'));
+
+      return pick(trackLinks).href;
+    }
+
+    case 'track-in-album': {
+      const {albumDirectories, albumTrackDirectories} = state;
+      if (!albumDirectories || !albumTrackDirectories) return null;
+
+      const albumDirectory = cssProp(a, '--album-directory');
+      const albumIndex = albumDirectories.indexOf(albumDirectory);
+      const trackDirectories = albumTrackDirectories[albumIndex];
+
+      return openTrack(pick(trackDirectories));
+    }
+
+    case 'artist': {
+      const {artistDirectories} = state;
+      if (!artistDirectories) return null;
+
+      return openArtist(pick(artistDirectories));
+    }
+
+    case 'artist-more-than-one-contrib': {
+      const {artistDirectories, artistNumContributions} = state;
+      if (!artistDirectories || !artistNumContributions) return null;
+
+      const filteredArtistDirectories =
+        artistDirectories
+          .filter((_artist, index) => artistNumContributions[index] > 1);
+
+      return openArtist(pick(filteredArtistDirectories));
+    }
+  }
+}
+
+export function mutatePageContent() {
+  mutateNavigationLinkContent();
+}
+
+function mutateNavigationLinkContent() {
+  const prependTitle = (el, prepend) => {
+    if (!el) return;
+    if (!el.hasAttribute('href')) return;
+
+    el?.setAttribute(
+      'title',
+      (el.hasAttribute('title')
+        ? prepend + ' ' + el.getAttribute('title')
+        : prepend));
+  };
+
+  prependTitle(info.nextNavLink, '(Shift+N)');
+  prependTitle(info.previousNavLink, '(Shift+P)');
+  prependTitle(info.randomNavLink, '(Shift+R)');
+}
+
+function addNavigationKeyPressListeners() {
+  document.addEventListener('keypress', (event) => {
+    const {tagName} = document.activeElement ?? {};
+    if (tagName === 'INPUT' || tagName === 'TEXTAREA') {
+      return;
+    }
+
+    if (event.shiftKey) {
+      if (event.charCode === 'N'.charCodeAt(0)) {
+        info.nextNavLink?.click();
+      } else if (event.charCode === 'P'.charCodeAt(0)) {
+        info.previousNavLink?.click();
+      } else if (event.charCode === 'R'.charCodeAt(0)) {
+        info.randomNavLink?.click();
+      }
+    }
+  });
+}
+
+function addRevealLinkClickListeners() {
+  for (const {revealLink, revealContainer} of stitchArrays({
+    revealLink: Array.from(info.revealLinks ?? []),
+    revealContainer: Array.from(info.revealContainers ?? []),
+  })) {
+    revealLink.addEventListener('click', (event) => {
+      handleRevealLinkClicked(event, revealLink, revealContainer);
+    });
+  }
+}
+
+function handleRevealLinkClicked(domEvent, _revealLink, revealContainer) {
+  if (revealContainer.classList.contains('revealed')) {
+    return;
+  }
+
+  domEvent.preventDefault();
+  revealContainer.classList.add('revealed');
+  revealContainer.dispatchEvent(new CustomEvent('hsmusic-reveal'));
+}
+
+if (
+  document.documentElement.dataset.urlKey === 'localized.listing' &&
+  document.documentElement.dataset.urlValue0 === 'random'
+) {
+  const dataLoadingLine = document.getElementById('data-loading-line');
+  const dataLoadedLine = document.getElementById('data-loaded-line');
+  const dataErrorLine = document.getElementById('data-error-line');
+
+  dataLoadingLine.style.display = 'block';
+
+  fetch(rebase('random-link-data.json', 'rebaseShared'))
+    .then(data => data.json())
+    .then(data => {
+      const {state} = info;
+
+      Object.assign(state, {
+        albumDirectories: data.albumDirectories,
+        albumTrackDirectories: data.albumTrackDirectories,
+        artistDirectories: data.artistDirectories,
+        artistNumContributions: data.artistNumContributions,
+      });
+
+      dataLoadingLine.style.display = 'none';
+      dataLoadedLine.style.display = 'block';
+    }, () => {
+      dataLoadingLine.style.display = 'none';
+      dataErrorLine.style.display = 'block';
+    })
+    .then(() => {
+      for (const a of info.randomLinks) {
+        const href = determineRandomLinkHref(a);
+        if (!href) {
+          a.removeAttribute('href');
+        }
+      }
+    });
+}
diff --git a/src/static/js/client/sidebar-search.js b/src/static/js/client/sidebar-search.js
new file mode 100644
index 00000000..fb902636
--- /dev/null
+++ b/src/static/js/client/sidebar-search.js
@@ -0,0 +1,1147 @@
+/* eslint-env browser */
+
+import {getColors} from '../../shared-util/colors.js';
+import {accumulateSum, empty} from '../../shared-util/sugar.js';
+
+import {
+  cssProp,
+  openAlbum,
+  openArtist,
+  openArtTag,
+  openFlash,
+  openGroup,
+  openTrack,
+  rebase,
+  templateContent,
+} from '../client-util.js';
+
+import {getLatestDraggedLink} from './dragged-link.js';
+
+import {
+  info as wikiSearchInfo,
+  getSearchWorkerDownloadContext,
+  searchAll,
+} from './wiki-search.js';
+
+export const info = {
+  id: 'sidebarSearchInfo',
+
+  pageContainer: null,
+
+  searchSidebarColumn: null,
+  searchBox: null,
+  searchLabel: null,
+  searchInput: null,
+
+  progressRule: null,
+  progressContainer: null,
+  progressLabel: null,
+  progressBar: null,
+
+  failedRule: null,
+  failedContainer: null,
+
+  resultsRule: null,
+  resultsContainer: null,
+  results: null,
+
+  endSearchRule: null,
+  endSearchLine: null,
+  endSearchLink: null,
+
+  standbyInputPlaceholder: null,
+
+  preparingString: null,
+  loadingDataString: null,
+  searchingString: null,
+  failedString: null,
+
+  noResultsString: null,
+  currentResultString: null,
+  endSearchString: null,
+
+  albumResultKindString: null,
+  artistResultKindString: null,
+  groupResultKindString: null,
+  tagResultKindString: null,
+
+  state: {
+    sidebarColumnShownForSearch: null,
+
+    tidiedSidebar: null,
+    collapsedDetailsForTidiness: null,
+
+    recallingRecentSearch: null,
+    recallingRecentSearchFromMouse: null,
+
+    currentValue: null,
+
+    workerStatus: null,
+    searchStage: null,
+
+    stoppedTypingTimeout: null,
+    stoppedScrollingTimeout: null,
+    focusFirstResultTimeout: null,
+    dismissChangeEventTimeout: null,
+
+    indexDownloadStatuses: Object.create(null),
+  },
+
+  session: {
+    activeQuery: {
+      type: 'string',
+    },
+
+    activeQueryResults: {
+      type: 'json',
+      maxLength: settings => settings.maxActiveResultsStorage,
+    },
+
+    repeatQueryOnReload: {
+      type: 'boolean',
+      default: false,
+    },
+
+    resultsScrollOffset: {
+      type: 'number',
+    },
+  },
+
+  settings: {
+    stoppedTypingDelay: 800,
+    stoppedScrollingDelay: 200,
+
+    pressDownToFocusFirstResultLatency: 500,
+    dismissChangeEventAfterFocusingFirstResultLatency: 50,
+
+    maxActiveResultsStorage: 100000,
+  },
+};
+
+export function getPageReferences() {
+  info.pageContainer =
+    document.getElementById('page-container');
+
+  info.searchBox =
+    document.querySelector('.wiki-search-sidebar-box');
+
+  if (!info.searchBox) {
+    return;
+  }
+
+  info.searchLabel =
+    info.searchBox.querySelector('.wiki-search-label');
+
+  info.searchInput =
+    info.searchBox.querySelector('.wiki-search-input');
+
+  info.searchSidebarColumn =
+    info.searchBox.closest('.sidebar-column');
+
+  info.standbyInputPlaceholder =
+    info.searchInput.placeholder;
+
+  const findString = classPart =>
+    info.searchBox.querySelector(`.wiki-search-${classPart}-string`);
+
+  info.preparingString =
+    findString('preparing');
+
+  info.loadingDataString =
+    findString('loading-data');
+
+  info.searchingString =
+    findString('searching');
+
+  info.failedString =
+    findString('failed');
+
+  info.noResultsString =
+    findString('no-results');
+
+  info.currentResultString =
+    findString('current-result');
+
+  info.endSearchString =
+    findString('end-search');
+
+  info.albumResultKindString =
+    findString('album-result-kind');
+
+  info.artistResultKindString =
+    findString('artist-result-kind');
+
+  info.groupResultKindString =
+    findString('group-result-kind');
+
+  info.tagResultKindString =
+    findString('tag-result-kind');
+}
+
+export function addInternalListeners() {
+  if (!info.searchBox) return;
+
+  wikiSearchInfo.event.whenWorkerAlive.push(
+    trackSidebarSearchWorkerAlive,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenWorkerReady.push(
+    trackSidebarSearchWorkerReady,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenWorkerFailsToInitialize.push(
+    trackSidebarSearchWorkerFailsToInitialize,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenWorkerHasRuntimeError.push(
+    trackSidebarSearchWorkerHasRuntimeError,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenDownloadsBegin.push(
+    trackSidebarSearchDownloadsBegin,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenDownloadProgresses.push(
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenDownloadEnds.push(
+    trackSidebarSearchDownloadEnds,
+    updateSidebarSearchStatus);
+}
+
+export function mutatePageContent() {
+  if (!info.searchBox) return;
+
+  // Progress section
+
+  info.progressRule =
+    document.createElement('hr');
+
+  info.progressContainer =
+    document.createElement('div');
+
+  info.progressContainer.classList.add('wiki-search-progress-container');
+
+  cssProp(info.progressRule, 'display', 'none');
+  cssProp(info.progressContainer, 'display', 'none');
+
+  info.progressLabel =
+    document.createElement('label');
+
+  info.progressLabel.classList.add('wiki-search-progress-label');
+  info.progressLabel.htmlFor = 'wiki-search-progress-bar';
+
+  info.progressBar =
+    document.createElement('progress');
+
+  info.progressBar.classList.add('wiki-search-progress-bar');
+  info.progressBar.id = 'wiki-search-progress-bar';
+
+  info.progressContainer.appendChild(info.progressLabel);
+  info.progressContainer.appendChild(info.progressBar);
+
+  info.searchBox.appendChild(info.progressRule);
+  info.searchBox.appendChild(info.progressContainer);
+
+  // Search failed section
+
+  info.failedRule =
+    document.createElement('hr');
+
+  info.failedContainer =
+    document.createElement('div');
+
+  info.failedContainer.classList.add('wiki-search-failed-container');
+
+  {
+    const p = document.createElement('p');
+    p.appendChild(templateContent(info.failedString));
+    info.failedContainer.appendChild(p);
+  }
+
+  cssProp(info.failedRule, 'display', 'none');
+  cssProp(info.failedContainer, 'display', 'none');
+
+  info.searchBox.appendChild(info.failedRule);
+  info.searchBox.appendChild(info.failedContainer);
+
+  // Results section
+
+  info.resultsRule =
+    document.createElement('hr');
+
+  info.resultsContainer =
+    document.createElement('div');
+
+  info.resultsContainer.classList.add('wiki-search-results-container');
+
+  cssProp(info.resultsRule, 'display', 'none');
+  cssProp(info.resultsContainer, 'display', 'none');
+
+  info.results =
+    document.createElement('div');
+
+  info.results.classList.add('wiki-search-results');
+
+  info.resultsContainer.appendChild(info.results);
+
+  info.searchBox.appendChild(info.resultsRule);
+  info.searchBox.appendChild(info.resultsContainer);
+
+  // End search section
+
+  info.endSearchRule =
+    document.createElement('hr');
+
+  info.endSearchLine =
+    document.createElement('p');
+
+  info.endSearchLink =
+    document.createElement('a');
+
+  {
+    const p = info.endSearchLine;
+    const a = info.endSearchLink;
+    p.classList.add('wiki-search-end-search-line');
+    a.setAttribute('href', '#');
+    a.appendChild(templateContent(info.endSearchString));
+    p.appendChild(a);
+  }
+
+  cssProp(info.endSearchRule, 'display', 'none');
+  cssProp(info.endSearchLine, 'display', 'none');
+
+  info.searchBox.appendChild(info.endSearchRule);
+  info.searchBox.appendChild(info.endSearchLine);
+}
+
+export function addPageListeners() {
+  if (!info.searchInput) return;
+
+  info.searchInput.addEventListener('mousedown', _domEvent => {
+    const {state} = info;
+
+    if (state.recallingRecentSearch) {
+      state.recallingRecentSearchFromMouse = true;
+    }
+  });
+
+  info.searchInput.addEventListener('focus', _domEvent => {
+    const {session, state} = info;
+
+    if (state.recallingRecentSearch) {
+      info.searchInput.value = session.activeQuery;
+      info.searchInput.placeholder = info.standbyInputPlaceholder;
+      showSidebarSearchResults(session.activeQueryResults);
+      state.recallingRecentSearch = false;
+    }
+  });
+
+  info.searchLabel.addEventListener('click', domEvent => {
+    const {state} = info;
+
+    if (state.recallingRecentSearchFromMouse) {
+      if (info.searchInput.selectionStart === info.searchInput.selectionEnd) {
+        info.searchInput.select();
+      }
+
+      state.recallingRecentSearchFromMouse = false;
+      return;
+    }
+
+    const inputRect = info.searchInput.getBoundingClientRect();
+    if (domEvent.clientX < inputRect.left - 3) {
+      info.searchInput.select();
+    }
+  });
+
+  info.searchInput.addEventListener('change', _domEvent => {
+    const {state} = info;
+
+    if (state.dismissChangeEventTimeout) {
+      state.dismissChangeEventTimeout = null;
+      clearTimeout(state.dismissChangeEventTimeout);
+      return;
+    }
+
+    activateSidebarSearch(info.searchInput.value);
+  });
+
+  info.searchInput.addEventListener('input', _domEvent => {
+    const {settings, state} = info;
+
+    if (!info.searchInput.value) {
+      clearSidebarSearch();
+      return;
+    }
+
+    if (state.stoppedTypingTimeout) {
+      clearTimeout(state.stoppedTypingTimeout);
+    }
+
+    state.stoppedTypingTimeout =
+      setTimeout(() => {
+        state.stoppedTypingTimeout = null;
+        activateSidebarSearch(info.searchInput.value);
+      }, settings.stoppedTypingDelay);
+
+    if (state.focusFirstResultTimeout) {
+      clearTimeout(state.focusFirstResultTimeout);
+      state.focusFirstResultTimeout = null;
+    }
+  });
+
+  info.searchInput.addEventListener('drop', handleDroppedIntoSearchInput);
+
+  info.searchInput.addEventListener('keydown', domEvent => {
+    const {settings, state} = info;
+
+    if (domEvent.key === 'ArrowUp' || domEvent.key === 'ArrowDown') {
+      domEvent.preventDefault();
+    }
+
+    if (domEvent.key === 'ArrowDown') {
+      if (state.stoppedTypingTimeout) {
+        clearTimeout(state.stoppedTypingTimeout);
+        state.stoppedTypingTimeout = null;
+
+        if (state.focusFirstResultTimeout) {
+          clearTimeout(state.focusFirstResultTimeout);
+        }
+
+        state.focusFirstResultTimeout =
+          setTimeout(() => {
+            state.focusFirstResultTimeout = null;
+          }, settings.pressDownToFocusFirstResultLatency);
+
+        activateSidebarSearch(info.searchInput.value);
+      } else {
+        focusFirstSidebarSearchResult();
+      }
+    }
+  });
+
+  document.addEventListener('selectionchange', _domEvent => {
+    const {state} = info;
+
+    if (state.focusFirstResultTimeout) {
+      clearTimeout(state.focusFirstResultTimeout);
+      state.focusFirstResultTimeout = null;
+    }
+  });
+
+  info.endSearchLink.addEventListener('click', domEvent => {
+    domEvent.preventDefault();
+    clearSidebarSearch();
+    possiblyHideSearchSidebarColumn();
+    restoreSidebarSearchColumn();
+  });
+
+  info.resultsContainer.addEventListener('scroll', () => {
+    const {settings, state} = info;
+
+    if (state.stoppedScrollingTimeout) {
+      clearTimeout(state.stoppedScrollingTimeout);
+    }
+
+    state.stoppedScrollingTimeout =
+      setTimeout(() => {
+        saveSidebarSearchResultsScrollOffset();
+      }, settings.stoppedScrollingDelay);
+  });
+}
+
+export function initializeState() {
+  const {session} = info;
+
+  if (!info.searchInput) return;
+
+  if (session.activeQuery) {
+    if (session.repeatQueryOnReload) {
+      info.searchInput.value = session.activeQuery;
+      activateSidebarSearch(session.activeQuery);
+    } else if (session.activeQueryResults) {
+      considerRecallingRecentSidebarSearch();
+    }
+  }
+}
+
+function trackSidebarSearchWorkerAlive() {
+  const {state} = info;
+
+  state.workerStatus = 'alive';
+}
+
+function trackSidebarSearchWorkerReady() {
+  const {state} = info;
+
+  state.workerStatus = 'ready';
+  state.searchStage = 'searching';
+}
+
+function trackSidebarSearchWorkerFailsToInitialize() {
+  const {state} = info;
+
+  state.workerStatus = 'failed';
+  state.searchStage = 'failed';
+}
+
+function trackSidebarSearchWorkerHasRuntimeError() {
+  const {state} = info;
+
+  state.workerStatus = 'failed';
+  state.searchStage = 'failed';
+}
+
+function trackSidebarSearchDownloadsBegin(event) {
+  const {state} = info;
+
+  if (event.context === 'search-indexes') {
+    for (const key of event.keys) {
+      state.indexDownloadStatuses[key] = 'active';
+    }
+  }
+}
+
+function trackSidebarSearchDownloadEnds(event) {
+  const {state} = info;
+
+  if (event.context === 'search-indexes') {
+    state.indexDownloadStatuses[event.key] = 'complete';
+
+    const statuses = Object.values(state.indexDownloadStatuses);
+    if (statuses.every(status => status === 'complete')) {
+      for (const key of Object.keys(state.indexDownloadStatuses)) {
+        delete state.indexDownloadStatuses[key];
+      }
+    }
+  }
+}
+
+async function activateSidebarSearch(query) {
+  const {session, state} = info;
+
+  if (!query) {
+    return;
+  }
+
+  if (state.stoppedTypingTimeout) {
+    clearTimeout(state.stoppedTypingTimeout);
+    state.stoppedTypingTimeout = null;
+  }
+
+  state.searchStage =
+    (state.workerStatus === 'ready'
+      ? 'searching'
+      : 'preparing');
+  updateSidebarSearchStatus();
+
+  let results;
+  try {
+    results = await searchAll(query, {enrich: true});
+  } catch (error) {
+    console.error(`There was an error performing a sidebar search:`);
+    console.error(error);
+    showSidebarSearchFailed();
+    return;
+  }
+
+  state.searchStage = 'complete';
+  updateSidebarSearchStatus();
+
+  session.activeQuery = query;
+  session.activeQueryResults = results;
+  session.resultsScrollOffset = 0;
+
+  showSidebarSearchResults(results);
+
+  if (state.focusFirstResultTimeout) {
+    clearTimeout(state.focusFirstResultTimeout);
+    state.focusFirstResultTimeout = null;
+    focusFirstSidebarSearchResult();
+  }
+}
+
+function clearSidebarSearch() {
+  const {session, state} = info;
+
+  if (state.stoppedTypingTimeout) {
+    clearTimeout(state.stoppedTypingTimeout);
+    state.stoppedTypingTimeout = null;
+  }
+
+  info.searchBox.classList.remove('showing-results');
+  info.searchSidebarColumn.classList.remove('search-showing-results');
+
+  info.searchInput.value = '';
+
+  state.searchStage = null;
+
+  session.activeQuery = null;
+  session.activeQueryResults = null;
+  session.resultsScrollOffset = null;
+
+  hideSidebarSearchResults();
+}
+
+function updateSidebarSearchStatus() {
+  const {state} = info;
+
+  if (state.searchStage === 'failed') {
+    hideSidebarSearchResults();
+    showSidebarSearchFailed();
+
+    return;
+  }
+
+  const searchIndexDownloads =
+    getSearchWorkerDownloadContext('search-indexes');
+
+  const downloadProgressValues =
+    Object.values(searchIndexDownloads ?? {});
+
+  if (downloadProgressValues.some(v => v < 1.00)) {
+    const total = Object.keys(state.indexDownloadStatuses).length;
+    const sum = accumulateSum(downloadProgressValues);
+    showSidebarSearchProgress(
+      sum / total,
+      templateContent(info.loadingDataString));
+
+    return;
+  }
+
+  if (state.searchStage === 'preparing') {
+    showSidebarSearchProgress(
+      null,
+      templateContent(info.preparingString));
+
+    return;
+  }
+
+  if (state.searchStage === 'searching') {
+    showSidebarSearchProgress(
+      null,
+      templateContent(info.searchingString));
+
+    return;
+  }
+
+  hideSidebarSearchProgress();
+}
+
+function showSidebarSearchProgress(progress, label) {
+  cssProp(info.progressRule, 'display', null);
+  cssProp(info.progressContainer, 'display', null);
+
+  if (progress === null) {
+    info.progressBar.removeAttribute('value');
+  } else {
+    info.progressBar.value = progress;
+  }
+
+  while (info.progressLabel.firstChild) {
+    info.progressLabel.firstChild.remove();
+  }
+
+  info.progressLabel.appendChild(label);
+}
+
+function hideSidebarSearchProgress() {
+  cssProp(info.progressRule, 'display', 'none');
+  cssProp(info.progressContainer, 'display', 'none');
+}
+
+function showSidebarSearchFailed() {
+  const {state} = info;
+
+  hideSidebarSearchProgress();
+  hideSidebarSearchResults();
+
+  cssProp(info.failedRule, 'display', null);
+  cssProp(info.failedContainer, 'display', null);
+
+  info.searchLabel.classList.add('disabled');
+  info.searchInput.disabled = true;
+
+  if (state.stoppedTypingTimeout) {
+    clearTimeout(state.stoppedTypingTimeout);
+    state.stoppedTypingTimeout = null;
+  }
+}
+
+function showSidebarSearchResults(results) {
+  console.debug(`Showing search results:`, results);
+
+  showSearchSidebarColumn();
+
+  const flatResults =
+    Object.entries(results)
+      .filter(([index]) => index === 'generic')
+      .flatMap(([index, results]) => results
+        .flatMap(({doc, id}) => ({
+          index,
+          reference: id ?? null,
+          referenceType: (id ? id.split(':')[0] : null),
+          directory: (id ? id.split(':')[1] : null),
+          data: doc,
+        })));
+
+  info.searchBox.classList.add('showing-results');
+  info.searchSidebarColumn.classList.add('search-showing-results');
+
+  while (info.results.firstChild) {
+    info.results.firstChild.remove();
+  }
+
+  cssProp(info.resultsRule, 'display', 'block');
+  cssProp(info.resultsContainer, 'display', 'block');
+
+  if (empty(flatResults)) {
+    const p = document.createElement('p');
+    p.classList.add('wiki-search-no-results');
+    p.appendChild(templateContent(info.noResultsString));
+    info.results.appendChild(p);
+  }
+
+  for (const result of flatResults) {
+    const el = generateSidebarSearchResult(result);
+    if (!el) continue;
+
+    info.results.appendChild(el);
+  }
+
+  if (!empty(flatResults)) {
+    cssProp(info.endSearchRule, 'display', 'block');
+    cssProp(info.endSearchLine, 'display', 'block');
+
+    tidySidebarSearchColumn();
+  }
+
+  restoreSidebarSearchResultsScrollOffset();
+}
+
+function generateSidebarSearchResult(result) {
+  const preparedSlots = {
+    color:
+      result.data.color ?? null,
+
+    name:
+      result.data.name ?? result.data.primaryName ?? null,
+
+    imageSource:
+      getSearchResultImageSource(result),
+  };
+
+  switch (result.referenceType) {
+    case 'album': {
+      preparedSlots.href =
+        openAlbum(result.directory);
+
+      preparedSlots.kindString =
+        info.albumResultKindString;
+
+      break;
+    }
+
+    case 'artist': {
+      preparedSlots.href =
+        openArtist(result.directory);
+
+      preparedSlots.kindString =
+        info.artistResultKindString;
+
+      break;
+    }
+
+    case 'group': {
+      preparedSlots.href =
+        openGroup(result.directory);
+
+      preparedSlots.kindString =
+        info.groupResultKindString;
+
+      break;
+    }
+
+    case 'flash': {
+      preparedSlots.href =
+        openFlash(result.directory);
+
+      break;
+    }
+
+    case 'tag': {
+      preparedSlots.href =
+        openArtTag(result.directory);
+
+      preparedSlots.kindString =
+        info.tagResultKindString;
+
+      break;
+    }
+
+    case 'track': {
+      preparedSlots.href =
+        openTrack(result.directory);
+
+      break;
+    }
+
+    default:
+      return null;
+  }
+
+  return generateSidebarSearchResultTemplate(preparedSlots);
+}
+
+function getSearchResultImageSource(result) {
+  const {artwork} = result.data;
+  if (!artwork) return null;
+
+  return (
+    rebase(
+      artwork.replace('<>', result.directory),
+      'rebaseThumb'));
+}
+
+function generateSidebarSearchResultTemplate(slots) {
+  const link = document.createElement('a');
+  link.classList.add('wiki-search-result');
+
+  if (slots.href) {
+    link.setAttribute('href', slots.href);
+  }
+
+  if (slots.color) {
+    cssProp(link, '--primary-color', slots.color);
+
+    try {
+      const colors =
+        getColors(slots.color, {
+          chroma: window.chroma,
+        });
+      cssProp(link, '--light-ghost-color', colors.lightGhost);
+      cssProp(link, '--deep-color', colors.deep);
+    } catch (error) {
+      console.warn(error);
+    }
+  }
+
+  const imgContainer = document.createElement('span');
+  imgContainer.classList.add('wiki-search-result-image-container');
+
+  if (slots.imageSource) {
+    const img = document.createElement('img');
+    img.classList.add('wiki-search-result-image');
+    img.setAttribute('src', slots.imageSource);
+    imgContainer.appendChild(img);
+    if (slots.imageSource.endsWith('.mini.jpg')) {
+      img.classList.add('has-warning');
+    }
+  } else {
+    const placeholder = document.createElement('span');
+    placeholder.classList.add('wiki-search-result-image-placeholder');
+    imgContainer.appendChild(placeholder);
+  }
+
+  link.appendChild(imgContainer);
+
+  const text = document.createElement('span');
+  text.classList.add('wiki-search-result-text-area');
+
+  if (slots.name) {
+    const span = document.createElement('span');
+    span.classList.add('wiki-search-result-name');
+    span.appendChild(document.createTextNode(slots.name));
+    text.appendChild(span);
+  }
+
+  let accentSpan = null;
+
+  if (link.href) {
+    const here = location.href.replace(/\/$/, '');
+    const there = link.href.replace(/\/$/, '');
+    if (here === there) {
+      link.classList.add('current-result');
+      accentSpan = document.createElement('span');
+      accentSpan.classList.add('wiki-search-current-result-text');
+      accentSpan.appendChild(templateContent(info.currentResultString));
+    }
+  }
+
+  if (!accentSpan && slots.kindString) {
+    accentSpan = document.createElement('span');
+    accentSpan.classList.add('wiki-search-result-kind');
+    accentSpan.appendChild(templateContent(slots.kindString));
+  }
+
+  if (accentSpan) {
+    text.appendChild(document.createTextNode(' '));
+    text.appendChild(accentSpan);
+  }
+
+  link.appendChild(text);
+
+  link.addEventListener('click', () => {
+    saveSidebarSearchResultsScrollOffset();
+  });
+
+  link.addEventListener('keydown', domEvent => {
+    if (domEvent.key === 'ArrowDown') {
+      const elem = link.nextElementSibling;
+      if (elem) {
+        domEvent.preventDefault();
+        elem.focus({focusVisible: true});
+      }
+    } else if (domEvent.key === 'ArrowUp') {
+      domEvent.preventDefault();
+      const elem = link.previousElementSibling;
+      if (elem) {
+        elem.focus({focusVisible: true});
+      } else {
+        info.searchInput.focus();
+      }
+    }
+  });
+
+  return link;
+}
+
+function hideSidebarSearchResults() {
+  cssProp(info.resultsRule, 'display', 'none');
+  cssProp(info.resultsContainer, 'display', 'none');
+
+  while (info.results.firstChild) {
+    info.results.firstChild.remove();
+  }
+
+  cssProp(info.endSearchRule, 'display', 'none');
+  cssProp(info.endSearchLine, 'display', 'none');
+}
+
+function focusFirstSidebarSearchResult() {
+  const {settings, state} = info;
+
+  const elem = info.results.firstChild;
+  if (!elem?.classList.contains('wiki-search-result')) {
+    return;
+  }
+
+  if (state.dismissChangeEventTimeout) {
+    clearTimeout(state.dismissChangeEventTimeout);
+  }
+
+  state.dismissChangeEventTimeout =
+    setTimeout(() => {
+      state.dismissChangeEventTimeout = null;
+    }, settings.dismissChangeEventAfterFocusingFirstResultLatency);
+
+  elem.focus({focusVisible: true});
+}
+
+function saveSidebarSearchResultsScrollOffset() {
+  const {session} = info;
+
+  session.resultsScrollOffset = info.resultsContainer.scrollTop;
+}
+
+function restoreSidebarSearchResultsScrollOffset() {
+  const {session} = info;
+
+  if (session.resultsScrollOffset) {
+    info.resultsContainer.scrollTop = session.resultsScrollOffset;
+  }
+}
+
+function showSearchSidebarColumn() {
+  const {state} = info;
+
+  if (!info.searchSidebarColumn) {
+    return;
+  }
+
+  if (!info.searchSidebarColumn.classList.contains('initially-hidden')) {
+    return;
+  }
+
+  info.searchSidebarColumn.classList.remove('initially-hidden');
+
+  if (info.searchSidebarColumn.id === 'sidebar-left') {
+    info.pageContainer.classList.add('showing-sidebar-left');
+  } else if (info.searchSidebarColumn.id === 'sidebar-right') {
+    info.pageContainer.classList.add('showing-sidebar-right');
+  }
+
+  state.sidebarColumnShownForSearch = true;
+}
+
+function possiblyHideSearchSidebarColumn() {
+  const {state} = info;
+
+  if (!info.searchSidebarColumn) {
+    return;
+  }
+
+  if (!state.sidebarColumnShownForSearch) {
+    return;
+  }
+
+  info.searchSidebarColumn.classList.add('initially-hidden');
+
+  if (info.searchSidebarColumn.id === 'sidebar-left') {
+    info.pageContainer.classList.remove('showing-sidebar-left');
+  } else if (info.searchSidebarColumn.id === 'sidebar-right') {
+    info.pageContainer.classList.remove('showing-sidebar-right');
+  }
+
+  state.sidebarColumnShownForSearch = null;
+}
+
+// This should be called after results are shown, since it checks the
+// elements added to understand the current search state.
+function tidySidebarSearchColumn() {
+  const {state} = info;
+
+  // Don't *re-tidy* the sidebar if we've already tidied it to display
+  // some results. This flag will get cleared if the search is dismissed
+  // altogether (and the pre-tidy state is restored).
+  if (state.tidiedSidebar) {
+    return;
+  }
+
+  const here = location.href.replace(/\/$/, '');
+  const currentPageIsResult =
+    Array.from(info.results.querySelectorAll('a'))
+      .some(link => {
+        const there = link.href.replace(/\/$/, '');
+        return here === there;
+      });
+
+  // Don't tidy the sidebar if you've navigated to some other page than
+  // what's in the current result list.
+  if (!currentPageIsResult) {
+    return;
+  }
+
+  state.tidiedSidebar = true;
+  state.collapsedDetailsForTidiness = [];
+
+  for (const box of info.searchSidebarColumn.querySelectorAll('.sidebar')) {
+    if (box === info.searchBox) {
+      continue;
+    }
+
+    for (const details of box.getElementsByTagName('details')) {
+      if (details.open) {
+        details.removeAttribute('open');
+        state.collapsedDetailsForTidiness.push(details);
+      }
+    }
+  }
+}
+
+function restoreSidebarSearchColumn() {
+  const {state} = info;
+
+  if (!state.tidiedSidebar) {
+    return;
+  }
+
+  for (const details of state.collapsedDetailsForTidiness) {
+    details.setAttribute('open', '');
+  }
+
+  state.collapsedDetailsForTidiness = [];
+  state.tidiedSidebar = null;
+
+  info.searchInput.placeholder = info.standbyInputPlaceholder;
+}
+
+function considerRecallingRecentSidebarSearch() {
+  const {session, state} = info;
+
+  if (document.documentElement.dataset.urlKey === 'localized.home') {
+    return forgetRecentSidebarSearch();
+  }
+
+  info.searchInput.placeholder = session.activeQuery;
+  state.recallingRecentSearch = true;
+}
+
+function forgetRecentSidebarSearch() {
+  const {session} = info;
+
+  session.activeQuery = null;
+  session.activeQueryResults = null;
+}
+
+async function handleDroppedIntoSearchInput(domEvent) {
+  const itemByType = type =>
+    Array.from(domEvent.dataTransfer.items)
+      .find(item => item.type === type);
+
+  const textItem = itemByType('text/plain');
+
+  if (!textItem) return;
+
+  domEvent.preventDefault();
+
+  const getAssTring = item =>
+    new Promise(res => item.getAsString(res))
+      .then(string => string.trim());
+
+  const timer = Date.now();
+
+  let droppedText =
+    await getAssTring(textItem);
+
+  if (Date.now() - timer > 500) return;
+  if (!droppedText) return;
+
+  let droppedURL;
+  try {
+    droppedURL = new URL(droppedText);
+  } catch (error) {
+    droppedURL = null;
+  }
+
+  if (droppedURL) matchLink: {
+    const isDroppedURL = a =>
+      a.toString() === droppedURL.toString();
+
+    const matchingLinks =
+      Array.from(document.getElementsByTagName('a'))
+        .filter(a =>
+          isDroppedURL(new URL(a.href, document.documentURI)));
+
+    const latestDraggedLink = getLatestDraggedLink();
+
+    if (!matchingLinks.includes(latestDraggedLink)) {
+      break matchLink;
+    }
+
+    let matchedLink = latestDraggedLink;
+
+    if (matchedLink.querySelector('.normal-content')) {
+      matchedLink = matchedLink.cloneNode(true);
+      for (const node of matchedLink.querySelectorAll('.normal-content')) {
+        node.remove();
+      }
+    }
+
+    droppedText = matchedLink.innerText;
+  }
+
+  if (droppedText.includes('-')) splitDashes: {
+    if (droppedURL) break splitDashes;
+    if (droppedText.includes(' ')) break splitDashes;
+
+    const parts = droppedText.split('-');
+    if (parts.length === 2) break splitDashes;
+
+    droppedText = parts.join(' ');
+  }
+
+  info.searchInput.value = droppedText;
+  activateSidebarSearch(info.searchInput.value);
+}
diff --git a/src/static/js/client/sticky-heading.js b/src/static/js/client/sticky-heading.js
new file mode 100644
index 00000000..b65574d0
--- /dev/null
+++ b/src/static/js/client/sticky-heading.js
@@ -0,0 +1,345 @@
+/* eslint-env browser */
+
+import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js';
+import {cssProp, dispatchInternalEvent, templateContent}
+  from '../client-util.js';
+
+export const info = {
+  id: 'stickyHeadingInfo',
+
+  stickyRoots: null,
+
+  stickyContainers: null,
+  staticContainers: null,
+
+  stickyHeadingRows: null,
+  stickyHeadings: null,
+  stickySubheadingRows: null,
+  stickySubheadings: null,
+
+  stickyCoverContainers: null,
+  stickyCoverTextAreas: null,
+  stickyCovers: null,
+
+  contentContainers: null,
+  contentHeadings: null,
+  contentCoverColumns: null,
+  contentCovers: null,
+  contentCoversReveal: null,
+
+  referenceCollapsedHeading: null,
+
+  state: {
+    displayedHeading: null,
+  },
+
+  event: {
+    whenDisplayedHeadingChanges: [],
+    whenStuckStatusChanges: [],
+  },
+};
+
+export function getPageReferences() {
+  info.stickyRoots =
+    Array.from(document.querySelectorAll('.content-sticky-heading-root:not([inert])'));
+
+  info.stickyContainers =
+    info.stickyRoots
+      .map(el => el.querySelector('.content-sticky-heading-container'));
+
+  info.staticContainers =
+    info.stickyRoots
+      .map(el => el.nextElementSibling);
+
+  info.stickyCoverContainers =
+    info.stickyContainers
+      .map(el => el.querySelector('.content-sticky-heading-cover-container'));
+
+  info.stickyCovers =
+    info.stickyCoverContainers
+      .map(el => el?.querySelector('.content-sticky-heading-cover'));
+
+  info.stickyCoverTextAreas =
+    info.stickyCovers
+      .map(el => el?.querySelector('.image-text-area'));
+
+  info.stickyHeadingRows =
+    info.stickyContainers
+      .map(el => el.querySelector('.content-sticky-heading-row'));
+
+  info.stickyHeadings =
+    info.stickyHeadingRows
+      .map(el => el.querySelector('h1'));
+
+  info.stickySubheadingRows =
+    info.stickyContainers
+      .map(el => el.querySelector('.content-sticky-subheading-row'));
+
+  info.stickySubheadings =
+    info.stickySubheadingRows
+      .map(el => el.querySelector('h2'));
+
+  info.contentContainers =
+    info.stickyContainers
+      .map(el => el.closest('.content-sticky-heading-root').parentElement);
+
+  info.contentCoverColumns =
+    info.contentContainers
+      .map(el => el.querySelector('#artwork-column'));
+
+  info.contentCovers =
+    info.contentCoverColumns
+      .map(el => el ? el.querySelector('.cover-artwork') : null);
+
+  info.contentCoversReveal =
+    info.contentCovers
+      .map(el => el ? !!el.querySelector('.reveal') : null);
+
+  info.contentHeadings =
+    info.contentContainers
+      .map(el => Array.from(el.querySelectorAll('.content-heading')));
+
+  info.referenceCollapsedHeading =
+    info.stickyHeadings
+      .map(el => el.querySelector('.reference-collapsed-heading'));
+}
+
+export function mutatePageContent() {
+  removeTextPlaceholderStickyHeadingCovers();
+  addRevealClassToStickyHeadingCovers();
+}
+
+function removeTextPlaceholderStickyHeadingCovers() {
+  const hasTextArea =
+    info.stickyCoverTextAreas.map(el => !!el);
+
+  const coverContainersWithTextArea =
+    info.stickyCoverContainers
+      .filter((_el, index) => hasTextArea[index]);
+
+  for (const el of coverContainersWithTextArea) {
+    el.remove();
+  }
+
+  info.stickyCoverContainers =
+    info.stickyCoverContainers
+      .map((el, index) => hasTextArea[index] ? null : el);
+
+  info.stickyCovers =
+    info.stickyCovers
+      .map((el, index) => hasTextArea[index] ? null : el);
+
+  info.stickyCoverTextAreas =
+    info.stickyCoverTextAreas
+      .slice()
+      .fill(null);
+}
+
+function addRevealClassToStickyHeadingCovers() {
+  const stickyCoversWhichReveal =
+    info.stickyCovers
+      .filter((_el, index) => info.contentCoversReveal[index]);
+
+  for (const el of stickyCoversWhichReveal) {
+    el.classList.add('content-sticky-heading-cover-needs-reveal');
+  }
+}
+
+function addRevealListenersForStickyHeadingCovers() {
+  const stickyCovers = info.stickyCovers.slice();
+  const contentCovers = info.contentCovers.slice();
+
+  filterMultipleArrays(
+    stickyCovers,
+    contentCovers,
+    (_stickyCover, _contentCover, index) => info.contentCoversReveal[index]);
+
+  for (const {stickyCover, contentCover} of stitchArrays({
+    stickyCover: stickyCovers,
+    contentCover: contentCovers,
+  })) {
+    // TODO: Janky - should use internal event instead of DOM event
+    contentCover.querySelector('.reveal').addEventListener('hsmusic-reveal', () => {
+      stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal');
+    });
+  }
+}
+
+function topOfViewInside(el, scroll = window.scrollY) {
+  return (
+    scroll > el.offsetTop &&
+    scroll < el.offsetTop + el.offsetHeight);
+}
+
+function updateStuckStatus(index) {
+  const {event} = info;
+
+  const contentContainer = info.contentContainers[index];
+  const stickyContainer = info.stickyContainers[index];
+
+  const wasStuck = stickyContainer.classList.contains('stuck');
+  const stuck = topOfViewInside(contentContainer);
+
+  if (stuck === wasStuck) return;
+
+  if (stuck) {
+    stickyContainer.classList.add('stuck');
+  } else {
+    stickyContainer.classList.remove('stuck');
+  }
+
+  dispatchInternalEvent(event, 'whenStuckStatusChanges', index, stuck);
+}
+
+function updateCollapseStatus(index) {
+  const stickyContainer = info.stickyContainers[index];
+  const staticContainer = info.staticContainers[index];
+  const stickyHeading = info.stickyHeadings[index];
+  const referenceCollapsedHeading = info.referenceCollapsedHeading[index];
+
+  const {height: uncollapsedHeight} = stickyHeading.getBoundingClientRect();
+  const {height: collapsedHeight} = referenceCollapsedHeading.getBoundingClientRect();
+
+  if (
+    staticContainer.getBoundingClientRect().bottom < 4 ||
+    staticContainer.getBoundingClientRect().top < -80
+  ) {
+    if (!stickyContainer.classList.contains('collapse')) {
+      stickyContainer.classList.add('collapse');
+      cssProp(stickyContainer, '--uncollapsed-heading-height', uncollapsedHeight + 'px');
+      cssProp(stickyContainer, '--collapsed-heading-height', collapsedHeight + 'px');
+    }
+  } else {
+    stickyContainer.classList.remove('collapse');
+  }
+}
+
+function updateStickyCoverVisibility(index) {
+  const stickyCoverContainer = info.stickyCoverContainers[index];
+  const stickyContainer = info.stickyContainers[index];
+  const contentCoverColumn = info.contentCoverColumns[index];
+
+  if (contentCoverColumn && stickyCoverContainer) {
+    if (contentCoverColumn.getBoundingClientRect().bottom < 4) {
+      stickyCoverContainer.classList.add('visible');
+      stickyContainer.classList.add('cover-visible');
+    } else {
+      stickyCoverContainer.classList.remove('visible');
+      stickyContainer.classList.remove('cover-visible');
+    }
+  }
+}
+
+function getContentHeadingClosestToStickySubheading(index) {
+  const contentContainer = info.contentContainers[index];
+
+  if (!topOfViewInside(contentContainer)) {
+    return null;
+  }
+
+  const stickyHeadingRow = info.stickyHeadingRows[index];
+  const stickyRect = stickyHeadingRow.getBoundingClientRect();
+
+  // Subheadings only appear when the sticky heading is collapsed,
+  // so the used bottom edge should always be *as though* it's only
+  // displaying one line of text. Subtract the current discrepancy.
+  const stickyHeading = info.stickyHeadings[index];
+  const referenceCollapsedHeading = info.referenceCollapsedHeading[index];
+  const correctBottomEdge =
+    stickyHeading.getBoundingClientRect().height -
+    referenceCollapsedHeading.getBoundingClientRect().height;
+
+  const stickyBottom =
+    (stickyRect.bottom
+   - correctBottomEdge);
+
+  // Iterate from bottom to top of the content area.
+  const contentHeadings = info.contentHeadings[index];
+  for (const heading of contentHeadings.slice().reverse()) {
+    const headingRect = heading.getBoundingClientRect();
+    if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 40) {
+      return heading;
+    }
+  }
+
+  return null;
+}
+
+function updateStickySubheadingContent(index) {
+  const {event, state} = info;
+
+  const stickyContainer = info.stickyContainers[index];
+
+  const closestHeading =
+    (stickyContainer.classList.contains('collapse')
+      ? getContentHeadingClosestToStickySubheading(index)
+      : null);
+
+  if (state.displayedHeading === closestHeading) return;
+
+  const stickySubheadingRow = info.stickySubheadingRows[index];
+
+  if (closestHeading) {
+    const stickySubheading = info.stickySubheadings[index];
+
+    // Array.from needed to iterate over a live array with for..of
+    for (const child of Array.from(stickySubheading.childNodes)) {
+      child.remove();
+    }
+
+    const textContainer =
+      templateContent(
+        closestHeading.querySelector('.content-heading-sticky-title')) ??
+      closestHeading.querySelector('.content-heading-main-title') ??
+      closestHeading;
+
+    for (const child of textContainer.childNodes) {
+      if (child.tagName === 'A') {
+        for (const grandchild of child.childNodes) {
+          stickySubheading.appendChild(grandchild.cloneNode(true));
+        }
+      } else {
+        stickySubheading.appendChild(child.cloneNode(true));
+      }
+    }
+
+    stickySubheadingRow.classList.add('visible');
+  } else {
+    stickySubheadingRow.classList.remove('visible');
+  }
+
+  const oldDisplayedHeading = state.displayedHeading;
+
+  state.displayedHeading = closestHeading;
+
+  dispatchInternalEvent(event, 'whenDisplayedHeadingChanges', index, {
+    oldHeading: oldDisplayedHeading,
+    newHeading: closestHeading,
+  });
+}
+
+export function updateStickyHeadings(index) {
+  updateStuckStatus(index);
+  updateCollapseStatus(index);
+  updateStickyCoverVisibility(index);
+  updateStickySubheadingContent(index);
+}
+
+export function initializeState() {
+  for (let i = 0; i < info.stickyContainers.length; i++) {
+    updateStickyHeadings(i);
+  }
+}
+
+export function addPageListeners() {
+  addRevealListenersForStickyHeadingCovers();
+  addScrollListenerForStickyHeadings();
+}
+
+function addScrollListenerForStickyHeadings() {
+  document.addEventListener('scroll', () => {
+    for (let i = 0; i < info.stickyContainers.length; i++) {
+      updateStickyHeadings(i);
+    }
+  });
+}
diff --git a/src/static/js/client/summary-nested-link.js b/src/static/js/client/summary-nested-link.js
new file mode 100644
index 00000000..23857fa5
--- /dev/null
+++ b/src/static/js/client/summary-nested-link.js
@@ -0,0 +1,48 @@
+/* eslint-env browser */
+
+import {
+  empty,
+  filterMultipleArrays,
+  stitchArrays,
+} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'summaryNestedLinkInfo',
+
+  summaries: null,
+  links: null,
+};
+
+export function getPageReferences() {
+  info.summaries =
+    Array.from(document.getElementsByTagName('summary'));
+
+  info.links =
+    info.summaries
+      .map(summary =>
+        Array.from(summary.getElementsByTagName('a')));
+
+  filterMultipleArrays(
+    info.summaries,
+    info.links,
+    (_summary, links) => !empty(links));
+}
+
+export function addPageListeners() {
+  for (const {summary, links} of stitchArrays({
+    summary: info.summaries,
+    links: info.links,
+  })) {
+    for (const link of links) {
+      link.addEventListener('mouseover', () => {
+        link.classList.add('nested-hover');
+        summary.classList.add('has-nested-hover');
+      });
+
+      link.addEventListener('mouseout', () => {
+        link.classList.remove('nested-hover');
+        summary.classList.remove('has-nested-hover');
+      });
+    }
+  }
+}
diff --git a/src/static/js/client/text-with-tooltip.js b/src/static/js/client/text-with-tooltip.js
new file mode 100644
index 00000000..dd207e04
--- /dev/null
+++ b/src/static/js/client/text-with-tooltip.js
@@ -0,0 +1,34 @@
+/* eslint-env browser */
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+import {registerTooltipElement, registerTooltipHoverableElement}
+  from './hoverable-tooltip.js';
+
+export const info = {
+  id: 'textWithTooltipInfo',
+
+  hoverables: null,
+  tooltips: null,
+};
+
+export function getPageReferences() {
+  const spans =
+    Array.from(document.querySelectorAll('.text-with-tooltip'));
+
+  info.hoverables =
+    spans.map(span => span.children[0]);
+
+  info.tooltips =
+    spans.map(span => span.children[1]);
+}
+
+export function addPageListeners() {
+  for (const {hoverable, tooltip} of stitchArrays({
+    hoverable: info.hoverables,
+    tooltip: info.tooltips,
+  })) {
+    registerTooltipElement(tooltip);
+    registerTooltipHoverableElement(hoverable, tooltip);
+  }
+}
diff --git a/src/static/js/client/wiki-search.js b/src/static/js/client/wiki-search.js
new file mode 100644
index 00000000..2446c172
--- /dev/null
+++ b/src/static/js/client/wiki-search.js
@@ -0,0 +1,239 @@
+/* eslint-env browser */
+
+import {promiseWithResolvers} from '../../shared-util/sugar.js';
+
+import {dispatchInternalEvent} from '../client-util.js';
+
+export const info = {
+  id: 'wikiSearchInfo',
+
+  state: {
+    worker: null,
+
+    workerReadyPromise: null,
+    workerReadyPromiseResolvers: null,
+
+    workerActionCounter: 0,
+    workerActionPromiseResolverMap: new Map(),
+
+    downloads: Object.create(null),
+  },
+
+  event: {
+    whenWorkerAlive: [],
+    whenWorkerReady: [],
+    whenWorkerFailsToInitialize: [],
+    whenWorkerHasRuntimeError: [],
+
+    whenDownloadBegins: [],
+    whenDownloadsBegin: [],
+    whenDownloadProgresses: [],
+    whenDownloadEnds: [],
+  },
+};
+
+export async function initializeSearchWorker() {
+  const {state} = info;
+
+  if (state.worker) {
+    return await state.workerReadyPromise;
+  }
+
+  state.worker =
+    new Worker(
+      import.meta.resolve('../search-worker.js'),
+      {type: 'module'});
+
+  state.worker.onmessage = handleSearchWorkerMessage;
+
+  const {promise, resolve, reject} = promiseWithResolvers();
+
+  state.workerReadyPromiseResolvers = {resolve, reject};
+
+  return await (state.workerReadyPromise = promise);
+}
+
+function handleSearchWorkerMessage(message) {
+  switch (message.data.kind) {
+    case 'status':
+      handleSearchWorkerStatusMessage(message);
+      break;
+
+    case 'result':
+      handleSearchWorkerResultMessage(message);
+      break;
+
+    case 'download-begun':
+      handleSearchWorkerDownloadBegunMessage(message);
+      break;
+
+    case 'download-progress':
+      handleSearchWorkerDownloadProgressMessage(message);
+      break;
+
+    case 'download-complete':
+      handleSearchWorkerDownloadCompleteMessage(message);
+      break;
+
+    default:
+      console.warn(`Unknown message kind "${message.data.kind}" <- from search worker`);
+      break;
+  }
+}
+
+function handleSearchWorkerStatusMessage(message) {
+  const {state, event} = info;
+
+  switch (message.data.status) {
+    case 'alive':
+      console.debug(`Search worker is alive, but not yet ready.`);
+      dispatchInternalEvent(event, 'whenWorkerAlive');
+      break;
+
+    case 'ready':
+      console.debug(`Search worker has loaded corpuses and is ready.`);
+      state.workerReadyPromiseResolvers.resolve(state.worker);
+      dispatchInternalEvent(event, 'whenWorkerReady');
+      break;
+
+    case 'setup-error':
+      console.debug(`Search worker failed to initialize.`);
+      state.workerReadyPromiseResolvers.reject(new Error('Received "setup-error" status from worker'));
+      dispatchInternalEvent(event, 'whenWorkerFailsToInitialize');
+      break;
+
+    case 'runtime-error':
+      console.debug(`Search worker had an uncaught runtime error.`);
+      dispatchInternalEvent(event, 'whenWorkerHasRuntimeError');
+      break;
+
+    default:
+      console.warn(`Unknown status "${message.data.status}" <- from search worker`);
+      break;
+  }
+}
+
+function handleSearchWorkerResultMessage(message) {
+  const {state} = info;
+  const {id} = message.data;
+
+  if (!id) {
+    console.warn(`Result without id <- from search worker:`, message.data);
+    return;
+  }
+
+  if (!state.workerActionPromiseResolverMap.has(id)) {
+    console.warn(`Runaway result id <- from search worker:`, message.data);
+    return;
+  }
+
+  const {resolve, reject} =
+    state.workerActionPromiseResolverMap.get(id);
+
+  switch (message.data.status) {
+    case 'resolve':
+      resolve(message.data.value);
+      break;
+
+    case 'reject':
+      reject(message.data.value);
+      break;
+
+    default:
+      console.warn(`Unknown result status "${message.data.status}" <- from search worker`);
+      return;
+  }
+
+  state.workerActionPromiseResolverMap.delete(id);
+}
+
+function handleSearchWorkerDownloadBegunMessage(message) {
+  const {event} = info;
+  const {context: contextKey, keys} = message.data;
+
+  const context = getSearchWorkerDownloadContext(contextKey, true);
+
+  for (const key of keys) {
+    context[key] = 0.00;
+
+    dispatchInternalEvent(event, 'whenDownloadBegins', {
+      context: contextKey,
+      key,
+    });
+  }
+
+  dispatchInternalEvent(event, 'whenDownloadsBegin', {
+    context: contextKey,
+    keys,
+  });
+}
+
+function handleSearchWorkerDownloadProgressMessage(message) {
+  const {event} = info;
+  const {context: contextKey, key, progress} = message.data;
+
+  const context = getSearchWorkerDownloadContext(contextKey);
+
+  context[key] = progress;
+
+  dispatchInternalEvent(event, 'whenDownloadProgresses', {
+    context: contextKey,
+    key,
+    progress,
+  });
+}
+
+function handleSearchWorkerDownloadCompleteMessage(message) {
+  const {event} = info;
+  const {context: contextKey, key} = message.data;
+
+  const context = getSearchWorkerDownloadContext(contextKey);
+
+  context[key] = 1.00;
+
+  dispatchInternalEvent(event, 'whenDownloadEnds', {
+    context: contextKey,
+    key,
+  });
+}
+
+export function getSearchWorkerDownloadContext(context, initialize = false) {
+  const {state} = info;
+
+  if (context in state.downloads) {
+    return state.downloads[context];
+  }
+
+  if (!initialize) {
+    return null;
+  }
+
+  return state.downloads[context] = Object.create(null);
+}
+
+export async function postSearchWorkerAction(action, options) {
+  const {state} = info;
+
+  const worker = await initializeSearchWorker();
+  const id = ++state.workerActionCounter;
+
+  const {promise, resolve, reject} = promiseWithResolvers();
+
+  state.workerActionPromiseResolverMap.set(id, {resolve, reject});
+
+  worker.postMessage({
+    kind: 'action',
+    action: action,
+    id,
+    options,
+  });
+
+  return await promise;
+}
+
+export async function searchAll(query, options = {}) {
+  return await postSearchWorkerAction('search', {
+    query,
+    options,
+  });
+}