« get me outta code hell

Merge branch 'commentary-sidebar' into preview - hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
author(quasar) nebula <qznebula@protonmail.com>2023-10-06 08:12:15 -0300
committer(quasar) nebula <qznebula@protonmail.com>2023-10-06 08:12:15 -0300
commitbc68ac93a4dfc1f80209ea26e0af333ddc25d6b3 (patch)
treea21dfeb824390d0faf4f2f808f5425e8dcfe05b7 /src
parent97d9a13846654b8fa5b7520254f0f5ebc575305b (diff)
parent33f622ca94cdac2b7b6b1d3bbd57a96248e57035 (diff)
Merge branch 'commentary-sidebar' into preview
Diffstat (limited to 'src')
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js39
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackSection.js24
-rw-r--r--src/content/dependencies/generateCoverArtwork.js15
-rw-r--r--src/static/client2.js964
-rw-r--r--src/static/site4.css35
5 files changed, 822 insertions, 255 deletions
diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js
index 5979ed3f..5c057b80 100644
--- a/src/content/dependencies/generateAlbumCommentaryPage.js
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -2,10 +2,13 @@ import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateAlbumCoverArtwork',
     'generateAlbumNavAccent',
+    'generateAlbumSidebarTrackSection',
     'generateAlbumStyleRules',
     'generateColorStyleVariables',
     'generateContentHeading',
+    'generateTrackCoverArtwork',
     'generatePageLayout',
     'linkAlbum',
     'linkTrack',
@@ -30,6 +33,9 @@ export default {
       relation('generateAlbumNavAccent', album, null);
 
     if (album.commentary) {
+      relations.albumCommentaryCover =
+        relation('generateAlbumCoverArtwork', album);
+
       relations.albumCommentaryContent =
         relation('transformContent', album.commentary);
     }
@@ -46,6 +52,13 @@ export default {
       tracksWithCommentary
         .map(track => relation('linkTrack', track));
 
+    relations.trackCommentaryCovers =
+      tracksWithCommentary
+        .map(track =>
+          (track.hasUniqueCoverArt
+            ? relation('generateTrackCoverArtwork', track)
+            : null));
+
     relations.trackCommentaryContent =
       tracksWithCommentary
         .map(track => relation('transformContent', track.commentary));
@@ -57,6 +70,13 @@ export default {
             ? null
             : relation('generateColorStyleVariables')));
 
+    relations.sidebarAlbumLink =
+      relation('linkAlbum', album);
+
+    relations.sidebarTrackSections =
+      album.trackSections.map(trackSection =>
+        relation('generateAlbumSidebarTrackSection', album, null, trackSection));
+
     return relations;
   },
 
@@ -129,6 +149,9 @@ export default {
               {class: ['content-heading']},
               language.$('albumCommentaryPage.entry.title.albumCommentary')),
 
+            relations.albumCommentaryCover
+              ?.slots({mode: 'commentary'}),
+
             html.tag('blockquote',
               relations.albumCommentaryContent),
           ],
@@ -137,15 +160,19 @@ export default {
             heading: relations.trackCommentaryHeadings,
             link: relations.trackCommentaryLinks,
             directory: data.trackCommentaryDirectories,
+            cover: relations.trackCommentaryCovers,
             content: relations.trackCommentaryContent,
             colorVariables: relations.trackCommentaryColorVariables,
             color: data.trackCommentaryColors,
-          }).map(({heading, link, directory, content, colorVariables, color}) => [
+          }).map(({heading, link, directory, cover, content, colorVariables, color}) => [
               heading.slots({
                 tag: 'h3',
                 id: directory,
                 title: link,
               }),
+
+              cover?.slots({mode: 'commentary'}),
+
               html.tag('blockquote',
                 (color
                   ? {style: colorVariables.slot('color', color).content}
@@ -170,6 +197,16 @@ export default {
               }),
           },
         ],
+
+        leftSidebarStickyMode: 'column',
+        leftSidebarContent: [
+          html.tag('h1', relations.sidebarAlbumLink),
+          relations.sidebarTrackSections.map(section =>
+            section.slots({
+              anchor: true,
+              open: true,
+            })),
+        ],
       });
   },
 };
diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js
index d71b0bdb..00e9b621 100644
--- a/src/content/dependencies/generateAlbumSidebarTrackSection.js
+++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js
@@ -33,10 +33,19 @@ export default {
       }
     }
 
+    data.trackDirectories =
+      trackSection.tracks
+        .map(track => track.directory);
+
     return data;
   },
 
-  generate(data, relations, {getColors, html, language}) {
+  slots: {
+    anchor: {type: 'boolean'},
+    open: {type: 'boolean'},
+  },
+
+  generate(data, relations, slots, {getColors, html, language}) {
     const sectionName =
       html.tag('span', {class: 'group-name'},
         (data.isDefaultTrackSection
@@ -59,7 +68,13 @@ export default {
               'current',
           },
           language.$('albumSidebar.trackList.item', {
-            track: trackLink,
+            track:
+              (slots.anchor
+                ? trackLink.slots({
+                    anchor: true,
+                    hash: data.trackDirectories[index],
+                  })
+                : trackLink),
           })));
 
     return html.tag('details',
@@ -67,6 +82,11 @@ export default {
         class: data.includesCurrentTrack && 'current',
 
         open: (
+          // Allow forcing open via a template slot.
+          // This isn't exactly janky, but the rest of this function
+          // kind of is when you contextualize it in a template...
+          slots.open ||
+
           // Leave sidebar track sections collapsed on album info page,
           // since there's already a view of the full track listing
           // in the main content area.
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index 4060c6b0..aeba97de 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -32,7 +32,7 @@ export default {
     },
 
     mode: {
-      validate: v => v.is('primary', 'thumbnail'),
+      validate: v => v.is('primary', 'thumbnail', 'commentary'),
       default: 'primary',
     },
   },
@@ -73,6 +73,19 @@ export default {
             square: true,
           });
 
+      case 'commentary':
+        return relations.image
+          .slots({
+            path: slots.path,
+            alt: slots.alt,
+            thumb: 'medium',
+            class: 'commentary-art',
+            reveal: true,
+            link: true,
+            square: true,
+            lazy: true,
+          });
+
       default:
         return html.blank();
     }
diff --git a/src/static/client2.js b/src/static/client2.js
index d9afcb03..4f4a7153 100644
--- a/src/static/client2.js
+++ b/src/static/client2.js
@@ -6,13 +6,28 @@
 // ephemeral nature.
 
 import {getColors} from '../util/colors.js';
-import {getArtistNumContributions} from '../util/wiki-data.js';
+import {empty, stitchArrays} from '../util/sugar.js';
+
+import {
+  filterMultipleArrays,
+  getArtistNumContributions,
+} from '../util/wiki-data.js';
 
 let albumData, artistData;
 let officialAlbumData, fandomAlbumData, beyondAlbumData;
 
 let ready = false;
 
+const clientInfo = window.hsmusicClientInfo = Object.create(null);
+
+const clientSteps = {
+  getPageReferences: [],
+  addInternalListeners: [],
+  mutatePageContent: [],
+  initializeState: [],
+  addPageListeners: [],
+};
+
 // Localiz8tion nonsense ----------------------------------
 
 const language = document.documentElement.getAttribute('lang');
@@ -86,113 +101,148 @@ function fetchData(type, directory) {
 
 // JS-based links -----------------------------------------
 
-for (const a of document.body.querySelectorAll('[data-random]')) {
-  a.addEventListener('click', (evt) => {
-    if (!ready) {
-      evt.preventDefault();
-      return;
-    }
+const scriptedLinkInfo = clientInfo.scriptedLinkInfo = {
+  randomLinks: null,
+  revealLinks: null,
 
-    const tracks = albumData =>
-      albumData
-        .map(album => album.tracks)
-        .reduce((acc, tracks) => acc.concat(tracks), []);
+  nextLink: null,
+  previousLink: null,
+  randomLink: null,
+};
 
-    setTimeout(() => {
-      a.href = rebase('js-disabled');
-    });
+function getScriptedLinkReferences() {
+  scriptedLinkInfo.randomLinks =
+    document.querySelectorAll('[data-random]');
 
-    switch (a.dataset.random) {
-      case 'album':
-        a.href = openAlbum(pick(albumData).directory);
-        break;
-
-      case 'album-in-official':
-        a.href = openAlbum(pick(officialAlbumData).directory);
-        break;
-
-      case 'album-in-fandom':
-        a.href = openAlbum(pick(fandomAlbumData).directory);
-        break;
-
-      case 'album-in-beyond':
-        a.href = openAlbum(pick(beyondAlbumData).directory);
-        break;
-
-      case 'track':
-        a.href = openTrack(getRefDirectory(pick(tracks(albumData))));
-        break;
-
-      case 'track-in-album':
-        a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks)));
-        break;
-
-      case 'track-in-official':
-        a.href = openTrack(getRefDirectory(pick(tracks(officialAlbumData))));
-        break;
-
-      case 'track-in-fandom':
-        a.href = openTrack(getRefDirectory(pick(tracks(fandomAlbumData))));
-        break;
-
-      case 'track-in-beyond':
-        a.href = openTrack(getRefDirectory(pick(tracks(beyondAlbumData))));
-        break;
-
-      case 'artist':
-        a.href = openArtist(pick(artistData).directory);
-        break;
-
-      case 'artist-more-than-one-contrib':
-        a.href =
-          openArtist(
-            pick(artistData.filter((artist) => getArtistNumContributions(artist) > 1))
-              .directory);
-        break;
-    }
-  });
+  scriptedLinkInfo.revealLinks =
+    document.getElementsByClassName('reveal');
+
+  scriptedLinkInfo.nextNavLink =
+    document.getElementById('next-button');
+
+  scriptedLinkInfo.previousNavLink =
+    document.getElementById('previous-button');
+
+  scriptedLinkInfo.randomNavLink =
+    document.getElementById('random-button');
 }
 
-const next = document.getElementById('next-button');
-const previous = document.getElementById('previous-button');
-const random = document.getElementById('random-button');
+function addRandomLinkListeners() {
+  for (const a of scriptedLinkInfo.randomLinks ?? []) {
+    a.addEventListener('click', evt => {
+      if (!ready) {
+        evt.preventDefault();
+        return;
+      }
 
-const prependTitle = (el, prepend) => {
-  const existing = el.getAttribute('title');
-  if (existing) {
-    el.setAttribute('title', prepend + ' ' + existing);
-  } else {
-    el.setAttribute('title', prepend);
-  }
-};
+      const tracks = albumData =>
+        albumData
+          .map(album => album.tracks)
+          .reduce((acc, tracks) => acc.concat(tracks), []);
 
-if (next) prependTitle(next, '(Shift+N)');
-if (previous) prependTitle(previous, '(Shift+P)');
-if (random) prependTitle(random, '(Shift+R)');
-
-document.addEventListener('keypress', (event) => {
-  if (event.shiftKey) {
-    if (event.charCode === 'N'.charCodeAt(0)) {
-      if (next) next.click();
-    } else if (event.charCode === 'P'.charCodeAt(0)) {
-      if (previous) previous.click();
-    } else if (event.charCode === 'R'.charCodeAt(0)) {
-      if (random && ready) random.click();
-    }
+      setTimeout(() => {
+        a.href = rebase('js-disabled');
+      });
+
+      switch (a.dataset.random) {
+        case 'album':
+          a.href = openAlbum(pick(albumData).directory);
+          break;
+
+        case 'album-in-official':
+          a.href = openAlbum(pick(officialAlbumData).directory);
+          break;
+
+        case 'album-in-fandom':
+          a.href = openAlbum(pick(fandomAlbumData).directory);
+          break;
+
+        case 'album-in-beyond':
+          a.href = openAlbum(pick(beyondAlbumData).directory);
+          break;
+
+        case 'track':
+          a.href = openTrack(getRefDirectory(pick(tracks(albumData))));
+          break;
+
+        case 'track-in-album':
+          a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks)));
+          break;
+
+        case 'track-in-official':
+          a.href = openTrack(getRefDirectory(pick(tracks(officialAlbumData))));
+          break;
+
+        case 'track-in-fandom':
+          a.href = openTrack(getRefDirectory(pick(tracks(fandomAlbumData))));
+          break;
+
+        case 'track-in-beyond':
+          a.href = openTrack(getRefDirectory(pick(tracks(beyondAlbumData))));
+          break;
+
+        case 'artist':
+          a.href = openArtist(pick(artistData).directory);
+          break;
+
+        case 'artist-more-than-one-contrib':
+          a.href =
+            openArtist(
+              pick(artistData.filter((artist) => getArtistNumContributions(artist) > 1))
+                .directory);
+          break;
+      }
+    });
   }
-});
+}
+
+function mutateNavigationLinkContent() {
+  const prependTitle = (el, prepend) =>
+    el?.setAttribute('title',
+      (el.hasAttribute('title')
+        ? prepend + ' ' + el.getAttribute('title')
+        : prepend));
 
-for (const reveal of document.querySelectorAll('.reveal')) {
-  reveal.addEventListener('click', (event) => {
-    if (!reveal.classList.contains('revealed')) {
-      reveal.classList.add('revealed');
-      event.preventDefault();
-      event.stopPropagation();
-      reveal.dispatchEvent(new CustomEvent('hsmusic-reveal'));
+  prependTitle(scriptedLinkInfo.nextNavLink, '(Shift+N)');
+  prependTitle(scriptedLinkInfo.previousNavLink, '(Shift+P)');
+  prependTitle(scriptedLinkInfo.randomNavLink, '(Shift+R)');
+}
+
+function addNavigationKeyPressListeners() {
+  document.addEventListener('keypress', (event) => {
+    if (event.shiftKey) {
+      if (event.charCode === 'N'.charCodeAt(0)) {
+        scriptedLinkInfo.nextNavLink?.click();
+      } else if (event.charCode === 'P'.charCodeAt(0)) {
+        scriptedLinkInfo.previousNavLink?.click();
+      } else if (event.charCode === 'R'.charCodeAt(0)) {
+        if (ready) {
+          scriptedLinkInfo.randomNavLink?.click();
+        }
+      }
     }
   });
 }
 
+function addRevealLinkClickListeners() {
+  for (const reveal of scriptedLinkInfo.revealLinks ?? []) {
+    reveal.addEventListener('click', (event) => {
+      if (!reveal.classList.contains('revealed')) {
+        reveal.classList.add('revealed');
+        event.preventDefault();
+        event.stopPropagation();
+        reveal.dispatchEvent(new CustomEvent('hsmusic-reveal'));
+      }
+    });
+  }
+}
+
+clientSteps.getPageReferences.push(getScriptedLinkReferences);
+clientSteps.addPageListeners.push(addRandomLinkListeners);
+clientSteps.addPageListeners.push(addNavigationKeyPressListeners);
+clientSteps.addPageListeners.push(addRevealLinkClickListeners);
+clientSteps.mutatePageContent.push(mutateNavigationLinkContent);
+
 const elements1 = document.getElementsByClassName('js-hide-once-data');
 const elements2 = document.getElementsByClassName('js-show-once-data');
 
@@ -454,205 +504,393 @@ if (localStorage.tryInfoCards) {
 
 // Custom hash links --------------------------------------
 
-function addHashLinkHandlers() {
+const hashLinkInfo = clientInfo.hashLinkInfo = {
+  links: null,
+  hrefs: null,
+  targets: null,
+
+  state: {
+    highlightedTarget: null,
+    scrollingAfterClick: false,
+    concludeScrollingStateInterval: null,
+  },
+
+  event: {
+    whenHashLinkClicked: [],
+  },
+};
+
+function getHashLinkReferences() {
+  const info = hashLinkInfo;
+
+  info.links =
+    Array.from(document.querySelectorAll('a[href^="#"]:not([href="#"])'));
+
+  info.hrefs =
+    info.links
+      .map(link => link.getAttribute('href'));
+
+  info.targets =
+    info.hrefs
+      .map(href => document.getElementById(href.slice(1)));
+
+  filterMultipleArrays(
+    info.links,
+    info.hrefs,
+    info.targets,
+    (_link, _href, target) => target);
+}
+
+function processScrollingAfterHashLinkClicked() {
+  const {state} = hashLinkInfo;
+
+  if (state.concludeScrollingStateInterval) return;
+
+  let lastScroll = window.scrollY;
+  state.scrollingAfterClick = true;
+  state.concludeScrollingStateInterval = setInterval(() => {
+    if (Math.abs(window.scrollY - lastScroll) < 10) {
+      clearInterval(state.concludeScrollingStateInterval);
+      state.scrollingAfterClick = false;
+      state.concludeScrollingStateInterval = null;
+    } else {
+      lastScroll = window.scrollY;
+    }
+  }, 200);
+}
+
+function addHashLinkListeners() {
   // Instead of defining a scroll offset (to account for the sticky heading)
   // in JavaScript, we interface with the CSS property 'scroll-margin-top'.
   // This lets the scroll offset be consolidated where it makes sense, and
   // sets an appropriate offset when (re)loading a page with hash for free!
 
-  let wasHighlighted;
-
-  for (const a of document.links) {
-    const href = a.getAttribute('href');
-    if (!href || !href.startsWith('#')) {
-      continue;
-    }
+  const info = hashLinkInfo;
+  const {state, event} = info;
 
-    a.addEventListener('click', handleHashLinkClicked);
-  }
+  for (const {hashLink, href, target} of stitchArrays({
+    hashLink: info.links,
+    href: info.hrefs,
+    target: info.targets,
+  })) {
+    hashLink.addEventListener('click', evt => {
+      if (evt.metaKey || evt.shiftKey || evt.ctrlKey || evt.altKey) {
+        return;
+      }
 
-  function handleHashLinkClicked(evt) {
-    if (evt.metaKey || evt.shiftKey || evt.ctrlKey || evt.altKey) {
-      return;
-    }
+      // Hide skipper box right away, so the layout is updated on time for the
+      // math operations coming up next.
+      const skipper = document.getElementById('skippers');
+      skipper.style.display = 'none';
+      setTimeout(() => skipper.style.display = '');
 
-    const href = evt.target.getAttribute('href');
-    const id = href.slice(1);
-    const linked = document.getElementById(id);
+      const box = target.getBoundingClientRect();
+      const style = window.getComputedStyle(target);
 
-    if (!linked) {
-      return;
-    }
+      const scrollY =
+          window.scrollY
+        + box.top
+        - style['scroll-margin-top'].replace('px', '');
 
-    // Hide skipper box right away, so the layout is updated on time for the
-    // math operations coming up next.
-    const skipper = document.getElementById('skippers');
-    skipper.style.display = 'none';
-    setTimeout(() => skipper.style.display = '');
+      evt.preventDefault();
+      history.pushState({}, '', href);
+      window.scrollTo({top: scrollY, behavior: 'smooth'});
+      target.focus({preventScroll: true});
 
-    const box = linked.getBoundingClientRect();
-    const style = window.getComputedStyle(linked);
+      const maxScroll =
+          document.body.scrollHeight
+        - window.innerHeight;
 
-    const scrollY =
-        window.scrollY
-      + box.top
-      - style['scroll-margin-top'].replace('px', '');
+      if (scrollY > maxScroll && target.classList.contains('content-heading')) {
+        if (state.highlightedTarget) {
+          state.highlightedTarget.classList.remove('highlight-hash-link');
+        }
 
-    evt.preventDefault();
-    history.pushState({}, '', href);
-    window.scrollTo({top: scrollY, behavior: 'smooth'});
-    linked.focus({preventScroll: true});
+        target.classList.add('highlight-hash-link');
+        state.highlightedTarget = target;
+      }
 
-    const maxScroll =
-        document.body.scrollHeight
-      - window.innerHeight;
+      processScrollingAfterHashLinkClicked();
 
-    if (scrollY > maxScroll && linked.classList.contains('content-heading')) {
-      if (wasHighlighted) {
-        wasHighlighted.classList.remove('highlight-hash-link');
+      for (const handler of event.whenHashLinkClicked) {
+        handler({
+          link: hashLink,
+        });
       }
+    });
+  }
 
-      wasHighlighted = linked;
-      linked.classList.add('highlight-hash-link');
-      linked.addEventListener('animationend', function handle(evt) {
-        if (evt.animationName === 'highlight-hash-link') {
-          linked.removeEventListener('animationend', handle);
-          linked.classList.remove('highlight-hash-link');
-          wasHighlighted = null;
-        }
-      });
-    }
+  for (const target of info.targets) {
+    target.addEventListener('animationend', evt => {
+      if (evt.animationName !== 'highlight-hash-link') return;
+      target.classList.remove('highlight-hash-link');
+      if (target !== state.highlightedTarget) return;
+      state.highlightedTarget = null;
+    });
   }
 }
 
-addHashLinkHandlers();
+clientSteps.getPageReferences.push(getHashLinkReferences);
+clientSteps.addPageListeners.push(addHashLinkListeners);
 
 // Sticky content heading ---------------------------------
 
-const stickyHeadingInfo = Array.from(document.querySelectorAll('.content-sticky-heading-container'))
-  .map(stickyContainer => {
-    const {parentElement: contentContainer} = stickyContainer;
-    const stickySubheadingRow = stickyContainer.querySelector('.content-sticky-subheading-row');
-    const stickySubheading = stickySubheadingRow.querySelector('h2');
-    let stickyCoverContainer = stickyContainer.querySelector('.content-sticky-heading-cover-container');
-    let stickyCover = stickyCoverContainer?.querySelector('.content-sticky-heading-cover');
-    const contentHeadings = Array.from(contentContainer.querySelectorAll('.content-heading'));
-    const contentCover = contentContainer.querySelector('#cover-art-container');
-
-    if (stickyCover?.querySelector('.image-text-area')) {
-      stickyCoverContainer.remove();
-      stickyCoverContainer = null;
-      stickyCover = null;
-    }
+const stickyHeadingInfo = clientInfo.stickyHeadingInfo = {
+  stickyContainers: null,
 
-    return {
-      contentContainer,
-      contentCover,
-      contentHeadings,
-      stickyContainer,
-      stickyCover,
-      stickyCoverContainer,
-      stickySubheading,
-      stickySubheadingRow,
-      state: {
-        displayedHeading: null,
-      },
-    };
-  });
+  stickySubheadingRows: null,
+  stickySubheadings: null,
 
-const topOfViewInside = (el, scroll = window.scrollY) => (
-  scroll > el.offsetTop &&
-  scroll < el.offsetTop + el.offsetHeight
-);
-
-function prepareStickyHeadings() {
-  for (const {
-    contentCover,
-    stickyCover,
-  } of stickyHeadingInfo) {
-    const coverRevealImage = contentCover?.querySelector('.reveal');
-    if (coverRevealImage) {
-      stickyCover.classList.add('content-sticky-heading-cover-needs-reveal');
-      coverRevealImage.addEventListener('hsmusic-reveal', () => {
-        stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal');
-      });
+  stickyCoverContainers: null,
+  stickyCoverTextAreas: null,
+  stickyCovers: null,
+
+  contentContainers: null,
+  contentHeadings: null,
+  contentCovers: null,
+  contentCoversReveal: null,
+
+  state: {
+    displayedHeading: null,
+  },
+
+  event: {
+    whenDisplayedHeadingChanges: [],
+  },
+};
+
+function getStickyHeadingReferences() {
+  const info = stickyHeadingInfo;
+
+  info.stickyContainers =
+    Array.from(document.getElementsByClassName('content-sticky-heading-container'));
+
+  info.stickyCoverContainers =
+    info.stickyContainers
+      .map(el => el.querySelector('.content-sticky-heading-cover-container'));
+
+  info.stickyCovers =
+    info.stickyCoverContainers
+      .map(el => el?.querySelector('.content-sticky-heading-cover'));
+
+  info.stickyCoverTextAreas =
+    info.stickyCovers
+      .map(el => el?.querySelector('.image-text-area'));
+
+  info.stickySubheadingRows =
+    info.stickyContainers
+      .map(el => el.querySelector('.content-sticky-subheading-row'));
+
+  info.stickySubheadings =
+    info.stickySubheadingRows
+      .map(el => el.querySelector('h2'));
+
+  info.contentContainers =
+    info.stickyContainers
+      .map(el => el.parentElement);
+
+  info.contentCovers =
+    info.contentContainers
+      .map(el => el.querySelector('#cover-art-container'));
+
+  info.contentCoversReveal =
+    info.contentCovers
+      .map(el => el ? !!el.querySelector('.reveal') : null);
+
+  info.contentHeadings =
+    info.contentContainers
+      .map(el => Array.from(el.querySelectorAll('.content-heading')));
+}
+
+function removeTextPlaceholderStickyHeadingCovers() {
+  const info = stickyHeadingInfo;
+
+  const hasTextArea =
+    info.stickyCoverTextAreas.map(el => !!el);
+
+  const coverContainersWithTextArea =
+    info.stickyCoverContainers
+      .filter((_el, index) => hasTextArea[index]);
+
+  for (const el of coverContainersWithTextArea) {
+    el.remove();
+  }
+
+  info.stickyCoverContainers =
+    info.stickyCoverContainers
+      .map((el, index) => hasTextArea[index] ? null : el);
+
+  info.stickyCovers =
+    info.stickyCovers
+      .map((el, index) => hasTextArea[index] ? null : el);
+
+  info.stickyCoverTextAreas =
+    info.stickyCoverTextAreas
+      .slice()
+      .fill(null);
+}
+
+function addRevealClassToStickyHeadingCovers() {
+  const info = stickyHeadingInfo;
+
+  const stickyCoversWhichReveal =
+    info.stickyCovers
+      .filter((_el, index) => info.contentCoversReveal[index]);
+
+  for (const el of stickyCoversWhichReveal) {
+    el.classList.add('content-sticky-heading-cover-needs-reveal');
+  }
+}
+
+function addRevealListenersForStickyHeadingCovers() {
+  const info = stickyHeadingInfo;
+
+  const stickyCovers = info.stickyCovers.slice();
+  const contentCovers = info.contentCovers.slice();
+
+  filterMultipleArrays(
+    stickyCovers,
+    contentCovers,
+    (_stickyCover, _contentCover, index) => info.contentCoversReveal[index]);
+
+  for (const {stickyCover, contentCover} of stitchArrays({
+    stickyCover: stickyCovers,
+    contentCover: contentCovers,
+  })) {
+    // TODO: Janky - should use internal event instead of DOM event
+    contentCover.querySelector('.reveal').addEventListener('hsmusic-reveal', () => {
+      stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal');
+    });
+  }
+}
+
+function topOfViewInside(el, scroll = window.scrollY) {
+  return (
+    scroll > el.offsetTop &&
+    scroll < el.offsetTop + el.offsetHeight);
+}
+
+function updateStickyCoverVisibility(index) {
+  const info = stickyHeadingInfo;
+
+  const stickyCoverContainer = info.stickyCoverContainers[index];
+  const contentCover = info.contentCovers[index];
+
+  if (contentCover && stickyCoverContainer) {
+    if (contentCover.getBoundingClientRect().bottom < 0) {
+      stickyCoverContainer.classList.add('visible');
+    } else {
+      stickyCoverContainer.classList.remove('visible');
     }
   }
 }
 
-function updateStickyHeading() {
-  for (const {
-    contentContainer,
-    contentCover,
-    contentHeadings,
-    stickyContainer,
-    stickyCoverContainer,
-    stickySubheading,
-    stickySubheadingRow,
-    state,
-  } of stickyHeadingInfo) {
-    let closestHeading = null;
+function getContentHeadingClosestToStickySubheading(index) {
+  const info = stickyHeadingInfo;
 
-    if (contentCover && stickyCoverContainer) {
-      if (contentCover.getBoundingClientRect().bottom < 0) {
-        stickyCoverContainer.classList.add('visible');
-      } else {
-        stickyCoverContainer.classList.remove('visible');
-      }
+  const contentContainer = info.contentContainers[index];
+
+  if (!topOfViewInside(contentContainer)) {
+    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();
+
+  // TODO: Should this compute with the subheading row instead of h2?
+  const subheadingRect = stickySubheading.getBoundingClientRect();
+
+  const stickyBottom = stickyRect.bottom + subheadingRect.height;
+
+  // 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) {
+      return heading;
     }
+  }
 
-    if (topOfViewInside(contentContainer)) {
-      if (stickySubheading.childNodes.length === 0) {
-        // &nbsp; to ensure correct basic line height
-        stickySubheading.appendChild(document.createTextNode('\xA0'));
-      }
+  return null;
+}
 
-      const stickyRect = stickyContainer.getBoundingClientRect();
-      const subheadingRect = stickySubheading.getBoundingClientRect();
-      const stickyBottom = stickyRect.bottom + subheadingRect.height;
-
-      // This array is reversed so that we're starting from the bottom when
-      // iterating over it.
-      for (let i = contentHeadings.length - 1; i >= 0; i--) {
-        const heading = contentHeadings[i];
-        const headingRect = heading.getBoundingClientRect();
-        if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 20) {
-          closestHeading = heading;
-          break;
+function updateStickySubheadingContent(index) {
+  const info = stickyHeadingInfo;
+  const {event, state} = info;
+
+  const closestHeading = getContentHeadingClosestToStickySubheading(index);
+
+  if (state.displayedHeading === closestHeading) return;
+
+  const stickySubheadingRow = info.stickySubheadingRows[index];
+
+  if (closestHeading) {
+    const stickySubheading = info.stickySubheadings[index];
+
+    // Array.from needed to iterate over a live array with for..of
+    for (const child of Array.from(stickySubheading.childNodes)) {
+      child.remove();
+    }
+
+    for (const child of closestHeading.childNodes) {
+      if (child.tagName === 'A') {
+        for (const grandchild of child.childNodes) {
+          stickySubheading.appendChild(grandchild.cloneNode(true));
         }
+      } else {
+        stickySubheading.appendChild(child.cloneNode(true));
       }
     }
 
-    if (state.displayedHeading !== closestHeading) {
-      if (closestHeading) {
-        // Array.from needed to iterate over a live array with for..of
-        for (const child of Array.from(stickySubheading.childNodes)) {
-          child.remove();
-        }
+    stickySubheadingRow.classList.add('visible');
+  } else {
+    stickySubheadingRow.classList.remove('visible');
+  }
 
-        for (const child of closestHeading.childNodes) {
-          if (child.tagName === 'A') {
-            for (const grandchild of child.childNodes) {
-              stickySubheading.appendChild(grandchild.cloneNode(true));
-            }
-          } else {
-            stickySubheading.appendChild(child.cloneNode(true));
-          }
-        }
+  const oldDisplayedHeading = state.displayedHeading;
 
-        stickySubheadingRow.classList.add('visible');
-      } else {
-        stickySubheadingRow.classList.remove('visible');
-      }
+  state.displayedHeading = closestHeading;
 
-      state.displayedHeading = closestHeading;
-    }
+  for (const handler of event.whenDisplayedHeadingChanges) {
+    handler(index, {
+      oldHeading: oldDisplayedHeading,
+      newHeading: closestHeading,
+    });
   }
 }
 
-document.addEventListener('scroll', updateStickyHeading);
-prepareStickyHeadings();
-updateStickyHeading();
+function updateStickyHeadings(index) {
+  updateStickyCoverVisibility(index);
+  updateStickySubheadingContent(index);
+}
+
+function initializeStateForStickyHeadings() {
+  for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) {
+    updateStickyHeadings(i);
+  }
+}
+
+function addScrollListenerForStickyHeadings() {
+  document.addEventListener('scroll', () => {
+    for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) {
+      updateStickyHeadings(i);
+    }
+  });
+}
+
+clientSteps.getPageReferences.push(getStickyHeadingReferences);
+clientSteps.mutatePageContent.push(removeTextPlaceholderStickyHeadingCovers);
+clientSteps.mutatePageContent.push(addRevealClassToStickyHeadingCovers);
+clientSteps.initializeState.push(initializeStateForStickyHeadings);
+clientSteps.addPageListeners.push(addRevealListenersForStickyHeadingCovers);
+clientSteps.addPageListeners.push(addScrollListenerForStickyHeadings);
 
 // Image overlay ------------------------------------------
 
@@ -970,3 +1208,227 @@ for (const info of groupContributionsTableInfo) {
     sortGroupContributionsTableBy(info, 'count');
   });
 }
+
+// Sticky commentary sidebar ------------------------------
+
+const albumCommentarySidebarInfo = clientInfo.albumCommentarySidebarInfo = {
+  sidebar: null,
+
+  sidebarTrackLinks: null,
+  sidebarTrackDirectories: null,
+
+  sidebarTrackSections: null,
+  sidebarTrackSectionStartIndices: null,
+
+  state: {
+    currentTrackSection: null,
+    currentTrackLink: null,
+    justChangedTrackSection: false,
+  },
+};
+
+function getAlbumCommentarySidebarReferences() {
+  const info = albumCommentarySidebarInfo;
+
+  info.sidebar =
+    document.getElementById('sidebar-left');
+
+  info.sidebarHeading =
+    info.sidebar.querySelector('h1');
+
+  info.sidebarTrackLinks =
+    Array.from(info.sidebar.querySelectorAll('li a'));
+
+  info.sidebarTrackDirectories =
+    info.sidebarTrackLinks
+      .map(el => el.getAttribute('href').slice(1));
+
+  info.sidebarTrackSections =
+    Array.from(info.sidebar.getElementsByTagName('details'));
+
+  info.sidebarTrackSectionStartIndices =
+    info.sidebarTrackSections
+      .map(details => details.querySelector('ol, ul'))
+      .reduce(
+        (accumulator, _list, index, array) =>
+          (empty(accumulator)
+            ? [0]
+            : [
+              ...accumulator,
+              (accumulator[accumulator.length - 1] +
+                array[index - 1].querySelectorAll('li a').length),
+            ]),
+        []);
+}
+
+function scrollAlbumCommentarySidebar() {
+  const info = albumCommentarySidebarInfo;
+  const {state} = info;
+  const {currentTrackLink, currentTrackSection} = state;
+
+  if (!currentTrackLink) {
+    return;
+  }
+
+  const {sidebar, sidebarHeading} = info;
+
+  const scrollTop = sidebar.scrollTop;
+
+  const headingRect = sidebarHeading.getBoundingClientRect();
+  const sidebarRect = sidebar.getBoundingClientRect();
+
+  const stickyPadding = headingRect.height;
+  const sidebarViewportHeight = sidebarRect.height - stickyPadding;
+
+  const linkRect = currentTrackLink.getBoundingClientRect();
+  const sectionRect = currentTrackSection.getBoundingClientRect();
+
+  const sectionTopEdge =
+    sectionRect.top - (sidebarRect.top - scrollTop);
+
+  const sectionHeight =
+    sectionRect.height;
+
+  const sectionScrollTop =
+    sectionTopEdge - stickyPadding - 10;
+
+  const linkTopEdge =
+    linkRect.top - (sidebarRect.top - scrollTop);
+
+  const linkBottomEdge =
+    linkRect.bottom - (sidebarRect.top - scrollTop);
+
+  const linkScrollTop =
+    linkTopEdge - stickyPadding - 5;
+
+  const linkDistanceFromSection =
+    linkScrollTop - sectionTopEdge;
+
+  const linkVisibleFromTopOfSection =
+    linkBottomEdge - sectionTopEdge > sidebarViewportHeight;
+
+  const linkScrollBottom =
+    linkScrollTop - sidebarViewportHeight + linkRect.height + 20;
+
+  const maxScrollInViewport =
+    scrollTop + stickyPadding + sidebarViewportHeight;
+
+  const minScrollInViewport =
+    scrollTop + stickyPadding;
+
+  if (linkBottomEdge > maxScrollInViewport) {
+    if (linkVisibleFromTopOfSection) {
+      sidebar.scrollTo({top: linkScrollBottom, behavior: 'smooth'});
+    } else {
+      sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'});
+    }
+  } else if (linkTopEdge < minScrollInViewport) {
+    if (linkVisibleFromTopOfSection) {
+      sidebar.scrollTo({top: linkScrollTop, behavior: 'smooth'});
+    } else {
+      sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'});
+    }
+  } else if (state.justChangedTrackSection) {
+    if (sectionHeight < sidebarViewportHeight) {
+      sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'});
+    }
+  }
+}
+
+function markDirectoryAsCurrentForAlbumCommentary(trackDirectory) {
+  const info = albumCommentarySidebarInfo;
+  const {state} = info;
+
+  const trackIndex =
+    (trackDirectory
+      ? info.sidebarTrackDirectories
+          .indexOf(trackDirectory)
+      : -1);
+
+  const sectionIndex =
+    (trackIndex >= 0
+      ? info.sidebarTrackSectionStartIndices
+          .findIndex((start, index, array) =>
+            (index === array.length - 1
+              ? true
+              : trackIndex < array[index + 1]))
+      : -1);
+
+  const sidebarTrackLink =
+    (trackIndex >= 0
+      ? info.sidebarTrackLinks[trackIndex]
+      : null);
+
+  const sidebarTrackSection =
+    (sectionIndex >= 0
+      ? info.sidebarTrackSections[sectionIndex]
+      : null);
+
+  state.currentTrackLink?.classList?.remove('current');
+  state.currentTrackLink = sidebarTrackLink;
+  state.currentTrackLink?.classList?.add('current');
+
+  if (sidebarTrackSection !== state.currentTrackSection) {
+    if (sidebarTrackSection && !sidebarTrackSection.open) {
+      if (state.currentTrackSection) {
+        state.currentTrackSection.open = false;
+      }
+
+      sidebarTrackSection.open = true;
+    }
+
+    state.currentTrackSection?.classList?.remove('current');
+    state.currentTrackSection = sidebarTrackSection;
+    state.currentTrackSection?.classList?.add('current');
+    state.justChangedTrackSection = true;
+  } else {
+    state.justChangedTrackSection = false;
+  }
+}
+
+function addAlbumCommentaryInternalListeners() {
+  const info = albumCommentarySidebarInfo;
+
+  const mainContentIndex =
+    (stickyHeadingInfo.contentContainers ?? [])
+      .findIndex(({id}) => id === 'content');
+
+  if (mainContentIndex === -1) return;
+
+  stickyHeadingInfo.event.whenDisplayedHeadingChanges.push((index, {newHeading}) => {
+    if (index !== mainContentIndex) return;
+    if (hashLinkInfo.state.scrollingAfterClick) return;
+
+    const trackDirectory =
+      (newHeading
+        ? newHeading.id
+        : null);
+
+    markDirectoryAsCurrentForAlbumCommentary(trackDirectory);
+    scrollAlbumCommentarySidebar();
+  });
+
+  hashLinkInfo.event.whenHashLinkClicked.push(({link}) => {
+    const hash = link.getAttribute('href').slice(1);
+    if (!info.sidebarTrackDirectories.includes(hash)) return;
+    markDirectoryAsCurrentForAlbumCommentary(hash);
+  });
+}
+
+if (document.documentElement.dataset.urlKey === 'localized.albumCommentary') {
+  clientSteps.getPageReferences.push(getAlbumCommentarySidebarReferences);
+  clientSteps.addInternalListeners.push(addAlbumCommentaryInternalListeners);
+}
+
+// Run setup steps ----------------------------------------
+
+for (const [key, steps] of Object.entries(clientSteps)) {
+  for (const step of steps) {
+    try {
+      step();
+    } catch (error) {
+      console.warn(`During ${key}, failed to run ${step.name}`);
+      console.debug(error);
+    }
+  }
+}
diff --git a/src/static/site4.css b/src/static/site4.css
index ab8976bc..0e6166b4 100644
--- a/src/static/site4.css
+++ b/src/static/site4.css
@@ -533,6 +533,13 @@ p .current {
   margin-top: 5px;
 }
 
+.commentary-art {
+  float: right;
+  width: 30%;
+  max-width: 250px;
+  margin: 15px 0 10px 20px;
+}
+
 .js-hide,
 .js-show-once-data,
 .js-hide-once-data {
@@ -1250,6 +1257,10 @@ html[data-url-key="localized.home"] .carousel-container {
   animation-delay: 125ms;
 }
 
+h3.content-heading {
+  clear: both;
+}
+
 /* This animation's name is referenced in JavaScript */
 @keyframes highlight-hash-link {
   0% {
@@ -1438,6 +1449,30 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   align-self: flex-start;
 }
 
+.sidebar-column.sidebar.sticky-column {
+  max-height: calc(100vh - 20px);
+  overflow-y: scroll;
+  align-self: start;
+  padding-bottom: 0;
+  box-sizing: border-box;
+  flex-basis: 275px;
+  padding-top: 0;
+}
+
+.sidebar-column.sidebar.sticky-column > h1 {
+  position: sticky;
+  top: 0;
+  margin: 0 calc(-1 * var(--content-padding));
+  margin-bottom: 10px;
+
+  border-bottom: 1px dotted rgba(220, 220, 220, 0.4);
+  padding: 10px 5px;
+
+  background: var(--bg-black-color);
+  -webkit-backdrop-filter: blur(3px);
+  backdrop-filter: blur(3px);
+}
+
 /* Image overlay */
 
 #image-overlay-container {