« 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
diff options
context:
space:
mode:
Diffstat (limited to 'src/static/js')
-rw-r--r--src/static/js/client-util.js46
-rw-r--r--src/static/js/client/additional-names-box.js69
-rw-r--r--src/static/js/client/art-tag-gallery-filter.js151
-rw-r--r--src/static/js/client/art-tag-network.js147
-rw-r--r--src/static/js/client/artist-rolling-window.js573
-rw-r--r--src/static/js/client/css-compatibility-assistant.js26
-rw-r--r--src/static/js/client/expandable-grid-section.js85
-rw-r--r--src/static/js/client/gallery-style-selector.js123
-rw-r--r--src/static/js/client/hoverable-tooltip.js53
-rw-r--r--src/static/js/client/image-overlay.js52
-rw-r--r--src/static/js/client/index.js12
-rw-r--r--src/static/js/client/reveal-all-grid-control.js72
-rw-r--r--src/static/js/client/sidebar-search.js389
-rw-r--r--src/static/js/client/sticky-heading.js132
-rw-r--r--src/static/js/rectangles.js42
-rw-r--r--src/static/js/search-worker.js181
16 files changed, 1982 insertions, 171 deletions
diff --git a/src/static/js/client-util.js b/src/static/js/client-util.js
index f06b707a..396c4889 100644
--- a/src/static/js/client-util.js
+++ b/src/static/js/client-util.js
@@ -1,12 +1,19 @@
 /* eslint-env browser */
 
 export function rebase(href, rebaseKey = 'rebaseLocalized') {
-  const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/';
-  if (relative) {
-    return relative + href;
-  } else {
-    return href;
+  let result = document.documentElement.dataset[rebaseKey] || './';
+
+  if (!result.endsWith('/')) {
+    result += '/';
+  }
+
+  if (href.startsWith('/')) {
+    href = href.slice(1);
   }
+
+  result += href;
+
+  return result;
 }
 
 export function cssProp(el, ...args) {
@@ -30,7 +37,7 @@ export function cssProp(el, ...args) {
   }
 }
 
-export function templateContent(el) {
+export function templateContent(el, slots = {}) {
   if (el === null) {
     return null;
   }
@@ -39,7 +46,25 @@ export function templateContent(el) {
     throw new Error(`Expected a <template> element`);
   }
 
-  return el.content.cloneNode(true);
+  const content = el.content.cloneNode(true);
+
+  for (const [key, value] of Object.entries(slots)) {
+    const slot = content.querySelector(`slot[name="${key}"]`);
+
+    if (!slot) {
+      console.warn(`Slot ${key} missing in template:`, el);
+      continue;
+    }
+
+    if (value === null || value === undefined) {
+      console.warn(`Valueless slot ${key} in template:`, el);
+      continue;
+    }
+
+    slot.replaceWith(value);
+  }
+
+  return content;
 }
 
 // Curry-style, so multiple points can more conveniently be tested at once.
@@ -120,3 +145,10 @@ export function dispatchInternalEvent(event, eventName, ...args) {
 
   return results;
 }
+
+const languageCode = document.documentElement.getAttribute('lang');
+
+export function formatDate(inputDate) {
+  const date = new Date(inputDate);
+  return date.toLocaleDateString(languageCode);
+}
diff --git a/src/static/js/client/additional-names-box.js b/src/static/js/client/additional-names-box.js
index 558ef06f..195ba25d 100644
--- a/src/static/js/client/additional-names-box.js
+++ b/src/static/js/client/additional-names-box.js
@@ -3,12 +3,17 @@
 import {cssProp} from '../client-util.js';
 
 import {info as hashLinkInfo} from './hash-link.js';
+import {info as stickyHeadingInfo} from './sticky-heading.js';
 
 export const info = {
   id: 'additionalNamesBoxInfo',
 
   box: null,
+
   links: null,
+  stickyHeadingLink: null,
+
+  contentContainer: null,
   mainContentContainer: null,
 
   state: {
@@ -23,6 +28,16 @@ export function getPageReferences() {
   info.links =
     document.querySelectorAll('a[href="#additional-names-box"]');
 
+  info.stickyHeadingLink =
+    document.querySelector(
+      '.content-sticky-heading-container' +
+      ' ' +
+      'a[href="#additional-names-box"]' +
+      ':not(:where([inert] *))');
+
+  info.contentContainer =
+    document.querySelector('#content');
+
   info.mainContentContainer =
     document.querySelector('#content .main-content-container');
 }
@@ -33,6 +48,34 @@ export function addInternalListeners() {
       return false;
     }
   });
+
+  stickyHeadingInfo.event.whenStuckStatusChanges.push((index, stuck) => {
+    const {state} = info;
+
+    if (!info.stickyHeadingLink) return;
+
+    const container = stickyHeadingInfo.contentContainers[index];
+    if (container !== info.contentContainer) return;
+
+    if (stuck) {
+      if (!state.visible) {
+        info.stickyHeadingLink.removeAttribute('href');
+
+        if (info.stickyHeadingLink.hasAttribute('title')) {
+          info.stickyHeadingLink.dataset.restoreTitle = info.stickyHeadingLink.getAttribute('title');
+          info.stickyHeadingLink.removeAttribute('title');
+        }
+      }
+    } else {
+      info.stickyHeadingLink.setAttribute('href', '#additional-names-box');
+
+      const {restoreTitle} = info.stickyHeadingLink.dataset;
+      if (restoreTitle) {
+        info.stickyHeadingLink.setAttribute('title', restoreTitle);
+        delete info.stickyHeadingLink.dataset.restoreTitle;
+      }
+    }
+  });
 }
 
 export function addPageListeners() {
@@ -48,6 +91,7 @@ function handleAdditionalNamesBoxLinkClicked(domEvent) {
 
   domEvent.preventDefault();
 
+  if (!domEvent.target.hasAttribute('href')) return;
   if (!info.box || !info.mainContentContainer) return;
 
   const margin =
@@ -58,7 +102,30 @@ function handleAdditionalNamesBoxLinkClicked(domEvent) {
       ? info.box.getBoundingClientRect()
       : info.mainContentContainer.getBoundingClientRect());
 
-  if (top + 20 < margin || top > 0.4 * window.innerHeight) {
+  const {bottom, height} =
+    (state.visible
+      ? info.box.getBoundingClientRect()
+      : {bottom: null});
+
+  const boxFitsInFrame =
+    (height
+      ? height < window.innerHeight - margin - 60
+      : null);
+
+  const worthScrolling =
+    top + 20 < margin ||
+
+    (height && boxFitsInFrame
+      ? top > 0.7 * window.innerHeight
+   : height && !boxFitsInFrame
+      ? top > 0.4 * window.innerHeight
+      : top > 0.5 * window.innerHeight) ||
+
+    (bottom && boxFitsInFrame
+      ? bottom > window.innerHeight - 20
+      : false);
+
+  if (worthScrolling) {
     if (!state.visible) {
       toggleAdditionalNamesBox();
     }
diff --git a/src/static/js/client/art-tag-gallery-filter.js b/src/static/js/client/art-tag-gallery-filter.js
new file mode 100644
index 00000000..fd40d1a2
--- /dev/null
+++ b/src/static/js/client/art-tag-gallery-filter.js
@@ -0,0 +1,151 @@
+/* eslint-env browser */
+
+export const info = {
+  id: 'artTagGalleryFilterInfo',
+
+  featuredAllLine: null,
+  showingAllLine: null,
+  showingAllLink: null,
+
+  featuredDirectLine: null,
+  showingDirectLine: null,
+  showingDirectLink: null,
+
+  featuredIndirectLine: null,
+  showingIndirectLine: null,
+  showingIndirectLink: null,
+
+  gridItems: null,
+  gridItemsOnlyFeaturedIndirectly: null,
+  gridItemsFeaturedDirectly: null,
+};
+
+export function getPageReferences() {
+  if (document.documentElement.dataset.urlKey !== 'localized.artTagGallery') {
+    return;
+  }
+
+  info.featuredAllLine =
+    document.getElementById('featured-all-line');
+
+  info.featuredDirectLine =
+    document.getElementById('featured-direct-line');
+
+  info.featuredIndirectLine =
+    document.getElementById('featured-indirect-line');
+
+  info.showingAllLine =
+    document.getElementById('showing-all-line');
+
+  info.showingDirectLine =
+    document.getElementById('showing-direct-line');
+
+  info.showingIndirectLine =
+    document.getElementById('showing-indirect-line');
+
+  info.showingAllLink =
+    info.showingAllLine?.querySelector('a') ?? null;
+
+  info.showingDirectLink =
+    info.showingDirectLine?.querySelector('a') ?? null;
+
+  info.showingIndirectLink =
+    info.showingIndirectLine?.querySelector('a') ?? null;
+
+  info.gridItems =
+    Array.from(
+      document.querySelectorAll('#content .grid-listing .grid-item'));
+
+  info.gridItemsOnlyFeaturedIndirectly =
+    info.gridItems
+      .filter(gridItem => gridItem.classList.contains('featured-indirectly'));
+
+  info.gridItemsFeaturedDirectly =
+    info.gridItems
+      .filter(gridItem => !gridItem.classList.contains('featured-indirectly'));
+}
+
+function filterArtTagGallery(showing) {
+  let gridItemsToShow;
+
+  switch (showing) {
+    case 'all':
+      gridItemsToShow = info.gridItems;
+      break;
+
+    case 'direct':
+      gridItemsToShow = info.gridItemsFeaturedDirectly;
+      break;
+
+    case 'indirect':
+      gridItemsToShow = info.gridItemsOnlyFeaturedIndirectly;
+      break;
+  }
+
+  for (const gridItem of info.gridItems) {
+    if (gridItemsToShow.includes(gridItem)) {
+      gridItem.style.removeProperty('display');
+    } else {
+      gridItem.style.display = 'none';
+    }
+  }
+}
+
+export function addPageListeners() {
+  const orderShowing = [
+    'all',
+    'direct',
+    'indirect',
+  ];
+
+  const orderFeaturedLines = [
+    info.featuredAllLine,
+    info.featuredDirectLine,
+    info.featuredIndirectLine,
+  ];
+
+  const orderShowingLines = [
+    info.showingAllLine,
+    info.showingDirectLine,
+    info.showingIndirectLine,
+  ];
+
+  const orderShowingLinks = [
+    info.showingAllLink,
+    info.showingDirectLink,
+    info.showingIndirectLink,
+  ];
+
+  for (let index = 0; index < orderShowing.length; index++) {
+    if (!orderShowingLines[index]) continue;
+
+    let nextIndex = index;
+    do {
+      if (nextIndex === orderShowing.length) {
+        nextIndex = 0;
+      } else {
+        nextIndex++;
+      }
+    } while (!orderShowingLinks[nextIndex]);
+
+    const currentFeaturedLine = orderFeaturedLines[index];
+    const currentShowingLine = orderShowingLines[index];
+    const currentShowingLink = orderShowingLinks[index];
+
+    const nextFeaturedLine = orderFeaturedLines[nextIndex];
+    const nextShowingLine = orderShowingLines[nextIndex];
+    const nextShowing = orderShowing[nextIndex];
+
+    currentShowingLink.addEventListener('click', event => {
+      event.preventDefault();
+
+      currentFeaturedLine.style.display = 'none';
+      currentShowingLine.style.display = 'none';
+
+      nextFeaturedLine.style.display = 'block';
+      nextShowingLine.style.display = 'block';
+
+      filterArtTagGallery(nextShowing);
+    });
+  }
+}
diff --git a/src/static/js/client/art-tag-network.js b/src/static/js/client/art-tag-network.js
new file mode 100644
index 00000000..44e10c11
--- /dev/null
+++ b/src/static/js/client/art-tag-network.js
@@ -0,0 +1,147 @@
+/* eslint-env browser */
+
+import {cssProp} from '../client-util.js';
+
+import {atOffset, stitchArrays} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'artTagNetworkInfo',
+
+  noneStatLink: null,
+  totalUsesStatLink: null,
+  directUsesStatLink: null,
+  descendantsStatLink: null,
+  leavesStatLink: null,
+
+  tagsWithoutStats: null,
+  tagsWithStats: null,
+
+  totalUsesStats: null,
+  directUsesStats: null,
+  descendantsStats: null,
+  leavesStats: null,
+};
+
+export function getPageReferences() {
+  if (
+    document.documentElement.dataset.urlKey !== 'localized.listing' ||
+    document.documentElement.dataset.urlValue0 !== 'tags/network'
+  ) {
+    return;
+  }
+
+  info.noneStatLink =
+    document.getElementById('network-stat-none');
+
+  info.totalUsesStatLink =
+    document.getElementById('network-stat-total-uses');
+
+  info.directUsesStatLink =
+    document.getElementById('network-stat-direct-uses');
+
+  info.descendantsStatLink =
+    document.getElementById('network-stat-descendants');
+
+  info.leavesStatLink =
+    document.getElementById('network-stat-leaves');
+
+  info.tagsWithoutStats =
+    document.querySelectorAll('.network-tag:not(.with-stat)');
+
+  info.tagsWithStats =
+    document.querySelectorAll('.network-tag.with-stat');
+
+  info.totalUsesStats =
+    Array.from(document.getElementsByClassName('network-tag-total-uses-stat'));
+
+  info.directUsesStats =
+    Array.from(document.getElementsByClassName('network-tag-direct-uses-stat'));
+
+  info.descendantsStats =
+    Array.from(document.getElementsByClassName('network-tag-descendants-stat'));
+
+  info.leavesStats =
+    Array.from(document.getElementsByClassName('network-tag-leaves-stat'));
+}
+
+export function addPageListeners() {
+  if (!info.noneStatLink) return;
+
+  const linkOrder = [
+    info.noneStatLink,
+    info.totalUsesStatLink,
+    info.directUsesStatLink,
+    info.descendantsStatLink,
+    info.leavesStatLink,
+  ];
+
+  const statsOrder = [
+    null,
+    info.totalUsesStats,
+    info.directUsesStats,
+    info.descendantsStats,
+    info.leavesStats,
+  ];
+
+  const stitched =
+    stitchArrays({
+      link: linkOrder,
+      stats: statsOrder,
+    });
+
+  for (const [index, {link}] of stitched.entries()) {
+    const next = atOffset(stitched, index, +1, {wrap: true});
+
+    link.addEventListener('click', domEvent => {
+      domEvent.preventDefault();
+
+      cssProp(link, 'display', 'none');
+      cssProp(next.link, 'display', null);
+
+      if (next.stats === null) {
+        hideArtTagNetworkStats();
+      } else {
+        showArtTagNetworkStats(next.stats);
+      }
+    });
+  }
+}
+
+function showArtTagNetworkStats(stats) {
+  for (const tagElement of info.tagsWithoutStats) {
+    cssProp(tagElement, 'display', 'none');
+  }
+
+  for (const tagElement of info.tagsWithStats) {
+    cssProp(tagElement, 'display', null);
+  }
+
+  const allStats = [
+    ...info.totalUsesStats,
+    ...info.directUsesStats,
+    ...info.descendantsStats,
+    ...info.leavesStats,
+  ];
+
+  const otherStats =
+    allStats
+      .filter(stat => !stats.includes(stat));
+
+  for (const statElement of otherStats) {
+    cssProp(statElement, 'display', 'none');
+  }
+
+  for (const statElement of stats) {
+    cssProp(statElement, 'display', null);
+  }
+}
+
+function hideArtTagNetworkStats() {
+  for (const tagElement of info.tagsWithoutStats) {
+    cssProp(tagElement, 'display', null);
+  }
+
+  for (const tagElement of info.tagsWithStats) {
+    cssProp(tagElement, 'display', 'none');
+  }
+}
diff --git a/src/static/js/client/artist-rolling-window.js b/src/static/js/client/artist-rolling-window.js
new file mode 100644
index 00000000..b201e7df
--- /dev/null
+++ b/src/static/js/client/artist-rolling-window.js
@@ -0,0 +1,573 @@
+/* eslint-env browser */
+
+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
index 6e7b15b5..aa637cc4 100644
--- a/src/static/js/client/css-compatibility-assistant.js
+++ b/src/static/js/client/css-compatibility-assistant.js
@@ -1,22 +1,30 @@
 /* eslint-env browser */
 
+import {stitchArrays} from '../../shared-util/sugar.js';
+
 export const info = {
   id: 'cssCompatibilityAssistantInfo',
 
-  coverArtContainer: null,
-  coverArtImageDetails: null,
+  coverArtworks: null,
+  coverArtworkImageDetails: null,
 };
 
 export function getPageReferences() {
-  info.coverArtContainer =
-    document.getElementById('cover-art-container');
+  info.coverArtworks =
+    Array.from(document.querySelectorAll('.cover-artwork'));
 
-  info.coverArtImageDetails =
-    info.coverArtContainer?.querySelector('.image-details');
+  info.coverArtworkImageDetails =
+    info.coverArtworks
+      .map(artwork => artwork.querySelector('.image-details'));
 }
 
 export function mutatePageContent() {
-  if (info.coverArtImageDetails) {
-    info.coverArtContainer.classList.add('has-image-details');
-  }
+  stitchArrays({
+    coverArtwork: info.coverArtworks,
+    imageDetails: info.coverArtworkImageDetails,
+  }).forEach(({coverArtwork, imageDetails}) => {
+      if (imageDetails) {
+        coverArtwork.classList.add('has-image-details');
+      }
+    });
 }
diff --git a/src/static/js/client/expandable-grid-section.js b/src/static/js/client/expandable-grid-section.js
new file mode 100644
index 00000000..ce9a4c06
--- /dev/null
+++ b/src/static/js/client/expandable-grid-section.js
@@ -0,0 +1,85 @@
+/* eslint-env browser */
+
+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..c7086eae
--- /dev/null
+++ b/src/static/js/client/gallery-style-selector.js
@@ -0,0 +1,123 @@
+/* eslint-env browser */
+
+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/hoverable-tooltip.js b/src/static/js/client/hoverable-tooltip.js
index 484f2ab0..89119a47 100644
--- a/src/static/js/client/hoverable-tooltip.js
+++ b/src/static/js/client/hoverable-tooltip.js
@@ -118,17 +118,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,20 +158,20 @@ 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);
   });
 }
 
@@ -416,7 +416,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 +426,7 @@ function handleTooltipHoverableClicked(hoverable) {
     state.currentlyActiveHoverable === hoverable &&
     state.hoverableWasRecentlyTouched
   ) {
-    event.preventDefault();
+    domEvent.preventDefault();
   }
 }
 
@@ -576,6 +576,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');
@@ -667,12 +678,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'],
       ]) {
@@ -995,6 +1006,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;
 
diff --git a/src/static/js/client/image-overlay.js b/src/static/js/client/image-overlay.js
index b51d57a4..e9e2708d 100644
--- a/src/static/js/client/image-overlay.js
+++ b/src/static/js/client/image-overlay.js
@@ -66,8 +66,13 @@ export function getPageReferences() {
   info.fileSizeWarning =
     document.getElementById('image-overlay-file-size-warning');
 
+  const linkQuery = [
+    '.image-link',
+    '.image-media-link',
+  ].join(', ');
+
   info.links =
-    Array.from(document.querySelectorAll('.image-link'))
+    Array.from(document.querySelectorAll(linkQuery))
       .filter(link => !link.closest('.no-image-preview'));
 }
 
@@ -88,10 +93,13 @@ function handleContainerClicked(evt) {
     return;
   }
 
-  // If you clicked anything close to or beneath the action bar, don't hide
-  // the image overlay.
+  // 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) {
+  if (
+    evt.clientY >= rect.top - 40 && evt.clientY <= rect.bottom + 40 &&
+    evt.clientX >= rect.left + 20 && evt.clientX <= rect.right - 20
+  ) {
     return;
   }
 
@@ -141,13 +149,23 @@ function getImageLinkDetails(imageLink) {
       a.href,
 
     embeddedSrc:
-      img.src,
+      img?.src ??
+      a.dataset.embedSrc,
 
     originalFileSize:
-      img.dataset.originalSize,
+      img?.dataset.originalSize ??
+      a.dataset.originalSize ??
+      null,
 
     availableThumbList:
-      img.dataset.thumbs,
+      img?.dataset.thumbs ??
+      a.dataset.thumbs ??
+      null,
+
+    dimensions:
+      img?.dataset.dimensions?.split('x') ??
+      a.dataset.dimensions?.split('x') ??
+      null,
 
     color:
       cssProp(imageLink, '--primary-color'),
@@ -170,7 +188,7 @@ function getImageSources(details) {
     };
   } else {
     return {
-      mainSrc: originalSrc,
+      mainSrc: details.originalSrc,
       thumbSrc: null,
       mainThumb: '',
       thumbThumb: '',
@@ -211,15 +229,31 @@ async function loadOverlayImage(details) {
   if (details.thumbSrc) {
     info.thumbImage.src = details.thumbSrc;
     info.thumbImage.style.display = null;
+    info.container.classList.remove('no-thumb');
   } else {
     info.thumbImage.src = '';
     info.thumbImage.style.display = 'none';
+    info.container.classList.add('no-thumb');
   }
 
   // Show the thumbnail size on each <img> element's data attributes.
   // Y'know, just for debugging convenience.
   info.mainImage.dataset.displayingThumb = details.mainThumb;
-  info.thumbImage.dataset.displayingThumb = details.thumbThubm;
+  info.thumbImage.dataset.displayingThumb = details.thumbThumb;
+
+  if (details.dimensions) {
+    info.mainImage.width = details.dimensions[0];
+    info.mainImage.height = details.dimensions[1];
+    info.thumbImage.width = details.dimensions[0];
+    info.thumbImage.height = details.dimensions[1];
+    cssProp(info.thumbImage, 'aspect-ratio', details.dimensions.join('/'));
+  } else {
+    info.mainImage.removeAttribute('width');
+    info.mainImage.removeAttribute('height');
+    info.thumbImage.removeAttribute('width');
+    info.thumbImage.removeAttribute('height');
+    cssProp(info.thumbImage, 'aspect-ratio', null);
+  }
 
   info.mainImage.addEventListener('load', handleMainImageLoaded);
   info.mainImage.addEventListener('error', handleMainImageErrored);
diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js
index 52d2afd6..86081b5d 100644
--- a/src/static/js/client/index.js
+++ b/src/static/js/client/index.js
@@ -4,16 +4,22 @@ 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 artistRollingWindowModule from './artist-rolling-window.js';
 import * as cssCompatibilityAssistantModule from './css-compatibility-assistant.js';
 import * as datetimestampTooltipModule from './datetimestamp-tooltip.js';
 import * as draggedLinkModule from './dragged-link.js';
+import * as expandableGridSectionModule from './expandable-grid-section.js';
+import * as galleryStyleSelectorModule from './gallery-style-selector.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 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';
@@ -24,16 +30,22 @@ import * as wikiSearchModule from './wiki-search.js';
 export const modules = [
   additionalNamesBoxModule,
   albumCommentarySidebarModule,
+  artTagGalleryFilterModule,
+  artTagNetworkModule,
   artistExternalLinkTooltipModule,
+  artistRollingWindowModule,
   cssCompatibilityAssistantModule,
   datetimestampTooltipModule,
   draggedLinkModule,
+  expandableGridSectionModule,
+  galleryStyleSelectorModule,
   hashLinkModule,
   hoverableTooltipModule,
   imageOverlayModule,
   intrapageDotSwitcherModule,
   liveMousePositionModule,
   quickDescriptionModule,
+  revealAllGridControlModule,
   scriptedLinkModule,
   sidebarSearchModule,
   stickyHeadingModule,
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..1b362bea
--- /dev/null
+++ b/src/static/js/client/reveal-all-grid-control.js
@@ -0,0 +1,72 @@
+/* eslint-env browser */
+
+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/sidebar-search.js b/src/static/js/client/sidebar-search.js
index c79fb837..4467766c 100644
--- a/src/static/js/client/sidebar-search.js
+++ b/src/static/js/client/sidebar-search.js
@@ -1,7 +1,7 @@
 /* eslint-env browser */
 
 import {getColors} from '../../shared-util/colors.js';
-import {accumulateSum, empty} from '../../shared-util/sugar.js';
+import {accumulateSum, empty, unique} from '../../shared-util/sugar.js';
 
 import {
   cssProp,
@@ -41,6 +41,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,
@@ -49,6 +57,8 @@ export const info = {
   endSearchLine: null,
   endSearchLink: null,
 
+  standbyInputPlaceholder: null,
+
   preparingString: null,
   loadingDataString: null,
   searchingString: null,
@@ -63,12 +73,26 @@ export const info = {
   groupResultKindString: null,
   tagResultKindString: null,
 
+  groupResultDisambiguatorString: null,
+  flashResultDisambiguatorString: null,
+  trackResultDisambiguatorString: null,
+
+  albumResultFilterString: null,
+  artistResultFilterString: null,
+  flashResultFilterString: null,
+  groupResultFilterString: null,
+  tagResultFilterString: null,
+  trackResultFilterString: null,
+
   state: {
     sidebarColumnShownForSearch: null,
 
     tidiedSidebar: null,
     collapsedDetailsForTidiness: null,
 
+    recallingRecentSearch: null,
+    recallingRecentSearchFromMouse: null,
+
     currentValue: null,
 
     workerStatus: null,
@@ -92,6 +116,10 @@ export const info = {
       maxLength: settings => settings.maxActiveResultsStorage,
     },
 
+    activeFilterType: {
+      type: 'string',
+    },
+
     repeatQueryOnReload: {
       type: 'boolean',
       default: false,
@@ -133,6 +161,9 @@ export function getPageReferences() {
   info.searchSidebarColumn =
     info.searchBox.closest('.sidebar-column');
 
+  info.standbyInputPlaceholder =
+    info.searchInput.placeholder;
+
   const findString = classPart =>
     info.searchBox.querySelector(`.wiki-search-${classPart}-string`);
 
@@ -168,6 +199,33 @@ export function getPageReferences() {
 
   info.tagResultKindString =
     findString('tag-result-kind');
+
+  info.groupResultDisambiguatorString =
+    findString('group-result-disambiguator');
+
+  info.flashResultDisambiguatorString =
+    findString('flash-result-disambiguator');
+
+  info.trackResultDisambiguatorString =
+    findString('track-result-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() {
@@ -257,6 +315,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 =
@@ -310,6 +400,43 @@ export function mutatePageContent() {
 export function addPageListeners() {
   if (!info.searchInput) return;
 
+  info.searchInput.addEventListener('mousedown', _domEvent => {
+    const {state} = info;
+
+    if (state.recallingRecentSearch) {
+      state.recallingRecentSearchFromMouse = true;
+    }
+  });
+
+  info.searchInput.addEventListener('focus', _domEvent => {
+    const {session, state} = info;
+
+    if (state.recallingRecentSearch) {
+      info.searchInput.value = session.activeQuery;
+      info.searchInput.placeholder = info.standbyInputPlaceholder;
+      showSidebarSearchResults(session.activeQueryResults);
+      state.recallingRecentSearch = false;
+    }
+  });
+
+  info.searchLabel.addEventListener('click', domEvent => {
+    const {state} = info;
+
+    if (state.recallingRecentSearchFromMouse) {
+      if (info.searchInput.selectionStart === info.searchInput.selectionEnd) {
+        info.searchInput.select();
+      }
+
+      state.recallingRecentSearchFromMouse = false;
+      return;
+    }
+
+    const inputRect = info.searchInput.getBoundingClientRect();
+    if (domEvent.clientX < inputRect.left - 3) {
+      info.searchInput.select();
+    }
+  });
+
   info.searchInput.addEventListener('change', _domEvent => {
     const {state} = info;
 
@@ -326,7 +453,7 @@ export function addPageListeners() {
     const {settings, state} = info;
 
     if (!info.searchInput.value) {
-      clearSidebarSearch();
+      clearSidebarSearch(); // ...but don't clear filter
       return;
     }
 
@@ -388,10 +515,18 @@ 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', () => {
     const {settings, state} = info;
 
@@ -412,11 +547,11 @@ export function initializeState() {
   if (!info.searchInput) return;
 
   if (session.activeQuery) {
-    info.searchInput.value = session.activeQuery;
     if (session.repeatQueryOnReload) {
+      info.searchInput.value = session.activeQuery;
       activateSidebarSearch(session.activeQuery);
     } else if (session.activeQueryResults) {
-      showSidebarSearchResults(session.activeQueryResults);
+      considerRecallingRecentSidebarSearch();
     }
   }
 }
@@ -473,9 +608,28 @@ 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;
 
+  if (!query) {
+    return;
+  }
+
   if (state.stoppedTypingTimeout) {
     clearTimeout(state.stoppedTypingTimeout);
     state.stoppedTypingTimeout = null;
@@ -535,6 +689,16 @@ function clearSidebarSearch() {
   hideSidebarSearchResults();
 }
 
+function clearSidebarFilter() {
+  const {session} = info;
+
+  toggleSidebarSearchFilter(session.activeFilterType);
+
+  forEachFilter((_type, filterLink) => {
+    filterLink.classList.remove('shown', 'hidden');
+  });
+}
+
 function updateSidebarSearchStatus() {
   const {state} = info;
 
@@ -621,63 +785,131 @@ 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) {
+    cssProp(info.endSearchRule, 'display', 'block');
+    cssProp(info.endSearchLine, 'display', 'block');
 
-  if (empty(flatResults)) {
+    tidySidebarSearchColumn();
+  } else {
     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);
+
+  const filteredResults =
+    (filterType
+      ? tidiedResults.filter(result => result.referenceType === filterType)
+      : tidiedResults);
+
+  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) {
+    const el = generateSidebarSearchResult(result, filteredResults);
     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;
 
-  restoreSidebarSearchResultsScrollOffset();
+  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');
+  }
 }
 
-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),
@@ -742,9 +974,37 @@ function generateSidebarSearchResult(result) {
       return null;
   }
 
+  const compareReferenceType = otherResult =>
+    otherResult.referenceType === result.referenceType;
+
+  const compareName = otherResult =>
+    getSearchResultName(otherResult) === getSearchResultName(result);
+
+  const ambiguous =
+    results.some(otherResult =>
+      otherResult !== result &&
+      compareReferenceType(otherResult) &&
+      compareName(otherResult));
+
+  if (ambiguous) {
+    preparedSlots.disambiguate =
+      result.data.disambiguator;
+
+    preparedSlots.disambiguatorString =
+      info[result.referenceType + 'ResultDisambiguatorString'];
+  }
+
   return generateSidebarSearchResultTemplate(preparedSlots);
 }
 
+function getSearchResultName(result) {
+  return (
+    result.data.name ??
+    result.data.primaryName ??
+    null
+  );
+}
+
 function getSearchResultImageSource(result) {
   const {artwork} = result.data;
   if (!artwork) return null;
@@ -820,6 +1080,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');
@@ -859,6 +1128,8 @@ function generateSidebarSearchResultTemplate(slots) {
 }
 
 function hideSidebarSearchResults() {
+  cssProp(info.filterContainer, 'display', 'none');
+
   cssProp(info.resultsRule, 'display', 'none');
   cssProp(info.resultsContainer, 'display', 'none');
 
@@ -991,6 +1262,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;
 
@@ -1004,6 +1305,28 @@ function restoreSidebarSearchColumn() {
 
   state.collapsedDetailsForTidiness = [];
   state.tidiedSidebar = null;
+
+  info.searchInput.placeholder = info.standbyInputPlaceholder;
+}
+
+function considerRecallingRecentSidebarSearch() {
+  const {session, state} = info;
+
+  if (document.documentElement.dataset.urlKey === 'localized.home') {
+    return forgetRecentSidebarSearch();
+  }
+
+  info.searchInput.placeholder = session.activeQuery;
+  state.recallingRecentSearch = true;
+}
+
+function forgetRecentSidebarSearch() {
+  const {session} = info;
+
+  session.activeQuery = null;
+  session.activeQueryResults = null;
+
+  clearSidebarFilter();
 }
 
 async function handleDroppedIntoSearchInput(domEvent) {
@@ -1032,7 +1355,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 ae63eab5..b65574d0 100644
--- a/src/static/js/client/sticky-heading.js
+++ b/src/static/js/client/sticky-heading.js
@@ -1,13 +1,19 @@
 /* eslint-env browser */
 
 import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js';
-import {dispatchInternalEvent, templateContent} from '../client-util.js';
+import {cssProp, dispatchInternalEvent, templateContent}
+  from '../client-util.js';
 
 export const info = {
   id: 'stickyHeadingInfo',
 
+  stickyRoots: null,
+
   stickyContainers: null,
+  staticContainers: null,
 
+  stickyHeadingRows: null,
+  stickyHeadings: null,
   stickySubheadingRows: null,
   stickySubheadings: null,
 
@@ -17,21 +23,33 @@ export const info = {
 
   contentContainers: null,
   contentHeadings: null,
+  contentCoverColumns: null,
   contentCovers: null,
   contentCoversReveal: null,
 
+  referenceCollapsedHeading: null,
+
   state: {
     displayedHeading: null,
   },
 
   event: {
     whenDisplayedHeadingChanges: [],
+    whenStuckStatusChanges: [],
   },
 };
 
 export function getPageReferences() {
+  info.stickyRoots =
+    Array.from(document.querySelectorAll('.content-sticky-heading-root:not([inert])'));
+
   info.stickyContainers =
-    Array.from(document.getElementsByClassName('content-sticky-heading-container'));
+    info.stickyRoots
+      .map(el => el.querySelector('.content-sticky-heading-container'));
+
+  info.staticContainers =
+    info.stickyRoots
+      .map(el => el.nextElementSibling);
 
   info.stickyCoverContainers =
     info.stickyContainers
@@ -45,6 +63,14 @@ export function getPageReferences() {
     info.stickyCovers
       .map(el => el?.querySelector('.image-text-area'));
 
+  info.stickyHeadingRows =
+    info.stickyContainers
+      .map(el => el.querySelector('.content-sticky-heading-row'));
+
+  info.stickyHeadings =
+    info.stickyHeadingRows
+      .map(el => el.querySelector('h1'));
+
   info.stickySubheadingRows =
     info.stickyContainers
       .map(el => el.querySelector('.content-sticky-subheading-row'));
@@ -55,11 +81,15 @@ export function getPageReferences() {
 
   info.contentContainers =
     info.stickyContainers
-      .map(el => el.parentElement);
+      .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
@@ -68,6 +98,10 @@ export function getPageReferences() {
   info.contentHeadings =
     info.contentContainers
       .map(el => Array.from(el.querySelectorAll('.content-heading')));
+
+  info.referenceCollapsedHeading =
+    info.stickyHeadings
+      .map(el => el.querySelector('.reference-collapsed-heading'));
 }
 
 export function mutatePageContent() {
@@ -137,15 +171,61 @@ function topOfViewInside(el, scroll = window.scrollY) {
     scroll < el.offsetTop + el.offsetHeight);
 }
 
+function updateStuckStatus(index) {
+  const {event} = info;
+
+  const contentContainer = info.contentContainers[index];
+  const stickyContainer = info.stickyContainers[index];
+
+  const wasStuck = stickyContainer.classList.contains('stuck');
+  const stuck = topOfViewInside(contentContainer);
+
+  if (stuck === wasStuck) return;
+
+  if (stuck) {
+    stickyContainer.classList.add('stuck');
+  } else {
+    stickyContainer.classList.remove('stuck');
+  }
+
+  dispatchInternalEvent(event, 'whenStuckStatusChanges', index, stuck);
+}
+
+function updateCollapseStatus(index) {
+  const stickyContainer = info.stickyContainers[index];
+  const staticContainer = info.staticContainers[index];
+  const stickyHeading = info.stickyHeadings[index];
+  const referenceCollapsedHeading = info.referenceCollapsedHeading[index];
+
+  const {height: uncollapsedHeight} = stickyHeading.getBoundingClientRect();
+  const {height: collapsedHeight} = referenceCollapsedHeading.getBoundingClientRect();
+
+  if (
+    staticContainer.getBoundingClientRect().bottom < 4 ||
+    staticContainer.getBoundingClientRect().top < -80
+  ) {
+    if (!stickyContainer.classList.contains('collapse')) {
+      stickyContainer.classList.add('collapse');
+      cssProp(stickyContainer, '--uncollapsed-heading-height', uncollapsedHeight + 'px');
+      cssProp(stickyContainer, '--collapsed-heading-height', collapsedHeight + 'px');
+    }
+  } else {
+    stickyContainer.classList.remove('collapse');
+  }
+}
+
 function updateStickyCoverVisibility(index) {
   const stickyCoverContainer = info.stickyCoverContainers[index];
-  const contentCover = info.contentCovers[index];
+  const stickyContainer = info.stickyContainers[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 {
       stickyCoverContainer.classList.remove('visible');
+      stickyContainer.classList.remove('cover-visible');
     }
   }
 }
@@ -157,26 +237,27 @@ function getContentHeadingClosestToStickySubheading(index) {
     return null;
   }
 
-  const stickySubheading = info.stickySubheadings[index];
-
-  if (stickySubheading.childNodes.length === 0) {
-    // Supply a non-breaking space to ensure correct basic line height.
-    stickySubheading.appendChild(document.createTextNode('\xA0'));
-  }
-
-  const stickyContainer = info.stickyContainers[index];
-  const stickyRect = stickyContainer.getBoundingClientRect();
+  const stickyHeadingRow = info.stickyHeadingRows[index];
+  const stickyRect = stickyHeadingRow.getBoundingClientRect();
 
-  // TODO: Should this compute with the subheading row instead of h2?
-  const subheadingRect = stickySubheading.getBoundingClientRect();
+  // Subheadings only appear when the sticky heading is collapsed,
+  // so the used bottom edge should always be *as though* it's only
+  // displaying one line of text. Subtract the current discrepancy.
+  const stickyHeading = info.stickyHeadings[index];
+  const referenceCollapsedHeading = info.referenceCollapsedHeading[index];
+  const correctBottomEdge =
+    stickyHeading.getBoundingClientRect().height -
+    referenceCollapsedHeading.getBoundingClientRect().height;
 
-  const stickyBottom = stickyRect.bottom + subheadingRect.height;
+  const stickyBottom =
+    (stickyRect.bottom
+   - correctBottomEdge);
 
   // Iterate from bottom to top of the content area.
   const contentHeadings = info.contentHeadings[index];
   for (const heading of contentHeadings.slice().reverse()) {
     const headingRect = heading.getBoundingClientRect();
-    if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 20) {
+    if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 40) {
       return heading;
     }
   }
@@ -187,7 +268,12 @@ function getContentHeadingClosestToStickySubheading(index) {
 function updateStickySubheadingContent(index) {
   const {event, state} = info;
 
-  const closestHeading = getContentHeadingClosestToStickySubheading(index);
+  const stickyContainer = info.stickyContainers[index];
+
+  const closestHeading =
+    (stickyContainer.classList.contains('collapse')
+      ? getContentHeadingClosestToStickySubheading(index)
+      : null);
 
   if (state.displayedHeading === closestHeading) return;
 
@@ -233,6 +319,8 @@ function updateStickySubheadingContent(index) {
 }
 
 export function updateStickyHeadings(index) {
+  updateStuckStatus(index);
+  updateCollapseStatus(index);
   updateStickyCoverVisibility(index);
   updateStickySubheadingContent(index);
 }
diff --git a/src/static/js/rectangles.js b/src/static/js/rectangles.js
index cdab2cb8..b00ed98e 100644
--- a/src/static/js/rectangles.js
+++ b/src/static/js/rectangles.js
@@ -510,4 +510,46 @@ export class WikiRect extends DOMRect {
       height: this.height,
     });
   }
+
+  // Other utilities
+
+  #display = null;
+
+  display() {
+    if (!this.#display) {
+      this.#display = document.createElement('div');
+      document.body.appendChild(this.#display);
+    }
+
+    Object.assign(this.#display.style, {
+      position: 'fixed',
+      background: '#000c',
+      border: '3px solid var(--primary-color)',
+      borderRadius: '4px',
+      top: this.top + 'px',
+      left: this.left + 'px',
+      width: this.width + 'px',
+      height: this.height + 'px',
+      pointerEvents: 'none',
+    });
+
+    let i = 0;
+    const int = setInterval(() => {
+      i++;
+      if (i >= 3) clearInterval(int);
+      if (!this.#display) return;
+
+      this.#display.style.display = 'none';
+      setTimeout(() => {
+        this.#display.style.display = '';
+      }, 200);
+    }, 600);
+  }
+
+  hide() {
+    if (this.#display) {
+      this.#display.remove();
+      this.#display = null;
+    }
+  }
 }
diff --git a/src/static/js/search-worker.js b/src/static/js/search-worker.js
index 1b4684ad..3e9fbfca 100644
--- a/src/static/js/search-worker.js
+++ b/src/static/js/search-worker.js
@@ -130,7 +130,7 @@ async function loadDatabase() {
 
   try {
     idb = await promisifyIDBRequest(request);
-  } catch (error) {
+  } catch {
     console.warn(`Couldn't load search IndexedDB - won't use an internal cache.`);
     console.warn(request.error);
     idb = null;
@@ -371,58 +371,76 @@ function postActionResult(id, status, value) {
 }
 
 function performSearchAction({query, options}) {
-  const {generic, ...otherIndexes} = indexes;
+  const {queriedKind} = processTerms(query);
+  const genericResults = queryGenericIndex(query, options);
+  const verbatimResults = queryVerbatimIndex(query, options);
 
-  const genericResults =
-    queryGenericIndex(generic, query, options);
+  const verbatimIDs =
+    new Set(verbatimResults?.map(result => result.id));
 
-  const otherResults =
-    withEntries(otherIndexes, entries => entries
-      .map(([indexName, index]) => [
-        indexName,
-        index.search(query, options),
-      ]));
+  const commonResults =
+    (verbatimResults && genericResults
+      ? genericResults
+          .filter(({id}) => verbatimIDs.has(id))
+      : verbatimResults ?? genericResults);
 
   return {
-    generic: genericResults,
-    ...otherResults,
+    results: commonResults,
+    queriedKind,
   };
 }
 
-function queryGenericIndex(index, query, options) {
-  const interestingFieldCombinations = [
-    ['primaryName', 'parentName', 'groups'],
-    ['primaryName', 'parentName'],
-    ['primaryName', 'groups', 'contributors'],
-    ['primaryName', 'groups', 'artTags'],
-    ['primaryName', 'groups'],
-    ['primaryName', 'contributors'],
-    ['primaryName', 'artTags'],
-    ['parentName', 'groups', 'artTags'],
-    ['parentName', 'artTags'],
-    ['groups', 'contributors'],
-    ['groups', 'artTags'],
-
-    // This prevents just matching *everything* tagged "john" if you
-    // only search "john", but it actually supports matching more than
-    // *two* tags at once: "john rose lowas" works! This is thanks to
-    // flexsearch matching multiple field values in a single query.
-    ['artTags', 'artTags'],
-
-    ['contributors', 'parentName'],
-    ['contributors', 'groups'],
-    ['primaryName', 'contributors'],
-    ['primaryName'],
-  ];
+const interestingFieldCombinations = [
+  ['primaryName'],
+
+  ['primaryName', 'parentName', 'groups'],
+  ['primaryName', 'parentName'],
+  ['primaryName', 'groups', 'contributors'],
+  ['primaryName', 'groups', 'artTags'],
+  ['primaryName', 'groups'],
+  ['primaryName', 'contributors'],
+  ['primaryName', 'artTags'],
+  ['parentName', 'groups', 'artTags'],
+  ['parentName', 'artTags'],
+  ['groups', 'contributors'],
+  ['groups', 'artTags'],
+
+  // This prevents just matching *everything* tagged "john" if you
+  // only search "john", but it actually supports matching more than
+  // *two* tags at once: "john rose lowas" works! This is thanks to
+  // flexsearch matching multiple field values in a single query.
+  ['artTags', 'artTags'],
+
+  ['contributors', 'parentName'],
+  ['contributors', 'groups'],
+  ['primaryName', 'contributors'],
+];
+
+function queryGenericIndex(query, options) {
+  return queryIndex({
+    indexKey: 'generic',
+    termsKey: 'genericTerms',
+  }, query, options);
+}
+
+function queryVerbatimIndex(query, options) {
+  return queryIndex({
+    indexKey: 'verbatim',
+    termsKey: 'verbatimTerms',
+  }, query, options);
+}
 
+function queryIndex({termsKey, indexKey}, query, options) {
   const interestingFields =
     unique(interestingFieldCombinations.flat());
 
-  const {genericTerms, queriedKind} =
+  const {[termsKey]: terms, queriedKind} =
     processTerms(query);
 
+  if (empty(terms)) return null;
+
   const particles =
-    particulate(genericTerms);
+    particulate(terms);
 
   const groupedParticles =
     groupArray(particles, ({length}) => length);
@@ -437,7 +455,7 @@ function queryGenericIndex(index, query, options) {
           query: values,
         }));
 
-  const boilerplate = queryBoilerplate(index);
+  const boilerplate = queryBoilerplate(indexes[indexKey]);
 
   const particleResults =
     Object.fromEntries(
@@ -459,62 +477,73 @@ function queryGenericIndex(index, query, options) {
             ])),
       ]));
 
-  const results = new Set();
+  let matchedResults = new Set();
 
   for (const interestingFieldCombination of interestingFieldCombinations) {
     for (const query of queriesBy(interestingFieldCombination)) {
-      const idToMatchingFieldsMap = new Map();
-      for (const {field, query: fieldQuery} of query) {
-        for (const id of particleResults[field][fieldQuery]) {
-          if (idToMatchingFieldsMap.has(id)) {
-            idToMatchingFieldsMap.get(id).push(field);
-          } else {
-            idToMatchingFieldsMap.set(id, [field]);
-          }
-        }
-      }
+      const [firstQueryFieldLine, ...restQueryFieldLines] = query;
 
       const commonAcrossFields =
-        Array.from(idToMatchingFieldsMap.entries())
-          .filter(([id, matchingFields]) =>
-            matchingFields.length === interestingFieldCombination.length)
-          .map(([id]) => id);
+        new Set(
+          particleResults
+            [firstQueryFieldLine.field]
+            [firstQueryFieldLine.query]);
+
+      for (const currQueryFieldLine of restQueryFieldLines) {
+        const tossResults = new Set(commonAcrossFields);
+
+        const keepResults =
+          particleResults
+            [currQueryFieldLine.field]
+            [currQueryFieldLine.query];
+
+        for (const result of keepResults) {
+          tossResults.delete(result);
+        }
+
+        for (const result of tossResults) {
+          commonAcrossFields.delete(result);
+        }
+      }
 
       for (const result of commonAcrossFields) {
-        results.add(result);
+        matchedResults.add(result);
       }
     }
   }
 
-  const constituted =
-    boilerplate.constitute(results);
+  matchedResults = Array.from(matchedResults);
 
-  const constitutedAndFiltered =
-    constituted
-      .filter(({id}) =>
-        (queriedKind
-          ? id.split(':')[0] === queriedKind
-          : true));
+  const filteredResults =
+    (queriedKind
+      ? matchedResults.filter(id => id.split(':')[0] === queriedKind)
+      : matchedResults);
 
-  return constitutedAndFiltered;
+  const constitutedResults =
+    boilerplate.constitute(filteredResults);
+
+  return constitutedResults;
 }
 
 function processTerms(query) {
   const kindTermSpec = [
-    {kind: 'album', terms: ['album']},
-    {kind: 'artist', terms: ['artist']},
-    {kind: 'flash', terms: ['flash']},
-    {kind: 'group', terms: ['group']},
-    {kind: 'tag', terms: ['art tag', 'tag']},
-    {kind: 'track', terms: ['track']},
+    {kind: 'album', terms: ['album', 'albums']},
+    {kind: 'artist', terms: ['artist', 'artists']},
+    {kind: 'flash', terms: ['flash', 'flashes']},
+    {kind: 'group', terms: ['group', 'groups']},
+    {kind: 'tag', terms: ['art tag', 'art tags', 'tag', 'tags']},
+    {kind: 'track', terms: ['track', 'tracks']},
   ];
 
   const genericTerms = [];
+  const verbatimTerms = [];
   let queriedKind = null;
 
   const termRegexp =
     new RegExp(
-      String.raw`(?<kind>${kindTermSpec.flatMap(spec => spec.terms).join('|')})` +
+      String.raw`(?<kind>(?<=^|\s)(?:${kindTermSpec.flatMap(spec => spec.terms).join('|')})(?=$|\s))` +
+      String.raw`|(?<=^|\s)(?<quote>["'])(?<regularVerbatim>.+?)\k<quote>(?=$|\s)` +
+      String.raw`|(?<=^|\s)[“”‘’](?<curlyVerbatim>.+?)[“”‘’](?=$|\s)` +
       String.raw`|[^\s\-]+`,
       'gi');
 
@@ -530,10 +559,16 @@ function processTerms(query) {
       continue;
     }
 
+    const verbatim = groups.regularVerbatim || groups.curlyVerbatim;
+    if (verbatim) {
+      verbatimTerms.push(verbatim);
+      continue;
+    }
+
     genericTerms.push(match[0]);
   }
 
-  return {genericTerms, queriedKind};
+  return {genericTerms, verbatimTerms, queriedKind};
 }
 
 function particulate(terms) {