« 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.js49
-rw-r--r--src/static/js/client/album-commentary-sidebar.js7
-rw-r--r--src/static/js/client/art-tag-gallery-filter.js6
-rw-r--r--src/static/js/client/art-tag-network.js2
-rw-r--r--src/static/js/client/artist-external-link-tooltip.js2
-rw-r--r--src/static/js/client/artist-rolling-window.js571
-rw-r--r--src/static/js/client/css-compatibility-assistant.js22
-rw-r--r--src/static/js/client/datetimestamp-tooltip.js2
-rw-r--r--src/static/js/client/dragged-link.js2
-rw-r--r--src/static/js/client/expandable-grid-section.js83
-rw-r--r--src/static/js/client/gallery-style-selector.js121
-rw-r--r--src/static/js/client/group-contributions-table.js163
-rw-r--r--src/static/js/client/hash-link.js60
-rw-r--r--src/static/js/client/hoverable-tooltip.js162
-rw-r--r--src/static/js/client/image-overlay.js10
-rw-r--r--src/static/js/client/index.js155
-rw-r--r--src/static/js/client/intrapage-dot-switcher.js2
-rw-r--r--src/static/js/client/live-mouse-position.js2
-rw-r--r--src/static/js/client/memorable-details.js62
-rw-r--r--src/static/js/client/quick-description.js2
-rw-r--r--src/static/js/client/reveal-all-grid-control.js70
-rw-r--r--src/static/js/client/scripted-link.js2
-rw-r--r--src/static/js/client/sidebar-search.js623
-rw-r--r--src/static/js/client/sticky-heading.js23
-rw-r--r--src/static/js/client/summary-nested-link.js48
-rw-r--r--src/static/js/client/text-with-tooltip.js2
-rw-r--r--src/static/js/client/wiki-search.js2
27 files changed, 2023 insertions, 232 deletions
diff --git a/src/static/js/client/additional-names-box.js b/src/static/js/client/additional-names-box.js
index 3535a0e5..e099904a 100644
--- a/src/static/js/client/additional-names-box.js
+++ b/src/static/js/client/additional-names-box.js
@@ -1,5 +1,3 @@
-/* eslint-env browser */
-
 import {cssProp} from '../client-util.js';
 
 import {info as hashLinkInfo} from './hash-link.js';
@@ -19,8 +17,16 @@ export const info = {
   state: {
     visible: false,
   },
+
+  session: {
+    visibleWhileNavigatingAlbum: {type: 'string'},
+  },
 };
 
+export function* bindSessionStorage() {
+  yield 'visibleWhileNavigatingAlbum';
+}
+
 export function getPageReferences() {
   info.box =
     document.getElementById('additional-names-box');
@@ -33,7 +39,7 @@ export function getPageReferences() {
       '.content-sticky-heading-container' +
       ' ' +
       'a[href="#additional-names-box"]' +
-      ':not(:matches([inert] *))');
+      ':not(:where([inert] *))');
 
   info.contentContainer =
     document.querySelector('#content');
@@ -78,6 +84,33 @@ export function addInternalListeners() {
   });
 }
 
+export function mutatePageContent() {
+  const {session} = info;
+
+  if (!info.box) return;
+  if (!session.visibleWhileNavigatingAlbum) return;
+
+  const currentAlbum =
+    cssProp(document.body, '--album-directory');
+
+  if (session.visibleWhileNavigatingAlbum === currentAlbum) {
+    toggleAdditionalNamesBox();
+  }
+}
+
+export function initializeState() {
+  const {session} = info;
+
+  if (!session.visibleWhileNavigatingAlbum) return;
+
+  const currentAlbum =
+    cssProp(document.body, '--album-directory');
+
+  if (session.visibleWhileNavigatingAlbum !== currentAlbum) {
+    session.visibleWhileNavigatingAlbum = null;
+  }
+}
+
 export function addPageListeners() {
   for (const link of info.links) {
     link.addEventListener('click', domEvent => {
@@ -121,7 +154,7 @@ function handleAdditionalNamesBoxLinkClicked(domEvent) {
       ? top > 0.4 * window.innerHeight
       : top > 0.5 * window.innerHeight) ||
 
-    (bottom && bottomFitsInFrame
+    (bottom && boxFitsInFrame
       ? bottom > window.innerHeight - 20
       : false);
 
@@ -140,11 +173,17 @@ function handleAdditionalNamesBoxLinkClicked(domEvent) {
 }
 
 export function toggleAdditionalNamesBox() {
-  const {state} = info;
+  const {state, session} = info;
 
   state.visible = !state.visible;
+
   info.box.style.display =
     (state.visible
       ? 'block'
       : 'none');
+
+  session.visibleWhileNavigatingAlbum =
+    (state.visible
+      ? cssProp(document.body, '--album-directory')
+      : null);
 }
diff --git a/src/static/js/client/album-commentary-sidebar.js b/src/static/js/client/album-commentary-sidebar.js
index c5eaf81b..144544ed 100644
--- a/src/static/js/client/album-commentary-sidebar.js
+++ b/src/static/js/client/album-commentary-sidebar.js
@@ -1,5 +1,3 @@
-/* eslint-env browser */
-
 import {empty} from '../../shared-util/sugar.js';
 
 import {info as hashLinkInfo} from './hash-link.js';
@@ -25,7 +23,10 @@ export const info = {
 };
 
 export function getPageReferences() {
-  if (document.documentElement.dataset.urlKey !== 'localized.albumCommentary') {
+  if (
+    document.documentElement.dataset.urlKey !== 'localized.albumCommentary' &&
+    document.documentElement.dataset.urlKey !== 'localized.vgmAlbumCommentary'
+  ) {
     return;
   }
 
diff --git a/src/static/js/client/art-tag-gallery-filter.js b/src/static/js/client/art-tag-gallery-filter.js
index fd40d1a2..b7fff70d 100644
--- a/src/static/js/client/art-tag-gallery-filter.js
+++ b/src/static/js/client/art-tag-gallery-filter.js
@@ -1,5 +1,3 @@
-/* eslint-env browser */
-
 export const info = {
   id: 'artTagGalleryFilterInfo',
 
@@ -142,8 +140,8 @@ export function addPageListeners() {
       currentFeaturedLine.style.display = 'none';
       currentShowingLine.style.display = 'none';
 
-      nextFeaturedLine.style.display = 'block';
-      nextShowingLine.style.display = 'block';
+      nextFeaturedLine.style.display = 'inline';
+      nextShowingLine.style.display = 'inline';
 
       filterArtTagGallery(nextShowing);
     });
diff --git a/src/static/js/client/art-tag-network.js b/src/static/js/client/art-tag-network.js
index 44e10c11..d0576152 100644
--- a/src/static/js/client/art-tag-network.js
+++ b/src/static/js/client/art-tag-network.js
@@ -1,5 +1,3 @@
-/* eslint-env browser */
-
 import {cssProp} from '../client-util.js';
 
 import {atOffset, stitchArrays} from '../../shared-util/sugar.js';
diff --git a/src/static/js/client/artist-external-link-tooltip.js b/src/static/js/client/artist-external-link-tooltip.js
index 21ddfb91..2eadf916 100644
--- a/src/static/js/client/artist-external-link-tooltip.js
+++ b/src/static/js/client/artist-external-link-tooltip.js
@@ -1,5 +1,3 @@
-/* eslint-env browser */
-
 import {accumulateSum, empty} from '../../shared-util/sugar.js';
 
 import {info as hoverableTooltipInfo, repositionCurrentTooltip}
diff --git a/src/static/js/client/artist-rolling-window.js b/src/static/js/client/artist-rolling-window.js
new file mode 100644
index 00000000..b8ff7354
--- /dev/null
+++ b/src/static/js/client/artist-rolling-window.js
@@ -0,0 +1,571 @@
+import {cssProp, formatDate} from '../client-util.js';
+
+import {sortByDate} from '../../shared-util/sort.js';
+import {chunkByConditions, chunkByProperties, empty, stitchArrays}
+  from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'artistRollingWindowInfo',
+
+  timeframeMonthsBefore: null,
+  timeframeMonthsAfter: null,
+  timeframeMonthsPeek: null,
+
+  contributionKind: null,
+  contributionGroup: null,
+
+  timeframeSelectionSomeLine: null,
+  timeframeSelectionNoneLine: null,
+
+  timeframeSelectionContributionCount: null,
+  timeframeSelectionTimeframeCount: null,
+  timeframeSelectionFirstDate: null,
+  timeframeSelectionLastDate: null,
+
+  timeframeSelectionControl: null,
+  timeframeSelectionMenu: null,
+  timeframeSelectionPrevious: null,
+  timeframeSelectionNext: null,
+
+  timeframeEmptyLine: null,
+
+  sourceArea: null,
+  sourceGrid: null,
+  sources: null,
+};
+
+export function getPageReferences() {
+  if (document.documentElement.dataset.urlKey !== 'localized.artistRollingWindow') {
+    return;
+  }
+
+  info.timeframeMonthsBefore =
+    document.getElementById('timeframe-months-before');
+
+  info.timeframeMonthsAfter =
+    document.getElementById('timeframe-months-after');
+
+  info.timeframeMonthsPeek =
+    document.getElementById('timeframe-months-peek');
+
+  info.contributionKind =
+    document.getElementById('contribution-kind');
+
+  info.contributionGroup =
+    document.getElementById('contribution-group');
+
+  info.timeframeSelectionSomeLine =
+    document.getElementById('timeframe-selection-some');
+
+  info.timeframeSelectionNoneLine =
+    document.getElementById('timeframe-selection-none');
+
+  info.timeframeSelectionContributionCount =
+    document.getElementById('timeframe-selection-contribution-count');
+
+  info.timeframeSelectionTimeframeCount =
+    document.getElementById('timeframe-selection-timeframe-count');
+
+  info.timeframeSelectionFirstDate =
+    document.getElementById('timeframe-selection-first-date');
+
+  info.timeframeSelectionLastDate =
+    document.getElementById('timeframe-selection-last-date');
+
+  info.timeframeSelectionControl =
+    document.getElementById('timeframe-selection-control');
+
+  info.timeframeSelectionMenu =
+    document.getElementById('timeframe-selection-menu');
+
+  info.timeframeSelectionPrevious =
+    document.getElementById('timeframe-selection-previous');
+
+  info.timeframeSelectionNext =
+    document.getElementById('timeframe-selection-next');
+
+  info.timeframeEmptyLine =
+    document.getElementById('timeframe-empty');
+
+  info.sourceArea =
+    document.getElementById('timeframe-source-area');
+
+  info.sourceGrid =
+    info.sourceArea.querySelector('.grid-listing');
+
+  info.sources =
+    info.sourceGrid.getElementsByClassName('grid-item');
+}
+
+export function addPageListeners() {
+  if (!info.sourceArea) {
+    return;
+  }
+
+  for (const input of [
+    info.timeframeMonthsBefore,
+    info.timeframeMonthsAfter,
+    info.timeframeMonthsPeek,
+    info.contributionKind,
+    info.contributionGroup,
+  ]) {
+    input.addEventListener('change', () => {
+      updateArtistRollingWindow()
+    });
+  }
+
+  info.timeframeSelectionMenu.addEventListener('change', () => {
+    updateRollingWindowTimeframeSelection();
+  });
+
+  const eatClicks = (element, callback) => {
+    element.addEventListener('click', domEvent => {
+      domEvent.preventDefault();
+      callback();
+    });
+
+    element.addEventListener('mousedown', domEvent => {
+      if (domEvent.detail > 1) {
+        domEvent.preventDefault();
+      }
+    });
+  };
+
+  eatClicks(info.timeframeSelectionNext, nextRollingTimeframeSelection);
+  eatClicks(info.timeframeSelectionPrevious, previousRollingTimeframeSelection);
+}
+
+export function mutatePageContent() {
+  if (!info.sourceArea) {
+    return;
+  }
+
+  updateArtistRollingWindow();
+}
+
+function previousRollingTimeframeSelection() {
+  const menu = info.timeframeSelectionMenu;
+
+  if (menu.selectedIndex > 0) {
+    menu.selectedIndex--;
+  }
+
+  updateRollingWindowTimeframeSelection();
+}
+
+function nextRollingTimeframeSelection() {
+  const menu = info.timeframeSelectionMenu;
+
+  if (menu.selectedIndex < menu.length - 1) {
+    menu.selectedIndex++;
+  }
+
+  updateRollingWindowTimeframeSelection();
+}
+
+function getArtistRollingWindowSourceInfo() {
+  const sourceElements =
+    Array.from(info.sources);
+
+  const sourceTimeElements =
+    sourceElements
+      .map(el => Array.from(el.getElementsByTagName('time')));
+
+  const sourceTimeClasses =
+    sourceTimeElements
+      .map(times => times
+        .map(time => Array.from(time.classList)));
+
+  const sourceKinds =
+    sourceTimeClasses
+      .map(times => times
+        .map(classes => classes
+          .find(cl => cl.endsWith('-contribution-date'))
+          .slice(0, -'-contribution-date'.length)));
+
+  const sourceGroups =
+    sourceElements
+      .map(el =>
+        Array.from(el.querySelectorAll('.contribution-group'))
+          .map(data => data.value));
+
+  const sourceDates =
+    sourceTimeElements
+      .map(times => times
+        .map(time => new Date(time.getAttribute('datetime'))));
+
+  return stitchArrays({
+    element: sourceElements,
+    kinds: sourceKinds,
+    groups: sourceGroups,
+    dates: sourceDates,
+  });
+}
+
+function getArtistRollingWindowTimeframeInfo() {
+  const contributionKind =
+    info.contributionKind.value;
+
+  const contributionGroup =
+    info.contributionGroup.value;
+
+  const sourceInfo =
+    getArtistRollingWindowSourceInfo();
+
+  const principalSources =
+    sourceInfo.filter(source => {
+      if (!source.kinds.includes(contributionKind)) {
+        return false;
+      }
+
+      if (contributionGroup !== '-') {
+        if (!source.groups.includes(contributionGroup)) {
+          return false;
+        }
+      }
+
+      return true;
+    });
+
+  const principalSourceDates =
+    principalSources.map(source =>
+      stitchArrays({
+        kind: source.kinds,
+        date: source.dates,
+      }).find(({kind}) => kind === contributionKind)
+        .date);
+
+  const getPeekDate = inputDate => {
+    const date = new Date(inputDate);
+
+    date.setMonth(
+      (date.getMonth()
+     - parseInt(info.timeframeMonthsBefore.value)
+     - parseInt(info.timeframeMonthsPeek.value)));
+
+    return date;
+  };
+
+  const getEntranceDate = inputDate => {
+    const date = new Date(inputDate);
+
+    date.setMonth(
+      (date.getMonth()
+     - parseInt(info.timeframeMonthsBefore.value)));
+
+    return date;
+  };
+
+  const getExitDate = inputDate => {
+    const date = new Date(inputDate);
+
+    date.setMonth(
+      (date.getMonth()
+     + parseInt(info.timeframeMonthsAfter.value)));
+
+    return date;
+  };
+
+  const principalSourceIndices =
+    Array.from({length: principalSources.length}, (_, i) => i);
+
+  const timeframeSourceChunks =
+    chunkByConditions(principalSourceIndices, [
+      (previous, next) =>
+        +principalSourceDates[previous] !==
+        +principalSourceDates[next],
+    ]);
+
+  const timeframeSourceChunkDates =
+    timeframeSourceChunks
+      .map(indices => indices[0])
+      .map(index => principalSourceDates[index]);
+
+  const timeframeSourceChunkPeekDates =
+    timeframeSourceChunkDates
+      .map(getPeekDate);
+
+  const timeframeSourceChunkEntranceDates =
+    timeframeSourceChunkDates
+      .map(getEntranceDate);
+
+  const timeframeSourceChunkExitDates =
+    timeframeSourceChunkDates
+      .map(getExitDate);
+
+  const peekDateInfo =
+    stitchArrays({
+      peek: timeframeSourceChunkPeekDates,
+      indices: timeframeSourceChunks,
+    }).map(({peek, indices}) => ({
+        date: peek,
+        peek: indices,
+      }));
+
+  const entranceDateInfo =
+    stitchArrays({
+      entrance: timeframeSourceChunkEntranceDates,
+      indices: timeframeSourceChunks,
+    }).map(({entrance, indices}) => ({
+        date: entrance,
+        entrance: indices,
+      }));
+
+  const exitDateInfo =
+    stitchArrays({
+      exit: timeframeSourceChunkExitDates,
+      indices: timeframeSourceChunks,
+    }).map(({exit, indices}) => ({
+        date: exit,
+        exit: indices,
+      }));
+
+  const dateInfoChunks =
+    chunkByProperties(
+      sortByDate([
+        ...peekDateInfo,
+        ...entranceDateInfo,
+        ...exitDateInfo,
+      ]),
+      ['date']);
+
+  const dateInfo =
+    dateInfoChunks
+      .map(({chunk}) =>
+        Object.assign({
+          peek: null,
+          entrance: null,
+          exit: null,
+        }, ...chunk));
+
+  const timeframeInfo =
+    dateInfo.reduce(
+      (accumulator, {date, peek, entrance, exit}) => {
+        const previous = accumulator.at(-1);
+
+        // These mustn't be mutated!
+        let peeking = (previous ? previous.peeking : []);
+        let tracking = (previous ? previous.tracking : []);
+
+        if (peek) {
+          peeking =
+            peeking.concat(peek);
+        }
+
+        if (entrance) {
+          peeking =
+            peeking.filter(index => !entrance.includes(index));
+
+          tracking =
+            tracking.concat(entrance);
+        }
+
+        if (exit) {
+          tracking =
+            tracking.filter(index => !exit.includes(index));
+        }
+
+        return [...accumulator, {
+          date,
+          peeking,
+          tracking,
+          peek,
+          entrance,
+          exit,
+        }];
+      },
+      []);
+
+  const indicesToSources = indices =>
+    (indices
+      ? indices.map(index => principalSources[index])
+      : null);
+
+  const finalizedTimeframeInfo =
+    timeframeInfo.map(({
+      date,
+      peeking,
+      tracking,
+      peek,
+      entrance,
+      exit,
+    }) => ({
+      date,
+      peeking: indicesToSources(peeking),
+      tracking: indicesToSources(tracking),
+      peek: indicesToSources(peek),
+      entrance: indicesToSources(entrance),
+      exit: indicesToSources(exit),
+    }));
+
+  return finalizedTimeframeInfo;
+}
+
+function updateArtistRollingWindow() {
+  const timeframeInfo =
+    getArtistRollingWindowTimeframeInfo();
+
+  if (empty(timeframeInfo)) {
+    cssProp(info.timeframeSelectionControl, 'display', 'none');
+    cssProp(info.timeframeSelectionSomeLine, 'display', 'none');
+    cssProp(info.timeframeSelectionNoneLine, 'display', null);
+
+    updateRollingWindowTimeframeSelection(timeframeInfo);
+
+    return;
+  }
+
+  cssProp(info.timeframeSelectionControl, 'display', null);
+  cssProp(info.timeframeSelectionSomeLine, 'display', null);
+  cssProp(info.timeframeSelectionNoneLine, 'display', 'none');
+
+  // The last timeframe is just the exit of the final tracked sources,
+  // so we aren't going to display a menu option for it, and will just use
+  // it as the end of the final option's date range.
+
+  const usedTimeframes = timeframeInfo.slice(0, -1);
+  const firstTimeframe = timeframeInfo.at(0);
+  const lastTimeframe = timeframeInfo.at(-1);
+
+  const sourceCount =
+    timeframeInfo
+      .flatMap(({entrance}) => entrance ?? [])
+      .length;
+
+  const timeframeCount =
+    usedTimeframes.length;
+
+  info.timeframeSelectionContributionCount.innerText = sourceCount;
+  info.timeframeSelectionTimeframeCount.innerText = timeframeCount;
+
+  const firstDate = firstTimeframe.date;
+  const lastDate = lastTimeframe.date;
+
+  info.timeframeSelectionFirstDate.innerText = formatDate(firstDate);
+  info.timeframeSelectionLastDate.innerText = formatDate(lastDate);
+
+  while (info.timeframeSelectionMenu.firstChild) {
+    info.timeframeSelectionMenu.firstChild.remove();
+  }
+
+  for (const [index, timeframe] of usedTimeframes.entries()) {
+    const nextTimeframe = timeframeInfo[index + 1];
+
+    const option = document.createElement('option');
+
+    option.appendChild(document.createTextNode(
+      `${formatDate(timeframe.date)} – ${formatDate(nextTimeframe.date)}`));
+
+    info.timeframeSelectionMenu.appendChild(option);
+  }
+
+  updateRollingWindowTimeframeSelection(timeframeInfo);
+}
+
+function updateRollingWindowTimeframeSelection(timeframeInfo) {
+  timeframeInfo ??= getArtistRollingWindowTimeframeInfo();
+
+  updateRollingWindowTimeframeSelectionControls(timeframeInfo);
+  updateRollingWindowTimeframeSelectionSources(timeframeInfo);
+}
+
+function updateRollingWindowTimeframeSelectionControls(timeframeInfo) {
+  const currentIndex =
+    info.timeframeSelectionMenu.selectedIndex;
+
+  const atFirstTimeframe =
+    currentIndex === 0;
+
+  // The last actual timeframe is empty and not displayed as a menu option.
+  const atLastTimeframe =
+    currentIndex === timeframeInfo.length - 2;
+
+  if (atFirstTimeframe) {
+    info.timeframeSelectionPrevious.removeAttribute('href');
+  } else {
+    info.timeframeSelectionPrevious.setAttribute('href', '#');
+  }
+
+  if (atLastTimeframe) {
+    info.timeframeSelectionNext.removeAttribute('href');
+  } else {
+    info.timeframeSelectionNext.setAttribute('href', '#');
+  }
+}
+
+function updateRollingWindowTimeframeSelectionSources(timeframeInfo) {
+  const currentIndex =
+    info.timeframeSelectionMenu.selectedIndex;
+
+  const contributionGroup =
+    info.contributionGroup.value;
+
+  cssProp(info.sourceGrid, 'display', null);
+
+  const {peeking: peekingSources, tracking: trackingSources} =
+    (empty(timeframeInfo)
+      ? {peeking: [], tracking: []}
+      : timeframeInfo[currentIndex]);
+
+  const peekingElements =
+    peekingSources.map(source => source.element);
+
+  const trackingElements =
+    trackingSources.map(source => source.element);
+
+  const showingElements =
+    [...trackingElements, ...peekingElements];
+
+  const hidingElements =
+    Array.from(info.sources)
+      .filter(element =>
+        !peekingElements.includes(element) &&
+        !trackingElements.includes(element));
+
+  for (const element of peekingElements) {
+    element.classList.add('peeking');
+    element.classList.remove('tracking');
+  }
+
+  for (const element of trackingElements) {
+    element.classList.remove('peeking');
+    element.classList.add('tracking');
+  }
+
+  for (const element of hidingElements) {
+    element.classList.remove('peeking');
+    element.classList.remove('tracking');
+    cssProp(element, 'display', 'none');
+  }
+
+  for (const element of showingElements) {
+    cssProp(element, 'display', null);
+
+    for (const time of element.getElementsByTagName('time')) {
+      for (const className of time.classList) {
+        if (!className.endsWith('-contribution-date')) continue;
+
+        const kind = className.slice(0, -'-contribution-date'.length);
+        if (kind === info.contributionKind.value) {
+          cssProp(time, 'display', null);
+        } else {
+          cssProp(time, 'display', 'none');
+        }
+      }
+    }
+
+    for (const data of element.getElementsByClassName('contribution-group')) {
+      if (contributionGroup === '-' || data.value !== contributionGroup) {
+        cssProp(data, 'display', null);
+      } else {
+        cssProp(data, 'display', 'none');
+      }
+    }
+  }
+
+  if (empty(peekingElements) && empty(trackingElements)) {
+    cssProp(info.timeframeEmptyLine, 'display', null);
+  } else {
+    cssProp(info.timeframeEmptyLine, 'display', 'none');
+  }
+}
diff --git a/src/static/js/client/css-compatibility-assistant.js b/src/static/js/client/css-compatibility-assistant.js
deleted file mode 100644
index 6e7b15b5..00000000
--- a/src/static/js/client/css-compatibility-assistant.js
+++ /dev/null
@@ -1,22 +0,0 @@
-/* eslint-env browser */
-
-export const info = {
-  id: 'cssCompatibilityAssistantInfo',
-
-  coverArtContainer: null,
-  coverArtImageDetails: null,
-};
-
-export function getPageReferences() {
-  info.coverArtContainer =
-    document.getElementById('cover-art-container');
-
-  info.coverArtImageDetails =
-    info.coverArtContainer?.querySelector('.image-details');
-}
-
-export function mutatePageContent() {
-  if (info.coverArtImageDetails) {
-    info.coverArtContainer.classList.add('has-image-details');
-  }
-}
diff --git a/src/static/js/client/datetimestamp-tooltip.js b/src/static/js/client/datetimestamp-tooltip.js
index 46d1cd5b..00530484 100644
--- a/src/static/js/client/datetimestamp-tooltip.js
+++ b/src/static/js/client/datetimestamp-tooltip.js
@@ -1,5 +1,3 @@
-/* eslint-env browser */
-
 // TODO: Maybe datetimestamps can just be incorporated into text-with-tooltip?
 
 import {stitchArrays} from '../../shared-util/sugar.js';
diff --git a/src/static/js/client/dragged-link.js b/src/static/js/client/dragged-link.js
index 56021e7f..3a4ee314 100644
--- a/src/static/js/client/dragged-link.js
+++ b/src/static/js/client/dragged-link.js
@@ -1,5 +1,3 @@
-/* eslint-env browser */
-
 export const info = {
   id: `draggedLinkInfo`,
 
diff --git a/src/static/js/client/expandable-grid-section.js b/src/static/js/client/expandable-grid-section.js
new file mode 100644
index 00000000..4d6e0058
--- /dev/null
+++ b/src/static/js/client/expandable-grid-section.js
@@ -0,0 +1,83 @@
+import {cssProp} from '../client-util.js';
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'expandableGallerySectionInfo',
+
+  items: null,
+  toggles: null,
+  expandCues: null,
+  collapseCues: null,
+};
+
+export function getPageReferences() {
+  const expandos =
+    Array.from(document.querySelectorAll('.grid-expando'));
+
+  const grids =
+    expandos
+      .map(expando => expando.closest('.grid-listing'));
+
+  info.items =
+    grids
+      .map(grid => grid.querySelectorAll('.grid-item'))
+      .map(items => Array.from(items));
+
+  info.toggles =
+    expandos
+      .map(expando => expando.querySelector('.grid-expando-toggle'));
+
+  info.expandCues =
+    info.toggles
+      .map(toggle => toggle.querySelector('.grid-expand-cue'));
+
+  info.collapseCues =
+    info.toggles
+      .map(toggle => toggle.querySelector('.grid-collapse-cue'));
+}
+
+export function addPageListeners() {
+  stitchArrays({
+    items: info.items,
+    toggle: info.toggles,
+    expandCue: info.expandCues,
+    collapseCue: info.collapseCues,
+  }).forEach(({
+      items,
+      toggle,
+      expandCue,
+      collapseCue,
+    }) => {
+      toggle.addEventListener('click', domEvent => {
+        domEvent.preventDefault();
+
+        const collapsed =
+          items.some(item =>
+            item.classList.contains('hidden-by-expandable-cut'));
+
+        for (const item of items) {
+          if (
+            !item.classList.contains('hidden-by-expandable-cut') &&
+            !item.classList.contains('shown-by-expandable-cut')
+          ) continue;
+
+          if (collapsed) {
+            item.classList.remove('hidden-by-expandable-cut');
+            item.classList.add('shown-by-expandable-cut');
+          } else {
+            item.classList.add('hidden-by-expandable-cut');
+            item.classList.remove('shown-by-expandable-cut');
+          }
+        }
+
+        if (collapsed) {
+          cssProp(expandCue, 'display', 'none');
+          cssProp(collapseCue, 'display', null);
+        } else {
+          cssProp(expandCue, 'display', null);
+          cssProp(collapseCue, 'display', 'none');
+        }
+      });
+    });
+}
diff --git a/src/static/js/client/gallery-style-selector.js b/src/static/js/client/gallery-style-selector.js
new file mode 100644
index 00000000..44f98ac3
--- /dev/null
+++ b/src/static/js/client/gallery-style-selector.js
@@ -0,0 +1,121 @@
+import {cssProp} from '../client-util.js';
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'galleryStyleSelectorInfo',
+
+  selectors: null,
+  sections: null,
+
+  selectorStyleInputs: null,
+  selectorStyleInputStyles: null,
+
+  selectorReleaseItems: null,
+  selectorReleaseItemStyles: null,
+
+  selectorCountAll: null,
+  selectorCountFiltered: null,
+  selectorCountFilteredCount: null,
+  selectorCountNone: null,
+};
+
+export function getPageReferences() {
+  info.selectors =
+    Array.from(document.querySelectorAll('.gallery-style-selector'));
+
+  info.sections =
+    info.selectors
+      .map(selector => selector.closest('section'));
+
+  info.selectorStyleInputs =
+    info.selectors
+      .map(selector => selector.querySelectorAll('.styles input'))
+      .map(inputs => Array.from(inputs));
+
+  info.selectorStyleInputStyles =
+    info.selectorStyleInputs
+      .map(inputs => inputs
+        .map(input => input.closest('label').dataset.style));
+
+  info.selectorReleaseItems =
+    info.sections
+      .map(section => section.querySelectorAll('.grid-item'))
+      .map(items => Array.from(items));
+
+  info.selectorReleaseItemStyles =
+    info.selectorReleaseItems
+      .map(items => items
+        .map(item => item.dataset.style));
+
+  info.selectorCountAll =
+    info.selectors
+      .map(selector => selector.querySelector('.count.all'));
+
+  info.selectorCountFiltered =
+    info.selectors
+      .map(selector => selector.querySelector('.count.filtered'));
+
+  info.selectorCountFilteredCount =
+    info.selectorCountFiltered
+      .map(selector => selector.querySelector('span'));
+
+  info.selectorCountNone =
+    info.selectors
+      .map(selector => selector.querySelector('.count.none'));
+}
+
+export function addPageListeners() {
+  for (const index of info.selectors.keys()) {
+    for (const input of info.selectorStyleInputs[index]) {
+      input.addEventListener('input', () => updateVisibleReleases(index));
+    }
+  }
+}
+
+function updateVisibleReleases(index) {
+  const inputs = info.selectorStyleInputs[index];
+  const inputStyles = info.selectorStyleInputStyles[index];
+
+  const selectedStyles =
+    stitchArrays({input: inputs, style: inputStyles})
+      .filter(({input}) => input.checked)
+      .map(({style}) => style);
+
+  const releases = info.selectorReleaseItems[index];
+  const releaseStyles = info.selectorReleaseItemStyles[index];
+
+  let visible = 0;
+
+  stitchArrays({
+    release: releases,
+    style: releaseStyles,
+  }).forEach(({release, style}) => {
+      if (selectedStyles.includes(style)) {
+        release.classList.remove('hidden-by-style-mismatch');
+        visible++;
+      } else {
+        release.classList.add('hidden-by-style-mismatch');
+      }
+    });
+
+  const countAll = info.selectorCountAll[index];
+  const countFiltered = info.selectorCountFiltered[index];
+  const countFilteredCount = info.selectorCountFilteredCount[index];
+  const countNone = info.selectorCountNone[index];
+
+  if (visible === releases.length) {
+    cssProp(countAll, 'display', null);
+    cssProp(countFiltered, 'display', 'none');
+    cssProp(countNone, 'display', 'none');
+  } else if (visible === 0) {
+    cssProp(countAll, 'display', 'none');
+    cssProp(countFiltered, 'display', 'none');
+    cssProp(countNone, 'display', null);
+  } else {
+    cssProp(countAll, 'display', 'none');
+    cssProp(countFiltered, 'display', null);
+    cssProp(countNone, 'display', 'none');
+    countFilteredCount.innerHTML = visible;
+  }
+}
diff --git a/src/static/js/client/group-contributions-table.js b/src/static/js/client/group-contributions-table.js
new file mode 100644
index 00000000..80ee38a1
--- /dev/null
+++ b/src/static/js/client/group-contributions-table.js
@@ -0,0 +1,163 @@
+import {cssProp} from '../client-util.js';
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'groupContributionsInfo',
+
+  tables: null,
+  lists: null,
+
+  groupLinks: null,
+  groupLinkDirectories: null,
+
+  chunkDTs: null,
+  chunkDDs: null,
+  chunkGroupDirectories: null,
+
+  filterNotices: null,
+  filterNoticeClearLinks: null,
+};
+
+export function getPageReferences() {
+  if (document.documentElement.dataset.urlKey !== 'localized.artist') {
+    return;
+  }
+
+  info.tables =
+    Array.from(document.querySelectorAll('.group-contributions-table'));
+
+  info.lists =
+    info.tables
+      .map(table => table.closest('dl'));
+
+  info.groupLinks =
+    info.tables
+      .map(table => Array.from(table.querySelectorAll('td.group a')));
+
+  info.groupLinkDirectories =
+    info.groupLinks
+      .map(links => links
+        .map(link => link.dataset.directory));
+
+  info.chunkDTs =
+    info.lists
+      .map(list => Array.from(list.querySelectorAll('dt')))
+      .map(dts => dts
+        .filter(dt => !dt.classList.contains('filter-notice')));
+
+  info.chunkDDs =
+    info.chunkDTs
+      .map(dts => dts
+        .map(dt => dt.nextElementSibling)
+        .map(el => el?.tagName === 'DD' ? el : null));
+
+  info.chunkGroupDirectories =
+    info.chunkDTs
+      .map(dts => dts
+        .map(dt => dt.dataset.groups)
+        .map(string => string ? string.split(' ') : []));
+
+  info.filterNotices =
+    info.lists
+      .map(list => list.querySelector('.filter-notice'));
+
+  info.filterNoticeClearLinks =
+    info.filterNotices
+      .map(notice => notice.querySelector('a'));
+}
+
+export function addPageListeners() {
+  if (!info.tables) return;
+
+  stitchArrays({
+    table: info.tables,
+    groupLinks: info.groupLinks,
+  }).forEach(({table, groupLinks}) => {
+      groupLinks.forEach(groupLink => {
+        groupLink.addEventListener('click', domEvent => {
+          domEvent.preventDefault();
+          handleGroupLinkClicked(table, groupLink);
+        });
+      });
+    });
+
+  stitchArrays({
+    table: info.tables,
+    clearLink: info.filterNoticeClearLinks,
+  }).forEach(({table, clearLink}) => {
+      clearLink.addEventListener('click', domEvent => {
+        domEvent.preventDefault();
+        handleClearLinkClicked(table);
+      });
+    });
+}
+
+function handleGroupLinkClicked(table, groupLink) {
+  const i = info.tables.indexOf(table);
+
+  groupLink.classList.toggle('selected');
+
+  // For now, just disable having more than one link selected at a time.
+  for (const link of info.groupLinks[i]) {
+    if (link !== groupLink) {
+      link.classList.remove('selected');
+    }
+  }
+
+  updateVisibleChunks(table);
+}
+
+function handleClearLinkClicked(table) {
+  const i = info.tables.indexOf(table);
+
+  for (const link of info.groupLinks[i]) {
+    link.classList.remove('selected');
+  }
+
+  updateVisibleChunks(table);
+}
+
+function updateVisibleChunks(table) {
+  const i = info.tables.indexOf(table);
+
+  const selectedGroupDirectories =
+    stitchArrays({
+      link: info.groupLinks[i],
+      directory: info.groupLinkDirectories[i],
+    }).filter(({link}) => link.classList.contains('selected'))
+      .map(({directory}) => directory);
+
+  stitchArrays({
+    chunkDT: info.chunkDTs[i],
+    chunkDD: info.chunkDDs[i],
+    chunkGroupDirectories: info.chunkGroupDirectories[i],
+  }).forEach(({
+      chunkDT,
+      chunkDD,
+      chunkGroupDirectories,
+    }) => {
+      if (selectedGroupDirectories.length >= 1) {
+        const included =
+          chunkGroupDirectories
+            .some(d => selectedGroupDirectories.includes(d));
+
+        if (included) {
+          cssProp(chunkDT, 'display', null);
+          cssProp(chunkDD, 'display', null);
+        } else {
+          cssProp(chunkDT, 'display', 'none');
+          cssProp(chunkDD, 'display', 'none');
+        }
+      } else {
+        cssProp(chunkDT, 'display', null);
+        cssProp(chunkDD, 'display', null);
+      }
+    });
+
+  const filterNotice = info.filterNotices[i];
+  if (selectedGroupDirectories.length >= 1) {
+    cssProp(filterNotice, 'display', null);
+  } else {
+    cssProp(filterNotice, 'display', 'none');
+  }
+}
diff --git a/src/static/js/client/hash-link.js b/src/static/js/client/hash-link.js
index 27035e29..02ffdc23 100644
--- a/src/static/js/client/hash-link.js
+++ b/src/static/js/client/hash-link.js
@@ -1,6 +1,5 @@
-/* eslint-env browser */
-
-import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js';
+import {filterMultipleArrays, stitchArrays, unique}
+  from '../../shared-util/sugar.js';
 
 import {dispatchInternalEvent} from '../client-util.js';
 
@@ -11,6 +10,9 @@ export const info = {
   hrefs: null,
   targets: null,
 
+  details: null,
+  detailsIDs: null,
+
   state: {
     highlightedTarget: null,
     scrollingAfterClick: false,
@@ -40,6 +42,19 @@ export function getPageReferences() {
     info.hrefs,
     info.targets,
     (_link, _href, target) => target);
+
+  info.details =
+    unique([
+      ...document.querySelectorAll('details[id]'),
+      ...
+        Array.from(document.querySelectorAll('summary[id]'))
+          .map(summary => summary.closest('details')),
+    ]);
+
+  info.detailsIDs =
+    info.details.map(details =>
+      details.id ||
+      details.querySelector('summary').id);
 }
 
 function processScrollingAfterHashLinkClicked() {
@@ -60,6 +75,15 @@ function processScrollingAfterHashLinkClicked() {
   }, 200);
 }
 
+export function mutatePageContent() {
+  if (location.hash.length > 1) {
+    const target = document.getElementById(location.hash.slice(1));
+    if (target) {
+      expandDetails(target);
+    }
+  }
+}
+
 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'.
@@ -94,6 +118,8 @@ export function addPageListeners() {
         return;
       }
 
+      expandDetails(target);
+
       // Hide skipper box right away, so the layout is updated on time for the
       // math operations coming up next.
       const skipper = document.getElementById('skippers');
@@ -143,4 +169,32 @@ export function addPageListeners() {
       state.highlightedTarget = null;
     });
   }
+
+  stitchArrays({
+    details: info.details,
+    id: info.detailsIDs,
+  }).forEach(({details, id}) => {
+      details.addEventListener('toggle', () => {
+        if (!details.open) {
+          detractHash(id);
+        }
+      });
+    });
+}
+
+function expandDetails(target) {
+  if (target.nodeName === 'SUMMARY') {
+    const details = target.closest('details');
+    if (details) {
+      details.open = true;
+    }
+  } else if (target.nodeName === 'DETAILS') {
+    target.open = true;
+  }
+}
+
+function detractHash(id) {
+  if (location.hash === '#' + id) {
+    history.pushState({}, undefined, location.href.replace(/#.*$/, ''));
+  }
 }
diff --git a/src/static/js/client/hoverable-tooltip.js b/src/static/js/client/hoverable-tooltip.js
index 484f2ab0..e0c74747 100644
--- a/src/static/js/client/hoverable-tooltip.js
+++ b/src/static/js/client/hoverable-tooltip.js
@@ -1,5 +1,3 @@
-/* eslint-env browser */
-
 import {empty, filterMultipleArrays} from '../../shared-util/sugar.js';
 
 import {WikiRect} from '../rectangles.js';
@@ -8,7 +6,6 @@ import {
   cssProp,
   dispatchInternalEvent,
   getVisuallyContainingElement,
-  pointIsOverAnyOf,
 } from '../client-util.js';
 
 import {info as stickyHeadingInfo} from './sticky-heading.js';
@@ -118,17 +115,17 @@ export function registerTooltipElement(tooltip) {
     handleTooltipMouseLeft(tooltip);
   });
 
-  tooltip.addEventListener('focusin', event => {
-    handleTooltipReceivedFocus(tooltip, event.relatedTarget);
+  tooltip.addEventListener('focusin', domEvent => {
+    handleTooltipReceivedFocus(tooltip, domEvent.relatedTarget);
   });
 
-  tooltip.addEventListener('focusout', event => {
+  tooltip.addEventListener('focusout', domEvent => {
     // 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;
+    if (currentlyShownTooltipHasFocus(domEvent.relatedTarget)) return;
 
-    handleTooltipLostFocus(tooltip, event.relatedTarget);
+    handleTooltipLostFocus(tooltip, domEvent.relatedTarget);
   });
 }
 
@@ -158,24 +155,31 @@ export function registerTooltipHoverableElement(hoverable, tooltip) {
     handleTooltipHoverableMouseLeft(hoverable);
   });
 
-  hoverable.addEventListener('focusin', event => {
-    handleTooltipHoverableReceivedFocus(hoverable, event);
+  hoverable.addEventListener('focusin', domEvent => {
+    handleTooltipHoverableReceivedFocus(hoverable, domEvent);
   });
 
-  hoverable.addEventListener('focusout', event => {
-    handleTooltipHoverableLostFocus(hoverable, event);
+  hoverable.addEventListener('focusout', domEvent => {
+    handleTooltipHoverableLostFocus(hoverable, domEvent);
   });
 
-  hoverable.addEventListener('touchend', event => {
-    handleTooltipHoverableTouchEnded(hoverable, event);
+  hoverable.addEventListener('touchend', domEvent => {
+    handleTooltipHoverableTouchEnded(hoverable, domEvent);
   });
 
-  hoverable.addEventListener('click', event => {
-    handleTooltipHoverableClicked(hoverable, event);
+  hoverable.addEventListener('click', domEvent => {
+    handleTooltipHoverableClicked(hoverable, domEvent);
   });
 }
 
 function handleTooltipMouseEntered(tooltip) {
+  // NOTE: This function is NOT NATURALLY CALLED on iOS Safari.
+  // Elements generally don't receive mouse events there at all - hoverables
+  // are the exception (we have not identified exactly why). We do however
+  // mock calling this function. However, because we mock the event and do so
+  // without any special awareness, this function may be called multiple times
+  // in sequence, without the tooltip ever receiving a mouseleave event.
+
   const {state} = info;
 
   if (state.currentlyTransitioningHiddenTooltip) {
@@ -194,6 +198,9 @@ function handleTooltipMouseEntered(tooltip) {
 }
 
 function handleTooltipMouseLeft(tooltip) {
+  // NOTE: This function is NOT NATURALLY CALLED on iOS Safari.
+  // We don't mock it there, either.
+
   const {settings, state} = info;
 
   if (state.currentlyShownTooltip !== tooltip) return;
@@ -340,7 +347,10 @@ function handleTooltipHoverableLostFocus(hoverable, domEvent) {
   // 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)) {
+  if (
+    domEvent.relatedTarget &&
+    !currentlyShownTooltipHasFocus(domEvent.relatedTarget)
+  ) {
     hideCurrentlyShownTooltip(true);
   }
 }
@@ -386,11 +396,14 @@ function handleTooltipHoverableTouchEnded(hoverable, domEvent) {
   // 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));
+  let anyTouchEndedOverHoverable = false;
+  for (const touch of touches) {
+    const point = WikiRect.fromPoint(touch.clientX, touch.clientY);
+    if (WikiRect.fromElementContaining(hoverable, point)) {
+      anyTouchEndedOverHoverable = true;
+      break;
+    }
+  }
 
   if (!anyTouchEndedOverHoverable) {
     return;
@@ -416,7 +429,7 @@ function handleTooltipHoverableTouchEnded(hoverable, domEvent) {
     }, 1200);
 }
 
-function handleTooltipHoverableClicked(hoverable) {
+function handleTooltipHoverableClicked(hoverable, domEvent) {
   const {state} = info;
 
   // Don't navigate away from the page if the this hoverable was recently
@@ -426,7 +439,7 @@ function handleTooltipHoverableClicked(hoverable) {
     state.currentlyActiveHoverable === hoverable &&
     state.hoverableWasRecentlyTouched
   ) {
-    event.preventDefault();
+    domEvent.preventDefault();
   }
 }
 
@@ -576,6 +589,17 @@ export function showTooltipFromHoverable(hoverable) {
 
   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');
@@ -647,7 +671,22 @@ export function positionTooltipFromHoverableWithBrains(hoverable) {
   const {numBaselineRects, idealBaseline: baselineRect} = opportunities;
 
   if (baselineRect.contains(tooltipRect)) {
-    return;
+    // ...unless hovering over a rectangle besides the hoverable's first.
+    // An element has multiple rectangles if it's an inline element that
+    // has wrapped across to the next line.
+
+    const hoverableClientRects =
+      Array.from(hoverable.getClientRects())
+        .map(rect => WikiRect.fromRect(rect));
+
+    const mouseRect = WikiRect.fromMouse();
+
+    const hoverableClientRectIndex =
+      hoverableClientRects.findIndex(rect => rect.contains(mouseRect));
+
+    if (hoverableClientRectIndex <= 0) {
+      return;
+    }
   }
 
   const tryDirection = (dir1, dir2, i) => {
@@ -667,12 +706,12 @@ export function positionTooltipFromHoverableWithBrains(hoverable) {
 
     for (let i = 0; i < numBaselineRects; i++) {
       for (const [dir1, dir2] of [
+        ['down', 'right'],
+        ['down', 'left'],
         ['right', 'down'],
         ['left', 'down'],
         ['right', 'up'],
         ['left', 'up'],
-        ['down', 'right'],
-        ['down', 'left'],
         ['up', 'right'],
         ['up', 'left'],
       ]) {
@@ -705,6 +744,8 @@ export function positionTooltip(tooltip, x, y) {
   cssProp(tooltip, {
     left: `${x - tooltipRect.x}px`,
     top: `${y - tooltipRect.y}px`,
+    right: 'unset',
+    bottom: 'unset',
   });
 }
 
@@ -712,6 +753,8 @@ export function resetDynamicTooltipPositioning(tooltip) {
   cssProp(tooltip, {
     left: null,
     top: null,
+    right: null,
+    bottom: null,
   });
 }
 
@@ -722,8 +765,12 @@ export function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) {
   const baselineRects =
     getTooltipBaselineOpportunityAreas(tooltip);
 
+  const basicHoverableRect =
+    WikiRect.fromElementUnderMouse(hoverable) ??
+    WikiRect.fromRect(hoverable.getClientRects()[0]);
+
   const hoverableRect =
-    WikiRect.fromElementUnderMouse(hoverable).toExtended(5, 10);
+    basicHoverableRect.toExtended(5, 10);
 
   const tooltipRect =
     peekTooltipClientRect(tooltip);
@@ -850,7 +897,7 @@ export function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) {
 
   const rightRightLeft =
     WikiRect.leftOf(
-      hoverableRect.left - neededHorizontalOverlap + tooltipRect.width);
+      hoverableRect.left + tooltipRect.width);
 
   const leftLeftRight =
     WikiRect.rightOf(
@@ -995,6 +1042,14 @@ export function getTooltipBaselineOpportunityAreas(tooltip) {
   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;
 
@@ -1024,10 +1079,14 @@ export function addPageListeners() {
     });
   });
 
-  const getHoverablesAndTooltips = () => [
-    ...Array.from(state.registeredHoverables.keys()),
-    ...Array.from(state.registeredTooltips.keys()),
-  ];
+  const getHoverables = () =>
+    Array.from(state.registeredHoverables.keys());
+
+  const getTooltips = () =>
+    Array.from(state.registeredTooltips.keys());
+
+  const getHoverablesAndTooltips = () =>
+    [...getHoverables(), ...getTooltips()];
 
   document.body.addEventListener('touchend', domEvent => {
     const touches = Array.from(domEvent.changedTouches);
@@ -1042,12 +1101,26 @@ export function addPageListeners() {
 
     if (empty(touches)) return;
 
-    const pointIsOverHoverableOrTooltip =
-      pointIsOverAnyOf(getHoverablesAndTooltips());
+    let anyTouchOverAnyHoverableOrTooltip = false;
+    for (const touch of touches) {
+      const point = WikiRect.fromPoint(touch.clientX, touch.clientY);
 
-    const anyTouchOverAnyHoverableOrTooltip =
-      touches.some(({clientX, clientY}) =>
-        pointIsOverHoverableOrTooltip(clientX, clientY));
+      for (const hoverable of getHoverables()) {
+        if (WikiRect.fromElementContaining(hoverable, point)) {
+          anyTouchOverAnyHoverableOrTooltip = true;
+        }
+      }
+
+      for (const tooltip of getTooltips()) {
+        if (WikiRect.fromElementContaining(tooltip, point)) {
+          anyTouchOverAnyHoverableOrTooltip = true;
+
+          setTimeout(() => {
+            handleTooltipMouseEntered(tooltip);
+          }, 200);
+        }
+      }
+    }
 
     if (!anyTouchOverAnyHoverableOrTooltip) {
       hideCurrentlyShownTooltip();
@@ -1055,12 +1128,17 @@ export function addPageListeners() {
   });
 
   document.body.addEventListener('click', domEvent => {
-    const {clientX, clientY} = domEvent;
+    const point = WikiRect.fromPoint(domEvent.clientX, domEvent.clientY);
 
-    const pointIsOverHoverableOrTooltip =
-      pointIsOverAnyOf(getHoverablesAndTooltips());
+    let pointIsOverHoverableOrTooltip = false;
+    for (const element of getHoverablesAndTooltips()) {
+      if (WikiRect.fromElementContaining(element, point)) {
+        pointIsOverHoverableOrTooltip = true;
+        break;
+      }
+    }
 
-    if (!pointIsOverHoverableOrTooltip(clientX, clientY)) {
+    if (!pointIsOverHoverableOrTooltip) {
       // 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,
diff --git a/src/static/js/client/image-overlay.js b/src/static/js/client/image-overlay.js
index da192178..0595bff7 100644
--- a/src/static/js/client/image-overlay.js
+++ b/src/static/js/client/image-overlay.js
@@ -1,5 +1,3 @@
-/* eslint-env browser */
-
 import {getColors} from '../../shared-util/colors.js';
 
 import {cssProp} from '../client-util.js';
@@ -96,7 +94,10 @@ function handleContainerClicked(evt) {
   // 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) {
+  if (
+    evt.clientY >= rect.top - 40 && evt.clientY <= rect.bottom + 40 &&
+    evt.clientX >= rect.left + 20 && evt.clientX <= rect.right - 20
+  ) {
     return;
   }
 
@@ -146,7 +147,8 @@ function getImageLinkDetails(imageLink) {
       a.href,
 
     embeddedSrc:
-      img?.src ??
+      img?.src ||
+      img?.currentSrc ||
       a.dataset.embedSrc,
 
     originalFileSize:
diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js
index 81ea3415..a438d6d8 100644
--- a/src/static/js/client/index.js
+++ b/src/static/js/client/index.js
@@ -1,25 +1,25 @@
-/* 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 artistRollingWindowModule from './artist-rolling-window.js';
 import * as datetimestampTooltipModule from './datetimestamp-tooltip.js';
 import * as draggedLinkModule from './dragged-link.js';
+import * as expandableGridSectionModule from './expandable-grid-section.js';
+import * as galleryStyleSelectorModule from './gallery-style-selector.js';
+import * as groupContributionsTableModule from './group-contributions-table.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 memorableDetailsModule from './memorable-details.js';
 import * as quickDescriptionModule from './quick-description.js';
+import * as revealAllGridControlModule from './reveal-all-grid-control.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';
 
@@ -29,26 +29,34 @@ export const modules = [
   artTagGalleryFilterModule,
   artTagNetworkModule,
   artistExternalLinkTooltipModule,
-  cssCompatibilityAssistantModule,
+  artistRollingWindowModule,
   datetimestampTooltipModule,
   draggedLinkModule,
+  expandableGridSectionModule,
+  galleryStyleSelectorModule,
+  groupContributionsTableModule,
   hashLinkModule,
   hoverableTooltipModule,
   imageOverlayModule,
   intrapageDotSwitcherModule,
   liveMousePositionModule,
+  memorableDetailsModule,
   quickDescriptionModule,
+  revealAllGridControlModule,
   scriptedLinkModule,
   sidebarSearchModule,
   stickyHeadingModule,
-  summaryNestedLinkModule,
   textWithTooltipModule,
   wikiSearchModule,
 ];
 
 const clientInfo = window.hsmusicClientInfo = Object.create(null);
 
-const clientSteps = {
+// These steps are always run in the listed order, on page load.
+// So for example, all modules' getPageReferences steps are evaluated, then
+// all modules' addInternalListeners steps are evaluated, and so on.
+const setupSteps = {
+  bindSessionStorage: [],
   getPageReferences: [],
   addInternalListeners: [],
   mutatePageContent: [],
@@ -56,6 +64,18 @@ const clientSteps = {
   addPageListeners: [],
 };
 
+// These steps are run only on certain triggers. Those are global events,
+// so all modules (which specify that step) respond in sequence.
+const situationalSteps = {
+  /* There's none yet... sorry... */
+};
+
+const stepInfoSymbol = Symbol();
+
+const boundSessionStorage =
+  window.hsmusicBoundSessionStorage =
+  Object.create(null);
+
 for (const module of modules) {
   const {info} = module;
 
@@ -110,7 +130,7 @@ for (const module of modules) {
             break;
 
           case 'boolean':
-            formatRead = Boolean;
+            formatRead = value => value === 'true' ? true : false;
             formatWrite = String;
             break;
 
@@ -140,12 +160,47 @@ for (const module of modules) {
 
       const storageKey = `hsmusic.${infoKey}.${key}`;
 
+      // There are two storage systems besides actual session storage in play.
+      //
+      // "Fallback" is for if session storage is not available, which may
+      // suddenly become the case, i.e. access is temporarily revoked or fails.
+      // The fallback value is controlled completely internally i.e. in this
+      // infrastructure, in this lexical scope.
+      //
+      // "Bound" is for if the value kept in session storage was saved to
+      // the page when the page was initially loaded, rather than a living
+      // window on session storage (which may be affected by pages later in
+      // the history stack). Whether or not bound storage is in effect is
+      // controlled at page load (of course), by each module's own logic.
+      //
+      // Asterisk: Bound storage can't work miracles and if the page is
+      // actually deloaded with its JavaScript state discarded, the bound
+      // values are lost, even if the browser recreates on-page form state.
+
       let fallbackValue = defaultValue;
+      let boundValue = undefined;
+
+      const updateBoundValue = (givenValue = undefined) => {
+        if (givenValue) {
+          if (
+            infoKey in boundSessionStorage &&
+            key in boundSessionStorage[infoKey]
+          ) {
+            boundSessionStorage[infoKey][key] = givenValue;
+          }
+        } else {
+          boundValue = boundSessionStorage[infoKey]?.[key];
+        }
+      };
 
       Object.defineProperty(info.session, key, {
         get: () => {
+          updateBoundValue();
+
           let value;
-          try {
+          if (boundValue !== undefined) {
+            value = boundValue ?? defaultValue;
+          } else try {
             value = sessionStorage.getItem(storageKey) ?? defaultValue;
           } catch (error) {
             if (error instanceof DOMException) {
@@ -181,21 +236,23 @@ for (const module of modules) {
             return;
           }
 
-          let operation;
+          let sessionOperation;
           if (value === '') {
             fallbackValue = null;
-            operation = () => {
+            updateBoundValue(null);
+            sessionOperation = () => {
               sessionStorage.removeItem(storageKey);
             };
           } else {
             fallbackValue = value;
-            operation = () => {
+            updateBoundValue(value);
+            sessionOperation = () => {
               sessionStorage.setItem(storageKey, value);
             };
           }
 
           try {
-            operation();
+            sessionOperation();
           } catch (error) {
             if (!(error instanceof DOMException)) {
               throw error;
@@ -208,28 +265,72 @@ for (const module of modules) {
     Object.preventExtensions(info.session);
   }
 
-  for (const key of Object.keys(clientSteps)) {
-    if (Object.hasOwn(module, key)) {
-      const fn = module[key];
+  for (const stepsObject of [setupSteps, situationalSteps]) {
+    for (const key of Object.keys(stepsObject)) {
+      if (Object.hasOwn(module, key)) {
+        const fn = module[key];
 
-      Object.defineProperty(fn, 'name', {
-        value: `${infoKey}/${fn.name}`,
-      });
+        fn[stepInfoSymbol] = info;
+
+        Object.defineProperty(fn, 'name', {
+          value: `${infoKey}/${fn.name}`,
+        });
+
+        stepsObject[key].push(fn);
+      }
+    }
+  }
+}
+
+function evaluateBindSessionStorageStep(bindSessionStorage) {
+  const {id: infoKey, session: moduleExposedSessionObject} =
+    bindSessionStorage[stepInfoSymbol];
+
+  const generator = bindSessionStorage();
+
+  let lastBoundValue;
+  while (true) {
+    const {value: key, done} = generator.next(lastBoundValue);
+    const storageKey = `hsmusic.${infoKey}.${key}`;
+
+    let value = undefined;
+    try {
+      value = sessionStorage.getItem(storageKey);
+    } catch (error) {
+      if (!(error instanceof DOMException)) {
+        throw error;
+      }
+    }
 
-      clientSteps[key].push(fn);
+    if (value === undefined) {
+      // This effectively gets the default value.
+      value = moduleExposedSessionObject[key];
     }
+
+    boundSessionStorage[infoKey] ??= Object.create(null);
+    boundSessionStorage[infoKey][key] = value;
+
+    lastBoundValue = value;
+
+    if (done) break;
   }
 }
 
-for (const [key, steps] of Object.entries(clientSteps)) {
-  for (const step of steps) {
+function evaluateStep(stepsObject, key) {
+  for (const step of stepsObject[key]) {
     try {
-      step();
+      if (key === 'bindSessionStorage') {
+        evaluateBindSessionStorageStep(step);
+      } else {
+        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);
     }
   }
 }
+
+for (const key of Object.keys(setupSteps)) {
+  evaluateStep(setupSteps, key);
+}
diff --git a/src/static/js/client/intrapage-dot-switcher.js b/src/static/js/client/intrapage-dot-switcher.js
index d06bc5a6..b9a27a9b 100644
--- a/src/static/js/client/intrapage-dot-switcher.js
+++ b/src/static/js/client/intrapage-dot-switcher.js
@@ -1,5 +1,3 @@
-/* eslint-env browser */
-
 import {stitchArrays} from '../../shared-util/sugar.js';
 
 import {cssProp} from '../client-util.js';
diff --git a/src/static/js/client/live-mouse-position.js b/src/static/js/client/live-mouse-position.js
index 36a28429..32fc5bf4 100644
--- a/src/static/js/client/live-mouse-position.js
+++ b/src/static/js/client/live-mouse-position.js
@@ -1,5 +1,3 @@
-/* eslint-env browser */
-
 export const info = {
   id: 'liveMousePositionInfo',
 
diff --git a/src/static/js/client/memorable-details.js b/src/static/js/client/memorable-details.js
new file mode 100644
index 00000000..57d9fde8
--- /dev/null
+++ b/src/static/js/client/memorable-details.js
@@ -0,0 +1,62 @@
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'memorableDetailsInfo',
+
+  details: null,
+  ids: null,
+
+  session: {
+    openDetails: {
+      type: 'json',
+      maxLength: settings => settings.maxOpenDetailsStorage,
+    },
+  },
+
+  settings: {
+    maxOpenDetailsStorage: 1000,
+  },
+};
+
+export function getPageReferences() {
+  info.details =
+    Array.from(document.querySelectorAll('details.memorable'));
+
+  info.ids =
+    info.details.map(details => details.getAttribute('data-memorable-id'));
+}
+
+export function mutatePageContent() {
+  stitchArrays({
+    details: info.details,
+    id: info.ids,
+  }).forEach(({details, id}) => {
+      if (info.session.openDetails?.includes(id)) {
+        details.open = true;
+      }
+    });
+}
+
+export function addPageListeners() {
+  for (const [index, details] of info.details.entries()) {
+    details.addEventListener('toggle', () => {
+      handleDetailsToggled(index);
+    });
+  }
+}
+
+function handleDetailsToggled(index) {
+  const details = info.details[index];
+  const id = info.ids[index];
+
+  if (details.open) {
+    if (info.session.openDetails) {
+      info.session.openDetails = [...info.session.openDetails, id];
+    } else {
+      info.session.openDetails = [id];
+    }
+  } else if (info.session.openDetails?.includes(id)) {
+    info.session.openDetails =
+      info.session.openDetails.filter(item => item !== id);
+  }
+}
diff --git a/src/static/js/client/quick-description.js b/src/static/js/client/quick-description.js
index cff82252..9117d48c 100644
--- a/src/static/js/client/quick-description.js
+++ b/src/static/js/client/quick-description.js
@@ -1,5 +1,3 @@
-/* eslint-env browser */
-
 import {stitchArrays} from '../../shared-util/sugar.js';
 
 export const info = {
diff --git a/src/static/js/client/reveal-all-grid-control.js b/src/static/js/client/reveal-all-grid-control.js
new file mode 100644
index 00000000..0572a190
--- /dev/null
+++ b/src/static/js/client/reveal-all-grid-control.js
@@ -0,0 +1,70 @@
+import {cssProp} from '../client-util.js';
+
+export const info = {
+  id: 'revealAllGridControlInfo',
+
+  revealAllLinks: null,
+  revealables: null,
+
+  revealLabels: null,
+  concealLabels: null,
+};
+
+export function getPageReferences() {
+  info.revealAllLinks =
+    Array.from(document.querySelectorAll('.reveal-all a'));
+
+  info.revealables =
+    info.revealAllLinks
+      .map(link => link.closest('.grid-listing'))
+      .map(listing => listing.querySelectorAll('.reveal'));
+
+  info.revealLabels =
+    info.revealAllLinks
+      .map(link => link.querySelector('.reveal-label'));
+
+  info.concealLabels =
+    info.revealAllLinks
+      .map(link => link.querySelector('.conceal-label'));
+}
+
+export function addPageListeners() {
+  for (const [index, link] of info.revealAllLinks.entries()) {
+    link.addEventListener('click', domEvent => {
+      domEvent.preventDefault();
+      handleRevealAllLinkClicked(index);
+    });
+  }
+}
+
+export function addInternalListeners() {
+  // Don't even think about it. "Reveal all artworks" is a stable control,
+  // meaning it only changes because the user interacted with it directly.
+}
+
+function handleRevealAllLinkClicked(index) {
+  const revealables = info.revealables[index];
+  const revealLabel = info.revealLabels[index];
+  const concealLabel = info.concealLabels[index];
+
+  const shouldReveal =
+    (cssProp(revealLabel, 'display') === 'none'
+      ? false
+      : true);
+
+  for (const revealable of revealables) {
+    if (shouldReveal) {
+      revealable.classList.add('revealed');
+    } else {
+      revealable.classList.remove('revealed');
+    }
+  }
+
+  if (shouldReveal) {
+    cssProp(revealLabel, 'display', 'none');
+    cssProp(concealLabel, 'display', null);
+  } else {
+    cssProp(revealLabel, 'display', null);
+    cssProp(concealLabel, 'display', 'none');
+  }
+}
diff --git a/src/static/js/client/scripted-link.js b/src/static/js/client/scripted-link.js
index 8b8d8a13..badc6ccb 100644
--- a/src/static/js/client/scripted-link.js
+++ b/src/static/js/client/scripted-link.js
@@ -1,5 +1,3 @@
-/* eslint-env browser */
-
 import {pick, stitchArrays} from '../../shared-util/sugar.js';
 
 import {
diff --git a/src/static/js/client/sidebar-search.js b/src/static/js/client/sidebar-search.js
index fb902636..61a33c0d 100644
--- a/src/static/js/client/sidebar-search.js
+++ b/src/static/js/client/sidebar-search.js
@@ -1,16 +1,22 @@
-/* eslint-env browser */
-
 import {getColors} from '../../shared-util/colors.js';
-import {accumulateSum, empty} from '../../shared-util/sugar.js';
+
+import {
+  accumulateSum,
+  compareArrays,
+  empty,
+  unique,
+} from '../../shared-util/sugar.js';
 
 import {
   cssProp,
+  decodeEntities,
   openAlbum,
   openArtist,
   openArtTag,
   openFlash,
   openGroup,
   openTrack,
+  openVGMAlbum,
   rebase,
   templateContent,
 } from '../client-util.js';
@@ -33,6 +39,9 @@ export const info = {
   searchLabel: null,
   searchInput: null,
 
+  contextContainer: null,
+  contextBackLink: null,
+
   progressRule: null,
   progressContainer: null,
   progressLabel: null,
@@ -41,6 +50,14 @@ export const info = {
   failedRule: null,
   failedContainer: null,
 
+  filterContainer: null,
+  albumFilterLink: null,
+  artistFilterLink: null,
+  flashFilterLink: null,
+  groupFilterLink: null,
+  tagFilterLink: null,
+  trackFilterLink: null,
+
   resultsRule: null,
   resultsContainer: null,
   results: null,
@@ -60,10 +77,27 @@ export const info = {
   currentResultString: null,
   endSearchString: null,
 
+  backString: null,
+
   albumResultKindString: null,
   artistResultKindString: null,
+  flashResultKindString: null,
   groupResultKindString: null,
+  singleResultKindString: null,
   tagResultKindString: null,
+  vgmAlbumResultKindString: null,
+
+  groupResultDisambiguatorString: null,
+  flashResultDisambiguatorString: null,
+  trackResultDisambiguatorString1: null,
+  trackResultDisambiguatorString2: null,
+
+  albumResultFilterString: null,
+  artistResultFilterString: null,
+  flashResultFilterString: null,
+  groupResultFilterString: null,
+  tagResultFilterString: null,
+  trackResultFilterString: null,
 
   state: {
     sidebarColumnShownForSearch: null,
@@ -74,6 +108,8 @@ export const info = {
     recallingRecentSearch: null,
     recallingRecentSearchFromMouse: null,
 
+    justPerformedActiveQuery: false,
+
     currentValue: null,
 
     workerStatus: null,
@@ -92,11 +128,20 @@ export const info = {
       type: 'string',
     },
 
+    activeQueryContextPageName: {type: 'string'},
+    activeQueryContextPagePathname: {type: 'string'},
+    activeQueryContextPageColor: {type: 'string'},
+    zapActiveQueryContext: {type: 'boolean'},
+
     activeQueryResults: {
       type: 'json',
       maxLength: settings => settings.maxActiveResultsStorage,
     },
 
+    activeFilterType: {
+      type: 'string',
+    },
+
     repeatQueryOnReload: {
       type: 'boolean',
       default: false,
@@ -118,6 +163,19 @@ export const info = {
   },
 };
 
+export function* bindSessionStorage() {
+  if (yield 'activeQuery') {
+    yield 'activeQueryContextPageName';
+    yield 'activeQueryContextPagePathname';
+    yield 'activeQueryContextPageColor';
+    yield 'zapActiveQueryContext';
+
+    yield 'activeQueryResults';
+    yield 'activeFilterType';
+    yield 'resultsScrollOffset';
+  }
+}
+
 export function getPageReferences() {
   info.pageContainer =
     document.getElementById('page-container');
@@ -159,6 +217,9 @@ export function getPageReferences() {
   info.noResultsString =
     findString('no-results');
 
+  info.backString =
+    findString('back');
+
   info.currentResultString =
     findString('current-result');
 
@@ -171,11 +232,50 @@ export function getPageReferences() {
   info.artistResultKindString =
     findString('artist-result-kind');
 
+  info.flashResultKindString =
+    findString('flash-result-kind');
+
   info.groupResultKindString =
     findString('group-result-kind');
 
+  info.singleResultKindString =
+    findString('single-result-kind');
+
   info.tagResultKindString =
     findString('tag-result-kind');
+
+  info.vgmAlbumResultKindString =
+    findString('vgm-album-result-kind');
+
+  info.groupResultDisambiguatorString =
+    findString('group-result-disambiguator');
+
+  info.flashResultDisambiguatorString =
+    findString('flash-result-disambiguator');
+
+  info.trackResultDisambiguatorString1 =
+    findString('track-result-album-disambiguator');
+
+  info.trackResultDisambiguatorString2 =
+    findString('track-result-artist-disambiguator');
+
+  info.albumResultFilterString =
+    findString('album-result-filter');
+
+  info.artistResultFilterString =
+    findString('artist-result-filter');
+
+  info.flashResultFilterString =
+    findString('flash-result-filter');
+
+  info.groupResultFilterString =
+    findString('group-result-filter');
+
+  info.tagResultFilterString =
+    findString('tag-result-filter');
+
+  info.trackResultFilterString =
+    findString('track-result-filter');
 }
 
 export function addInternalListeners() {
@@ -212,6 +312,25 @@ export function addInternalListeners() {
 export function mutatePageContent() {
   if (!info.searchBox) return;
 
+  // Context section
+
+  info.contextContainer =
+    document.createElement('div');
+
+  info.contextContainer.classList.add('wiki-search-context-container');
+
+  info.contextBackLink =
+    document.createElement('a');
+
+  info.contextContainer.appendChild(
+    templateContent(info.backString, {
+      page: info.contextBackLink,
+    }));
+
+  cssProp(info.contextContainer, 'display', 'none');
+
+  info.searchBox.appendChild(info.contextContainer);
+
   // Progress section
 
   info.progressRule =
@@ -265,6 +384,38 @@ export function mutatePageContent() {
   info.searchBox.appendChild(info.failedRule);
   info.searchBox.appendChild(info.failedContainer);
 
+  // Filter section
+
+  info.filterContainer =
+    document.createElement('div');
+
+  info.filterContainer.classList.add('wiki-search-filter-container');
+
+  cssProp(info.filterContainer, 'display', 'none');
+
+  forEachFilter((type, _filterLink) => {
+    // TODO: It's probably a sin to access `session` during this step LOL
+    const {session} = info;
+
+    const filterLink = document.createElement('a');
+
+    filterLink.href = '#';
+    filterLink.classList.add('wiki-search-filter-link');
+
+    if (session.activeFilterType === type) {
+      filterLink.classList.add('active');
+    }
+
+    const string = info[type + 'ResultFilterString'];
+    filterLink.appendChild(templateContent(string));
+
+    info[type + 'FilterLink'] = filterLink;
+
+    info.filterContainer.appendChild(filterLink);
+  });
+
+  info.searchBox.appendChild(info.filterContainer);
+
   // Results section
 
   info.resultsRule =
@@ -313,6 +464,12 @@ export function mutatePageContent() {
 
   info.searchBox.appendChild(info.endSearchRule);
   info.searchBox.appendChild(info.endSearchLine);
+
+  // Accommodate the web browser reconstructing the search input with a value
+  // that was previously entered (or restored after recall), i.e. because
+  // the user is traversing very far back in history and yet the browser is
+  // trying to rebuild the page as-was anyway, by telling it "no don't".
+  info.searchInput.value = '';
 }
 
 export function addPageListeners() {
@@ -371,7 +528,7 @@ export function addPageListeners() {
     const {settings, state} = info;
 
     if (!info.searchInput.value) {
-      clearSidebarSearch();
+      clearSidebarSearch(); // ...but don't clear filter
       return;
     }
 
@@ -433,8 +590,15 @@ export function addPageListeners() {
   info.endSearchLink.addEventListener('click', domEvent => {
     domEvent.preventDefault();
     clearSidebarSearch();
+    clearSidebarFilter();
     possiblyHideSearchSidebarColumn();
-    restoreSidebarSearchColumn();
+  });
+
+  forEachFilter((type, filterLink) => {
+    filterLink.addEventListener('click', domEvent => {
+      domEvent.preventDefault();
+      toggleSidebarSearchFilter(type);
+    });
   });
 
   info.resultsContainer.addEventListener('scroll', () => {
@@ -449,6 +613,22 @@ export function addPageListeners() {
         saveSidebarSearchResultsScrollOffset();
       }, settings.stoppedScrollingDelay);
   });
+
+  document.addEventListener('keypress', domEvent => {
+    const {tagName} = document.activeElement ?? {};
+    if (tagName === 'INPUT' || tagName === 'TEXTAREA') {
+      return;
+    }
+
+    if (domEvent.shiftKey && domEvent.code === 'Slash') {
+      if (domEvent.ctrlKey || domEvent.metaKey) {
+        return;
+      }
+
+      domEvent.preventDefault();
+      info.searchLabel.click();
+    }
+  });
 }
 
 export function initializeState() {
@@ -518,6 +698,21 @@ function trackSidebarSearchDownloadEnds(event) {
   }
 }
 
+function forEachFilter(callback) {
+  const filterOrder = [
+    'track',
+    'album',
+    'artist',
+    'group',
+    'flash',
+    'tag',
+  ];
+
+  for (const type of filterOrder) {
+    callback(type, info[type + 'FilterLink']);
+  }
+}
+
 async function activateSidebarSearch(query) {
   const {session, state} = info;
 
@@ -546,9 +741,12 @@ async function activateSidebarSearch(query) {
     return;
   }
 
+  state.justPerformedActiveQuery = true;
   state.searchStage = 'complete';
   updateSidebarSearchStatus();
 
+  recordActiveQueryContext();
+
   session.activeQuery = query;
   session.activeQueryResults = results;
   session.resultsScrollOffset = 0;
@@ -562,8 +760,41 @@ async function activateSidebarSearch(query) {
   }
 }
 
+function recordActiveQueryContext() {
+  const {session} = info;
+
+  if (document.documentElement.dataset.urlKey === 'localized.home') {
+    session.activeQueryContextPageName = null;
+    session.activeQueryContextPagePathname = null;
+    session.activeQueryContextPageColor = null;
+    session.zapActiveQueryContext = true;
+    return;
+  }
+
+  // Zapping means subsequent searches don't record context.
+  if (session.zapActiveQueryContext) {
+    return;
+  }
+
+  // We also don't overwrite existing context.
+  if (session.activeQueryContextPagePathname) {
+    return;
+  }
+
+  session.activeQueryContextPageName =
+    decodeEntities(document.querySelector('title').dataset.withoutWikiName) ||
+    document.title;
+
+  session.activeQueryContextPagePathname =
+    location.pathname;
+
+  session.activeQueryContextPageColor =
+    document.querySelector('.color-style')?.dataset.color ??
+    null;
+}
+
 function clearSidebarSearch() {
-  const {session, state} = info;
+  const {state} = info;
 
   if (state.stoppedTypingTimeout) {
     clearTimeout(state.stoppedTypingTimeout);
@@ -576,12 +807,34 @@ function clearSidebarSearch() {
   info.searchInput.value = '';
 
   state.searchStage = null;
+  state.justPerformedActiveQuery = false;
+
+  clearActiveQuery();
+
+  hideSidebarSearchResults();
+}
+
+function clearActiveQuery() {
+  const {session} = info;
 
   session.activeQuery = null;
   session.activeQueryResults = null;
   session.resultsScrollOffset = null;
 
-  hideSidebarSearchResults();
+  session.activeQueryContextPageName = null;
+  session.activeQueryContextPagePathname = null;
+  session.activeQueryContextPageColor = null;
+  session.zapActiveQueryContext = false;
+}
+
+function clearSidebarFilter() {
+  const {session} = info;
+
+  toggleSidebarSearchFilter(session.activeFilterType);
+
+  forEachFilter((_type, filterLink) => {
+    filterLink.classList.remove('shown', 'hidden');
+  });
 }
 
 function updateSidebarSearchStatus() {
@@ -670,63 +923,189 @@ function showSidebarSearchFailed() {
 }
 
 function showSidebarSearchResults(results) {
-  console.debug(`Showing search results:`, results);
+  const {session} = info;
 
-  showSearchSidebarColumn();
+  console.debug(`Showing search results:`, tidyResults(results));
 
-  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,
-        })));
+  showSearchSidebarColumn();
 
   info.searchBox.classList.add('showing-results');
   info.searchSidebarColumn.classList.add('search-showing-results');
 
-  while (info.results.firstChild) {
-    info.results.firstChild.remove();
+  let filterType = session.activeFilterType;
+  let shownAnyResults =
+    fillResultElements(results, {filterType: session.activeFilterType});
+
+  showFilterElements(results);
+
+  if (!shownAnyResults) {
+    shownAnyResults = toggleSidebarSearchFilter(filterType);
+    filterType = null;
   }
 
-  cssProp(info.resultsRule, 'display', 'block');
-  cssProp(info.resultsContainer, 'display', 'block');
+  if (shownAnyResults) {
+    info.results.classList.add('has-results');
+
+    showContextControls();
+
+    cssProp(info.endSearchRule, 'display', 'block');
+    cssProp(info.endSearchLine, 'display', 'block');
+
+    tidySidebarSearchColumn();
+  } else {
+    info.results.classList.remove('has-results');
 
-  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);
+  restoreSidebarSearchResultsScrollOffset();
+}
+
+function tidyResults(results) {
+  const tidiedResults =
+    results.results.map(({doc, id}) => ({
+      reference: id ?? null,
+      referenceType: (id ? id.split(':')[0] : null),
+      directory: (id ? id.split(':')[1] : null),
+      data: doc,
+    }));
+
+  return tidiedResults;
+}
+
+function fillResultElements(results, {
+  filterType = null,
+} = {}) {
+  const tidiedResults = tidyResults(results);
+
+  let filteredResults = tidiedResults;
+
+  if (filterType) {
+    filteredResults = filteredResults
+      .filter(result => result.referenceType === filterType);
+  }
+
+  if (!filterType) {
+    filteredResults = filteredResults
+      .filter(result => {
+        if (result.referenceType !== 'track') return true;
+        if (result.data.classification !== 'single') return true;
+        return !filteredResults.find(otherResult => {
+          if (otherResult.referenceType !== 'album') return false;
+          return otherResult.name === result.parentName;
+        });
+      });
+  }
+
+  filteredResults = filteredResults
+
+  while (info.results.firstChild) {
+    info.results.firstChild.remove();
+  }
+
+  cssProp(info.resultsRule, 'display', 'block');
+  cssProp(info.resultsContainer, 'display', 'block');
+
+  if (empty(filteredResults)) {
+    return false;
+  }
+
+  for (const result of filteredResults) {
+    let el;
+    try {
+      el = generateSidebarSearchResult(result, filteredResults);
+    } catch (error) {
+      console.error(`Error showing result:`, result);
+      console.error(error);
+    }
+
     if (!el) continue;
 
     info.results.appendChild(el);
   }
 
-  if (!empty(flatResults)) {
-    cssProp(info.endSearchRule, 'display', 'block');
-    cssProp(info.endSearchLine, 'display', 'block');
+  return true;
+}
 
-    tidySidebarSearchColumn();
+function showFilterElements(results) {
+  const {queriedKind} = results;
+
+  const tidiedResults = tidyResults(results);
+
+  const allReferenceTypes =
+    unique(tidiedResults.map(result => result.referenceType));
+
+  let shownAny = false;
+
+  forEachFilter((type, filterLink) => {
+    filterLink.classList.remove('shown', 'hidden');
+
+    if (allReferenceTypes.includes(type)) {
+      shownAny = true;
+      cssProp(filterLink, 'display', null);
+
+      if (queriedKind) {
+        filterLink.setAttribute('inert', 'inert');
+      } else {
+        filterLink.removeAttribute('inert');
+      }
+
+      if (type === queriedKind) {
+        filterLink.classList.add('active-from-query');
+      } else {
+        filterLink.classList.remove('active-from-query');
+      }
+    } else {
+      cssProp(filterLink, 'display', 'none');
+    }
+  });
+
+  if (shownAny) {
+    cssProp(info.filterContainer, 'display', null);
+  } else {
+    cssProp(info.filterContainer, 'display', 'none');
   }
+}
 
-  restoreSidebarSearchResultsScrollOffset();
+function showContextControls() {
+  const {session} = info;
+
+  const shouldShow =
+    session.activeQueryContextPagePathname &&
+    location.pathname !== session.activeQueryContextPagePathname;
+
+  if (shouldShow) {
+    info.contextBackLink.href =
+      session.activeQueryContextPagePathname;
+
+    cssProp(info.contextBackLink,
+      '--primary-color',
+      session.activeQueryContextPageColor);
+
+    while (info.contextBackLink.firstChild) {
+      info.contextBackLink.firstChild.remove();
+    }
+
+    info.contextBackLink.appendChild(
+      document.createTextNode(
+        session.activeQueryContextPageName));
+
+    cssProp(info.contextContainer, 'display', 'block');
+  } else {
+    cssProp(info.contextContainer, 'display', 'none');
+  }
 }
 
-function generateSidebarSearchResult(result) {
+function generateSidebarSearchResult(result, results) {
   const preparedSlots = {
     color:
       result.data.color ?? null,
 
     name:
-      result.data.name ?? result.data.primaryName ?? null,
+      getSearchResultName(result),
 
     imageSource:
       getSearchResultImageSource(result),
@@ -735,10 +1114,16 @@ function generateSidebarSearchResult(result) {
   switch (result.referenceType) {
     case 'album': {
       preparedSlots.href =
-        openAlbum(result.directory);
+        (result.data.classification === 'in-game vgm'
+          ? openVGMAlbum(result.directory)
+          : openAlbum(result.directory));
 
       preparedSlots.kindString =
-        info.albumResultKindString;
+        (result.data.classification === 'single'
+          ? info.singleResultKindString
+       : result.data.classification === 'in-game vgm'
+          ? info.vgmAlbumResultKindString
+          : info.albumResultKindString);
 
       break;
     }
@@ -767,6 +1152,9 @@ function generateSidebarSearchResult(result) {
       preparedSlots.href =
         openFlash(result.directory);
 
+      preparedSlots.kindString =
+        info.flashResultKindString;
+
       break;
     }
 
@@ -791,9 +1179,103 @@ function generateSidebarSearchResult(result) {
       return null;
   }
 
+  const compareReferenceType = otherResult =>
+    otherResult.referenceType === result.referenceType;
+
+  const compareName = otherResult =>
+    getSearchResultName(otherResult) === getSearchResultName(result);
+
+  const ambiguousWith =
+    results.filter(otherResult =>
+      otherResult !== result &&
+      compareReferenceType(otherResult) &&
+      compareName(otherResult));
+
+  if (!empty(ambiguousWith)) disambiguate: {
+    const allAmbiguous = [result, ...ambiguousWith];
+
+    // First search for an ideal disambiguation, which disambiguates
+    // all ambiguous results in the same way.
+    let disambiguation = null, i;
+    for (i = 0; i < result.data.disambiguators.length; i++) {
+      const disambiguations =
+        allAmbiguous.map(r => r.data.disambiguators[i]);
+
+      if (unique(disambiguations).length === allAmbiguous.length) {
+        disambiguation = result.data.disambiguators[i];
+        break;
+      }
+    }
+
+    // Otherwise, search for a disambiguation which disambiguates
+    // *this result* with at least one other result which it is
+    // *otherwise* ambiguous with.
+    if (!disambiguation) {
+      for (i = 1; i < result.data.disambiguators.length; i++) {
+        const otherwiseAmbiguousWith =
+          ambiguousWith.filter(otherResult =>
+            compareArrays(
+              otherResult.data.disambiguators.slice(0, i),
+              result.data.disambiguators.slice(0, i)));
+
+        if (
+          otherwiseAmbiguousWith.find(otherResult =>
+            otherResult.data.disambiguators[i] !==
+            result.data.disambiguators[i])
+        ) {
+          disambiguation = result.data.disambiguators[i];
+          break;
+        }
+      }
+    }
+
+    // Otherwise, search for a disambiguation which disambiguates
+    // this result at all.
+    if (!disambiguation) {
+      for (i = 0; i < result.data.disambiguators.length; i++) {
+        if (
+          ambiguousWith.find(otherResult =>
+            otherResult.data.disambiguators[i] !==
+            result.data.disambiguators[i])
+        ) {
+          disambiguation = result.data.disambiguators[i];
+          break;
+        }
+      }
+    }
+
+    if (!disambiguation) {
+      break disambiguate;
+    }
+
+    const string =
+      info[result.referenceType + 'ResultDisambiguatorString' + (i + 1)];
+
+    if (!string) break disambiguate;
+
+    preparedSlots.disambiguate = disambiguation;
+    preparedSlots.disambiguatorString = string;
+  }
+
   return generateSidebarSearchResultTemplate(preparedSlots);
 }
 
+function getSearchResultName(result) {
+  const name =
+    result.data.name ??
+    result.data.primaryName;
+
+  if (!name) {
+    return null;
+  }
+
+  if (result.data.nameDetail) {
+    return `${name} (${result.data.nameDetail})`;
+  }
+
+  return name;
+}
+
 function getSearchResultImageSource(result) {
   const {artwork} = result.data;
   if (!artwork) return null;
@@ -869,6 +1351,15 @@ function generateSidebarSearchResultTemplate(slots) {
     }
   }
 
+  if (!accentSpan && slots.disambiguate) {
+    accentSpan = document.createElement('span');
+    accentSpan.classList.add('wiki-search-result-disambiguator');
+    accentSpan.appendChild(
+      templateContent(slots.disambiguatorString, {
+        disambiguator: slots.disambiguate,
+      }));
+  }
+
   if (!accentSpan && slots.kindString) {
     accentSpan = document.createElement('span');
     accentSpan.classList.add('wiki-search-result-kind');
@@ -908,6 +1399,9 @@ function generateSidebarSearchResultTemplate(slots) {
 }
 
 function hideSidebarSearchResults() {
+  cssProp(info.contextContainer, 'display', 'none');
+  cssProp(info.filterContainer, 'display', 'none');
+
   cssProp(info.resultsRule, 'display', 'none');
   cssProp(info.resultsContainer, 'display', 'none');
 
@@ -917,6 +1411,8 @@ function hideSidebarSearchResults() {
 
   cssProp(info.endSearchRule, 'display', 'none');
   cssProp(info.endSearchLine, 'display', 'none');
+
+  restoreSidebarSearchColumn();
 }
 
 function focusFirstSidebarSearchResult() {
@@ -1000,7 +1496,7 @@ function possiblyHideSearchSidebarColumn() {
 // 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;
+  const {session, 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
@@ -1009,17 +1505,24 @@ function tidySidebarSearchColumn() {
     return;
   }
 
-  const here = location.href.replace(/\/$/, '');
+  const hrefHere = location.href.replace(/\/$/, '');
   const currentPageIsResult =
     Array.from(info.results.querySelectorAll('a'))
       .some(link => {
-        const there = link.href.replace(/\/$/, '');
-        return here === there;
+        const hrefThere = link.href.replace(/\/$/, '');
+        return hrefHere === hrefThere;
       });
 
+  const currentPageIsContext =
+    location.pathname === session.activeQueryContextPagePathname;
+
   // Don't tidy the sidebar if you've navigated to some other page than
   // what's in the current result list.
-  if (!currentPageIsResult) {
+  if (
+    !state.justPerformedActiveQuery &&
+    !currentPageIsResult &&
+    !currentPageIsContext
+  ) {
     return;
   }
 
@@ -1040,6 +1543,36 @@ function tidySidebarSearchColumn() {
   }
 }
 
+function toggleSidebarSearchFilter(toggleType) {
+  const {session} = info;
+
+  if (!toggleType) return null;
+
+  let shownAnyResults = null;
+
+  forEachFilter((type, filterLink) => {
+    if (type === toggleType) {
+      const filterActive = filterLink.classList.toggle('active');
+      const filterType = (filterActive ? type : null);
+
+      if (cssProp(filterLink, 'display') !== 'none') {
+        filterLink.classList.add(filterActive ? 'shown' : 'hidden');
+      }
+
+      if (session.activeQueryResults) {
+        shownAnyResults =
+          fillResultElements(session.activeQueryResults, {filterType});
+      }
+
+      session.activeFilterType = filterType;
+    } else {
+      filterLink.classList.remove('active');
+    }
+  });
+
+  return shownAnyResults;
+}
+
 function restoreSidebarSearchColumn() {
   const {state} = info;
 
@@ -1069,10 +1602,8 @@ function considerRecallingRecentSidebarSearch() {
 }
 
 function forgetRecentSidebarSearch() {
-  const {session} = info;
-
-  session.activeQuery = null;
-  session.activeQueryResults = null;
+  clearActiveQuery();
+  clearSidebarFilter();
 }
 
 async function handleDroppedIntoSearchInput(domEvent) {
@@ -1101,7 +1632,7 @@ async function handleDroppedIntoSearchInput(domEvent) {
   let droppedURL;
   try {
     droppedURL = new URL(droppedText);
-  } catch (error) {
+  } catch {
     droppedURL = null;
   }
 
diff --git a/src/static/js/client/sticky-heading.js b/src/static/js/client/sticky-heading.js
index fba05b84..c69e137f 100644
--- a/src/static/js/client/sticky-heading.js
+++ b/src/static/js/client/sticky-heading.js
@@ -1,5 +1,3 @@
-/* eslint-env browser */
-
 import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js';
 import {cssProp, dispatchInternalEvent, templateContent}
   from '../client-util.js';
@@ -23,6 +21,7 @@ export const info = {
 
   contentContainers: null,
   contentHeadings: null,
+  contentCoverColumns: null,
   contentCovers: null,
   contentCoversReveal: null,
 
@@ -82,9 +81,13 @@ export function getPageReferences() {
     info.stickyContainers
       .map(el => el.closest('.content-sticky-heading-root').parentElement);
 
-  info.contentCovers =
+  info.contentCoverColumns =
     info.contentContainers
-      .map(el => el.querySelector('#cover-art-container'));
+      .map(el => el.querySelector('#artwork-column'));
+
+  info.contentCovers =
+    info.contentCoverColumns
+      .map(el => el ? el.querySelector('.cover-artwork') : null);
 
   info.contentCoversReveal =
     info.contentCovers
@@ -212,10 +215,10 @@ function updateCollapseStatus(index) {
 function updateStickyCoverVisibility(index) {
   const stickyCoverContainer = info.stickyCoverContainers[index];
   const stickyContainer = info.stickyContainers[index];
-  const contentCover = info.contentCovers[index];
+  const contentCoverColumn = info.contentCoverColumns[index];
 
-  if (contentCover && stickyCoverContainer) {
-    if (contentCover.getBoundingClientRect().bottom < 4) {
+  if (contentCoverColumn && stickyCoverContainer) {
+    if (contentCoverColumn.getBoundingClientRect().bottom < 4) {
       stickyCoverContainer.classList.add('visible');
       stickyContainer.classList.add('cover-visible');
     } else {
@@ -250,7 +253,11 @@ function getContentHeadingClosestToStickySubheading(index) {
 
   // Iterate from bottom to top of the content area.
   const contentHeadings = info.contentHeadings[index];
-  for (const heading of contentHeadings.slice().reverse()) {
+  for (const heading of contentHeadings.toReversed()) {
+    if (heading.nodeName === 'SUMMARY' && !heading.closest('details').open) {
+      continue;
+    }
+
     const headingRect = heading.getBoundingClientRect();
     if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 40) {
       return heading;
diff --git a/src/static/js/client/summary-nested-link.js b/src/static/js/client/summary-nested-link.js
deleted file mode 100644
index 23857fa5..00000000
--- a/src/static/js/client/summary-nested-link.js
+++ /dev/null
@@ -1,48 +0,0 @@
-/* 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
index dd207e04..2b855756 100644
--- a/src/static/js/client/text-with-tooltip.js
+++ b/src/static/js/client/text-with-tooltip.js
@@ -1,5 +1,3 @@
-/* eslint-env browser */
-
 import {stitchArrays} from '../../shared-util/sugar.js';
 
 import {registerTooltipElement, registerTooltipHoverableElement}
diff --git a/src/static/js/client/wiki-search.js b/src/static/js/client/wiki-search.js
index 2446c172..9a6e29c1 100644
--- a/src/static/js/client/wiki-search.js
+++ b/src/static/js/client/wiki-search.js
@@ -1,5 +1,3 @@
-/* eslint-env browser */
-
 import {promiseWithResolvers} from '../../shared-util/sugar.js';
 
 import {dispatchInternalEvent} from '../client-util.js';