« 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.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/css-compatibility-assistant.js26
-rw-r--r--src/static/js/client/expandable-gallery-section.js77
-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.js6
-rw-r--r--src/static/js/client/quick-description.js2
-rw-r--r--src/static/js/client/sidebar-search.js333
-rw-r--r--src/static/js/client/sticky-heading.js132
11 files changed, 960 insertions, 88 deletions
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/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-gallery-section.js b/src/static/js/client/expandable-gallery-section.js
new file mode 100644
index 00000000..dc83e8b7
--- /dev/null
+++ b/src/static/js/client/expandable-gallery-section.js
@@ -0,0 +1,77 @@
+/* eslint-env browser */
+
+// TODO: Combine this and quick-description.js
+
+import {cssProp} from '../client-util.js';
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'expandableGallerySectionInfo',
+
+  sections: null,
+
+  sectionContentBelowCut: null,
+
+  sectionExpandoToggles: null,
+
+  sectionExpandCues: null,
+  sectionCollapseCues: null,
+};
+
+export function getPageReferences() {
+  info.sections =
+    Array.from(document.querySelectorAll('.expandable-gallery-section'))
+      .filter(section => section.querySelector('.section-expando-toggle'));
+
+  info.sectionContentBelowCut =
+    info.sections
+      .map(section => section.querySelector('.section-content-below-cut'));
+
+  info.sectionExpandoToggles =
+    info.sections
+      .map(section => section.querySelector('.section-expando-toggle'));
+
+  info.sectionExpandCues =
+    info.sections
+      .map(section => section.querySelector('.section-expand-cue'));
+
+  info.sectionCollapseCues =
+    info.sections
+      .map(section => section.querySelector('.section-collapse-cue'));
+}
+
+export function addPageListeners() {
+  for (const {
+    section,
+    contentBelowCut,
+    expandoToggle,
+    expandCue,
+    collapseCue,
+  } of stitchArrays({
+    section: info.sections,
+    contentBelowCut: info.sectionContentBelowCut,
+    expandoToggle: info.sectionExpandoToggles,
+    expandCue: info.sectionExpandCues,
+    collapseCue: info.sectionCollapseCues,
+  })) {
+    expandoToggle.addEventListener('click', domEvent => {
+      domEvent.preventDefault();
+
+      const collapsed =
+        cssProp(contentBelowCut, 'display') === 'none';
+
+      if (collapsed) {
+        section.classList.add('expanded');
+        cssProp(contentBelowCut, 'display', null);
+        cssProp(expandCue, 'display', 'none');
+        cssProp(collapseCue, 'display', null);
+      } else {
+        section.classList.remove('expanded');
+        cssProp(contentBelowCut, 'display', 'none');
+        cssProp(expandCue, 'display', null);
+        cssProp(collapseCue, 'display', 'none');
+      }
+    });
+  }
+}
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..aeb9264a 100644
--- a/src/static/js/client/index.js
+++ b/src/static/js/client/index.js
@@ -4,10 +4,13 @@ import '../group-contributions-table.js';
 
 import * as additionalNamesBoxModule from './additional-names-box.js';
 import * as albumCommentarySidebarModule from './album-commentary-sidebar.js';
+import * as artTagGalleryFilterModule from './art-tag-gallery-filter.js';
+import * as artTagNetworkModule from './art-tag-network.js';
 import * as artistExternalLinkTooltipModule from './artist-external-link-tooltip.js';
 import * as cssCompatibilityAssistantModule from './css-compatibility-assistant.js';
 import * as datetimestampTooltipModule from './datetimestamp-tooltip.js';
 import * as draggedLinkModule from './dragged-link.js';
+import * as expandableGallerySectionModule from './expandable-gallery-section.js';
 import * as hashLinkModule from './hash-link.js';
 import * as hoverableTooltipModule from './hoverable-tooltip.js';
 import * as imageOverlayModule from './image-overlay.js';
@@ -24,10 +27,13 @@ import * as wikiSearchModule from './wiki-search.js';
 export const modules = [
   additionalNamesBoxModule,
   albumCommentarySidebarModule,
+  artTagGalleryFilterModule,
+  artTagNetworkModule,
   artistExternalLinkTooltipModule,
   cssCompatibilityAssistantModule,
   datetimestampTooltipModule,
   draggedLinkModule,
+  expandableGallerySectionModule,
   hashLinkModule,
   hoverableTooltipModule,
   imageOverlayModule,
diff --git a/src/static/js/client/quick-description.js b/src/static/js/client/quick-description.js
index cff82252..6a7a6023 100644
--- a/src/static/js/client/quick-description.js
+++ b/src/static/js/client/quick-description.js
@@ -1,5 +1,7 @@
 /* eslint-env browser */
 
+// TODO: Combine this and expandable-gallery-section.js
+
 import {stitchArrays} from '../../shared-util/sugar.js';
 
 export const info = {
diff --git a/src/static/js/client/sidebar-search.js b/src/static/js/client/sidebar-search.js
index c79fb837..eae1e74e 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,22 @@ export const info = {
   groupResultKindString: null,
   tagResultKindString: 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 +112,10 @@ export const info = {
       maxLength: settings => settings.maxActiveResultsStorage,
     },
 
+    activeFilterType: {
+      type: 'string',
+    },
+
     repeatQueryOnReload: {
       type: 'boolean',
       default: false,
@@ -133,6 +157,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 +195,24 @@ export function getPageReferences() {
 
   info.tagResultKindString =
     findString('tag-result-kind');
+
+  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 +302,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 +387,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 +440,7 @@ export function addPageListeners() {
     const {settings, state} = info;
 
     if (!info.searchInput.value) {
-      clearSidebarSearch();
+      clearSidebarSearch(); // ...but don't clear filter
       return;
     }
 
@@ -388,10 +502,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 +534,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 +595,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 +676,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,54 +772,122 @@ 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) {
+  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);
     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) {
@@ -859,6 +1078,8 @@ function generateSidebarSearchResultTemplate(slots) {
 }
 
 function hideSidebarSearchResults() {
+  cssProp(info.filterContainer, 'display', 'none');
+
   cssProp(info.resultsRule, 'display', 'none');
   cssProp(info.resultsContainer, 'display', 'none');
 
@@ -991,6 +1212,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 +1255,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 +1305,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);
 }