« get me outta code hell

sticky subheadings - 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>2022-12-03 21:28:15 -0400
committer(quasar) nebula <qznebula@protonmail.com>2022-12-03 21:28:15 -0400
commit4a1997b0edd7de4b124c17e3cdeb1a47ecea1095 (patch)
tree57665b4938178c054c5e2c634fd2d3772c013285 /src
parent690a7b53a72ac71f9f76260fa50c634566c4e984 (diff)
sticky subheadings
Diffstat (limited to 'src')
-rw-r--r--src/data/things/language.js1
-rw-r--r--src/listing-spec.js7
-rw-r--r--src/misc-templates.js20
-rw-r--r--src/page/album-commentary.js30
-rw-r--r--src/page/album.js11
-rw-r--r--src/page/artist.js11
-rw-r--r--src/page/flash.js5
-rw-r--r--src/page/group.js4
-rw-r--r--src/page/listing.js4
-rw-r--r--src/page/news.js6
-rw-r--r--src/page/track.js60
-rw-r--r--src/static/client.js87
-rw-r--r--src/static/site2.css70
-rwxr-xr-xsrc/upd8.js5
14 files changed, 272 insertions, 49 deletions
diff --git a/src/data/things/language.js b/src/data/things/language.js
index 21524993..3086ad2e 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -296,7 +296,6 @@ export class Language extends Thing {
       return this.formatString('count.fileSize.bytes', {bytes});
     }
   }
-
 }
 
 const countHelper = (stringKey, argName = stringKey) =>
diff --git a/src/listing-spec.js b/src/listing-spec.js
index f773bf87..e01912cd 100644
--- a/src/listing-spec.js
+++ b/src/listing-spec.js
@@ -96,6 +96,7 @@ const listingSpec = [
       html.tag('dl',
         data.flatMap(({dateAddedToWiki, chunk: albums}) => [
           html.tag('dt',
+            {class: ['content-heading']},
             language.$('listingPage.listAlbums.byDateAdded.date', {
               date: language.formatDate(dateAddedToWiki),
             })),
@@ -445,6 +446,7 @@ const listingSpec = [
       html.tag('dl',
         data.flatMap(({category, groups}) => [
           html.tag('dt',
+            {class: ['content-heading']},
             language.$('listingPage.listGroups.byCategory.category', {
               category: empty(groups)
                 ? category.name
@@ -594,6 +596,7 @@ const listingSpec = [
       html.tag('dl',
         data.flatMap(({album, tracks}) => [
           html.tag('dt',
+            {class: ['content-heading']},
             language.$('listingPage.listTracks.byAlbum.album', {
               album: link.album(album),
             })),
@@ -678,6 +681,7 @@ const listingSpec = [
       html.tag('dl',
         data.flatMap(({album, tracks}) => [
           html.tag('dt',
+            {class: ['content-heading']},
             language.$('listingPage.listTracks.byDurationInAlbum.album', {
               album: link.album(album),
             })),
@@ -731,6 +735,7 @@ const listingSpec = [
       html.tag('dl',
         data.flatMap(({album, chunk: tracks}) => [
           html.tag('dt',
+            {class: ['content-heading']},
             language.$('listingPage.listTracks.inFlashes.byAlbum.album', {
               album: link.album(album),
               date: language.formatDate(album.date),
@@ -766,6 +771,7 @@ const listingSpec = [
       html.tag('dl',
         data.flatMap(({flash, tracks}) => [
           html.tag('dt',
+            {class: ['content-heading']},
             language.$('listingPage.listTracks.inFlashes.byFlash.flash', {
               flash: link.flash(flash),
               date: language.formatDate(flash.date),
@@ -798,6 +804,7 @@ const listingSpec = [
       html.tag('dl',
         data.flatMap(({album, tracks}) => [
           html.tag('dt',
+            {class: ['content-heading']},
             language.$('listingPage.listTracks.withLyrics.album', {
               album: link.album(album),
               date: language.formatDate(album.date),
diff --git a/src/misc-templates.js b/src/misc-templates.js
index 83aae190..755ad649 100644
--- a/src/misc-templates.js
+++ b/src/misc-templates.js
@@ -50,7 +50,10 @@ function unbound_generateAdditionalFilesList(additionalFiles, {
 
   return html.fragment([
     html.tag('p',
-      {id: 'additional-files'},
+      {
+        id: 'additional-files',
+        class: ['content-heading'],
+      },
       language.$('releaseInfo.additionalFiles.heading', {
         additionalFiles: language.countAdditionalFiles(fileCount, {
           unit: true,
@@ -717,6 +720,19 @@ function unbound_generateNavigationLinks(current, {
   return language.formatUnitList(links);
 }
 
+// Sticky heading, ooooo
+
+function unbound_generateStickyHeadingContainer(headingContent, {
+  html,
+}) {
+  return html.tag('div',
+    {class: 'content-sticky-heading-container'},
+    [
+      html.tag('h1', headingContent),
+      html.tag('h2', {class: 'content-sticky-subheading'}),
+    ]);
+}
+
 // Footer stuff
 
 function unbound_getFooterLocalizationLinks(pathname, {
@@ -794,5 +810,7 @@ export {
   unbound_generateInfoGalleryLinks as generateInfoGalleryLinks,
   unbound_generateNavigationLinks as generateNavigationLinks,
 
+  unbound_generateStickyHeadingContainer as generateStickyHeadingContainer,
+
   unbound_getFooterLocalizationLinks as getFooterLocalizationLinks,
 }
diff --git a/src/page/album-commentary.js b/src/page/album-commentary.js
index 50a2aa39..90040026 100644
--- a/src/page/album-commentary.js
+++ b/src/page/album-commentary.js
@@ -21,6 +21,7 @@ export function write(album) {
     type: 'page',
     path: ['albumCommentary', album.directory],
     page: ({
+      generateStickyHeadingContainer,
       getAlbumStylesheet,
       getLinkThemeString,
       getThemeString,
@@ -35,23 +36,32 @@ export function write(album) {
 
       main: {
         content: html.tag('div', {class: 'long-content'}, [
-          html.tag('h1', language.$('albumCommentaryPage.title', {
-            album: link.album(album),
-          })),
-          html.tag('p', language.$('albumCommentaryPage.infoLine', {
-            words: html.tag('b', language.formatWordCount(words, {unit: true})),
-            entries: html.tag('b', language.countCommentaryEntries(entries.length, {unit: true})),
-          })),
+          generateStickyHeadingContainer(
+            language.$('albumCommentaryPage.title', {
+              album: link.album(album),
+            })),
+
+          html.tag('p',
+            language.$('albumCommentaryPage.infoLine', {
+              words: html.tag('b', language.formatWordCount(words, {unit: true})),
+              entries: html.tag('b', language.countCommentaryEntries(entries.length, {unit: true})),
+            })),
+
           ...html.fragment(album.commentary && [
-            html.tag('h3', language.$('albumCommentaryPage.entry.title.albumCommentary')),
-            html.tag('blockquote', transformMultiline(album.commentary)),
+            html.tag('h3',
+              {class: ['content-heading']},
+              language.$('albumCommentaryPage.entry.title.albumCommentary')),
+            html.tag('blockquote',
+              transformMultiline(album.commentary)),
           ]),
+
           ...album.tracks.filter(t => t.commentary).flatMap(track => [
             html.tag('h3',
-              {id: 'track.directory'},
+              {id: 'track.directory', class: ['content-heading']},
               language.$('albumCommentaryPage.entry.title.trackCommentary', {
                 track: link.track(track),
               })),
+
             html.tag('blockquote',
               {style: getLinkThemeString(track.color)},
               transformMultiline(track.commentary)),
diff --git a/src/page/album.js b/src/page/album.js
index cb512e86..1a90a79c 100644
--- a/src/page/album.js
+++ b/src/page/album.js
@@ -106,6 +106,7 @@ export function write(album, {wikiData}) {
       generateChronologyLinks,
       generateCoverLink,
       generateNavigationLinks,
+      generateStickyHeadingContainer,
       getAlbumCover,
       getAlbumStylesheet,
       getArtistString,
@@ -159,9 +160,8 @@ export function write(album, {wikiData}) {
               tags: album.artTags,
             }),
 
-            html.tag('h1', language.$('albumPage.title', {
-              album: album.name,
-            })),
+            generateStickyHeadingContainer(
+              language.$('albumPage.title', {album: album.name})),
 
             html.tag('p',
               {
@@ -254,6 +254,7 @@ export function write(album, {wikiData}) {
                   tracks,
                 }) => [
                   html.tag('dt',
+                    {class: ['content-heading']},
                     language.$('trackList.section.withDuration', {
                       duration: language.formatDuration(getTotalDuration(tracks), {
                         approximate: tracks.length > 1,
@@ -300,7 +301,9 @@ export function write(album, {wikiData}) {
 
             ...html.fragment(
               album.commentary && [
-                html.tag('p', language.$('releaseInfo.artistCommentary')),
+                html.tag('p',
+                  {class: ['content-heading']},
+                  language.$('releaseInfo.artistCommentary')),
                 html.tag('blockquote', transformMultiline(album.commentary)),
               ]),
           ],
diff --git a/src/page/artist.js b/src/page/artist.js
index 6dd2ef30..b62b32b5 100644
--- a/src/page/artist.js
+++ b/src/page/artist.js
@@ -321,6 +321,7 @@ export function write(artist, {wikiData}) {
       fancifyURL,
       generateCoverLink,
       generateInfoGalleryLinks,
+      generateStickyHeadingContainer,
       getArtistAvatar,
       getArtistString,
       html,
@@ -346,7 +347,7 @@ export function write(artist, {wikiData}) {
                 alt: language.$('misc.alt.artistAvatar'),
               }),
 
-            html.tag('h1',
+            generateStickyHeadingContainer(
               language.$('artistPage.title', {
                 artist: name,
               })),
@@ -408,7 +409,7 @@ export function write(artist, {wikiData}) {
             ...html.fragment(
               !empty(allTracks) && [
                 html.tag('h2',
-                  {id: 'tracks'},
+                  {id: 'tracks', class: ['content-heading']},
                   language.$('artistPage.trackList.title')),
 
                 totalDuration > 0 &&
@@ -446,7 +447,7 @@ export function write(artist, {wikiData}) {
             ...html.fragment(
               !empty(artThingsAll) && [
                 html.tag('h2',
-                  {id: 'art'},
+                  {id: 'art', class: ['content-heading']},
                   language.$('artistPage.artList.title')),
 
                 hasGallery &&
@@ -513,7 +514,7 @@ export function write(artist, {wikiData}) {
               wikiInfo.enableFlashesAndGames &&
               !empty(flashes) && [
                 html.tag('h2',
-                  {id: 'flashes'},
+                  {id: 'flashes', class: ['content-heading']},
                   language.$('artistPage.flashList.title')),
 
                 html.tag('dl',
@@ -555,7 +556,7 @@ export function write(artist, {wikiData}) {
             ...html.fragment(
               !empty(commentaryThings) && [
                 html.tag('h2',
-                  {id: 'commentary'},
+                  {id: 'commentary', class: ['content-heading']},
                   language.$('artistPage.commentaryList.title')),
 
                 html.tag('dl',
diff --git a/src/page/flash.js b/src/page/flash.js
index e5353a18..d968d00b 100644
--- a/src/page/flash.js
+++ b/src/page/flash.js
@@ -20,6 +20,7 @@ export function write(flash, {wikiData}) {
       generateChronologyLinks,
       generateCoverLink,
       generateNavigationLinks,
+      generateStickyHeadingContainer,
       getArtistString,
       getFlashCover,
       getThemeString,
@@ -44,7 +45,7 @@ export function write(flash, {wikiData}) {
             alt: language.$('misc.alt.flashArt'),
           }),
 
-          html.tag('h1',
+          generateStickyHeadingContainer(
             language.$('flashPage.title', {
               flash: flash.name,
             })),
@@ -68,6 +69,7 @@ export function write(flash, {wikiData}) {
           ...html.fragment(
             !empty(flash.featuredTracks) && [
               html.tag('p',
+                {class: ['content-heading']},
                 language.$('releaseInfo.tracksFeatured', {
                   flash: html.tag('i', flash.name),
                 })),
@@ -87,6 +89,7 @@ export function write(flash, {wikiData}) {
           ...html.fragment(
             !empty(flash.contributorContribs) && [
               html.tag('p',
+                {class: ['content-heading']},
                 language.$('releaseInfo.contributors')),
 
               html.tag('ul',
diff --git a/src/page/group.js b/src/page/group.js
index 1d586cf5..c4c376b6 100644
--- a/src/page/group.js
+++ b/src/page/group.js
@@ -32,6 +32,7 @@ export function write(group, {wikiData}) {
       fancifyURL,
       generateInfoGalleryLinks,
       generateNavigationLinks,
+      generateStickyHeadingContainer,
       getLinkThemeString,
       getThemeString,
       html,
@@ -46,7 +47,7 @@ export function write(group, {wikiData}) {
 
       main: {
         content: [
-          html.tag('h1',
+          generateStickyHeadingContainer(
             language.$('groupInfoPage.title', {
               group: group.name
             })),
@@ -65,6 +66,7 @@ export function write(group, {wikiData}) {
           ...html.fragment(
             group.albums && [
               html.tag('h2',
+                {class: ['content-heading']},
                 language.$('groupInfoPage.albumList.title')),
 
               html.tag('p',
diff --git a/src/page/listing.js b/src/page/listing.js
index cb297a89..fc643b11 100644
--- a/src/page/listing.js
+++ b/src/page/listing.js
@@ -32,6 +32,7 @@ export function write(listing, {wikiData}) {
     path: ['listing', listing.directory],
     page: (opts) => {
       const {
+        generateStickyHeadingContainer,
         getLinkThemeString,
         html,
         language,
@@ -45,7 +46,7 @@ export function write(listing, {wikiData}) {
 
         main: {
           content: [
-            html.tag('h1',
+            generateStickyHeadingContainer(
               language.$(titleKey)),
 
             ...html.fragment(
@@ -230,6 +231,7 @@ function generateLinkIndexForListings(currentListing, forSidebar, {
     : html.tag('dl',
         filteredByCondition.flatMap(({title, listings}) => [
           html.tag('dt',
+            {class: ['content-heading']},
             title({language})),
           html.tag('dd',
             genUL(listings)),
diff --git a/src/page/news.js b/src/page/news.js
index 62f94fb9..78e25f41 100644
--- a/src/page/news.js
+++ b/src/page/news.js
@@ -14,6 +14,7 @@ export function write(entry, {wikiData}) {
     path: ['newsEntry', entry.directory],
     page: ({
       generateNavigationLinks,
+      generateStickyHeadingContainer,
       html,
       language,
       link,
@@ -24,7 +25,7 @@ export function write(entry, {wikiData}) {
       main: {
         content:
           html.tag('div', {class: 'long-content'}, [
-            html.tag('h1',
+            generateStickyHeadingContainer(
               language.$('newsEntryPage.title', {
                 entry: entry.name,
               })),
@@ -58,6 +59,7 @@ export function writeTargetless({wikiData}) {
     type: 'page',
     path: ['newsIndex'],
     page: ({
+      generateStickyHeadingContainer,
       html,
       language,
       link,
@@ -70,7 +72,7 @@ export function writeTargetless({wikiData}) {
           html.tag('div',
             {class: ['long-content', 'news-index']},
             [
-              html.tag('h1',
+              generateStickyHeadingContainer(
                 language.$('newsIndex.title')),
 
               ...newsData.map(entry =>
diff --git a/src/page/track.js b/src/page/track.js
index 18fd7262..09c472ac 100644
--- a/src/page/track.js
+++ b/src/page/track.js
@@ -162,6 +162,7 @@ export function write(track, {wikiData}) {
       generateChronologyLinks,
       generateCoverLink,
       generateNavigationLinks,
+      generateStickyHeadingContainer,
       generateTrackListDividedByGroups,
       getAlbumStylesheet,
       getArtistString,
@@ -229,7 +230,8 @@ export function write(track, {wikiData}) {
               tags: track.artTags,
             }),
 
-            html.tag('h1', language.$('trackPage.title', {track: track.name})),
+            generateStickyHeadingContainer(
+              language.$('trackPage.title', {track: track.name})),
 
             html.tag('p',
               {
@@ -282,7 +284,9 @@ export function write(track, {wikiData}) {
 
             ...html.fragment(
               !empty(otherReleases) && [
-                html.tag('p', language.$('releaseInfo.alsoReleasedAs')),
+                html.tag('p', {class: ['content-heading']},
+                  language.$('releaseInfo.alsoReleasedAs')),
+
                 html.tag('ul', otherReleases.map(track =>
                   html.tag('li', language.$('releaseInfo.alsoReleasedAs.item', {
                     track: link.track(track),
@@ -292,7 +296,9 @@ export function write(track, {wikiData}) {
 
             ...html.fragment(
               !empty(contributorContribs) && [
-                html.tag('p', language.$('releaseInfo.contributors')),
+                html.tag('p', {class: ['content-heading']},
+                  language.$('releaseInfo.contributors')),
+
                 html.tag('ul', contributorContribs.map(contrib =>
                   html.tag('li', getArtistString([contrib], {
                     showContrib: true,
@@ -302,17 +308,21 @@ export function write(track, {wikiData}) {
 
             ...html.fragment(
               !empty(referencedTracks) && [
-                html.tag('p', language.$('releaseInfo.tracksReferenced', {
-                  track: html.tag('i', track.name),
-                })),
+                html.tag('p', {class: ['content-heading']},
+                  language.$('releaseInfo.tracksReferenced', {
+                    track: html.tag('i', track.name),
+                  })),
+
                 html.tag('ul', referencedTracks.map(getTrackItem)),
               ]),
 
             ...html.fragment(
               !empty(referencedByTracks) && [
-                html.tag('p', language.$('releaseInfo.tracksThatReference', {
-                  track: html.tag('i', track.name),
-                })),
+                html.tag('p', {class: ['content-heading']},
+                  language.$('releaseInfo.tracksThatReference', {
+                    track: html.tag('i', track.name),
+                  })),
+
                 generateTrackListDividedByGroups(referencedByTracks, {
                   getTrackItem,
                   wikiData,
@@ -321,26 +331,32 @@ export function write(track, {wikiData}) {
 
             ...html.fragment(
               !empty(sampledTracks) && [
-                html.tag('p', language.$('releaseInfo.tracksSampled', {
-                  track: html.tag('i', track.name),
-                })),
+                html.tag('p', {class: ['content-heading']},
+                  language.$('releaseInfo.tracksSampled', {
+                    track: html.tag('i', track.name),
+                  })),
+
                 html.tag('ul', sampledTracks.map(getTrackItem)),
               ]),
 
             ...html.fragment(
               !empty(sampledByTracks) && [
-                html.tag('p', language.$('releaseInfo.tracksThatSample', {
-                  track: html.tag('i', track.name),
-                })),
+                html.tag('p', {class: ['content-heading']},
+                  language.$('releaseInfo.tracksThatSample', {
+                    track: html.tag('i', track.name),
+                  })),
+
                 html.tag('ul', sampledByTracks.map(getTrackItem)),
               ]),
 
             ...html.fragment(
               wikiInfo.enableFlashesAndGames &&
               !empty(flashesThatFeature) && [
-                html.tag('p', language.$('releaseInfo.flashesThatFeature', {
-                  track: `<i>${track.name}</i>`,
-                })),
+                html.tag('p', {class: ['content-heading']},
+                  language.$('releaseInfo.flashesThatFeature', {
+                    track: html.tag('i', track.name),
+                  })),
+
                 html.tag('ul', flashesThatFeature.map(({flash, as}) =>
                   html.tag('li',
                     {class: as !== track && 'rerelease'},
@@ -356,13 +372,17 @@ export function write(track, {wikiData}) {
 
             ...html.fragment(
               track.lyrics && [
-                html.tag('p', language.$('releaseInfo.lyrics')),
+                html.tag('p', {class: ['content-heading']},
+                  language.$('releaseInfo.lyrics')),
+
                 html.tag('blockquote', transformLyrics(track.lyrics)),
               ]),
 
             ...html.fragment(
               hasCommentary && [
-                html.tag('p', language.$('releaseInfo.artistCommentary')),
+                html.tag('p', {class: ['content-heading']},
+                  language.$('releaseInfo.artistCommentary')),
+
                 html.tag('blockquote', generateCommentary({
                   link,
                   language,
diff --git a/src/static/client.js b/src/static/client.js
index b5ed6868..ebe8604f 100644
--- a/src/static/client.js
+++ b/src/static/client.js
@@ -443,3 +443,90 @@ function addInfoCardLinkHandlers(type) {
 if (localStorage.tryInfoCards) {
   addInfoCardLinkHandlers('track');
 }
+
+// Sticky content heading ---------------------------------
+
+const stickyHeadingInfo = Array.from(document.querySelectorAll('.content-sticky-heading-container'))
+  .map(stickyContainer => {
+    const {parentElement: contentContainer} = stickyContainer;
+    const stickySubheading = stickyContainer.querySelector('.content-sticky-subheading');
+    const contentHeadings = Array.from(contentContainer.querySelectorAll('.content-heading'));
+
+    return {
+      contentContainer,
+      contentHeadings,
+      stickyContainer,
+      stickySubheading,
+      state: {
+        displayedHeading: null,
+      },
+    };
+  });
+
+const topOfViewInside = (el, scroll = window.scrollY) => (
+  scroll > el.offsetTop &&
+  scroll < el.offsetTop + el.offsetHeight
+);
+
+function updateStickyHeading() {
+  for (const {
+    contentContainer,
+    contentHeadings,
+    stickyContainer,
+    stickySubheading,
+    state,
+  } of stickyHeadingInfo) {
+    let closestHeading = null;
+
+    if (topOfViewInside(contentContainer)) {
+      if (stickySubheading.childNodes.length === 0) {
+        // &nbsp; to ensure correct basic line height
+        stickySubheading.appendChild(document.createTextNode('\xA0'));
+      }
+
+      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) {
+          closestHeading = heading;
+          break;
+        }
+      }
+    }
+
+    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();
+        }
+
+        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));
+          }
+        }
+
+        stickySubheading.classList.add('visible');
+      } else {
+        stickySubheading.classList.remove('visible');
+      }
+
+      state.displayedHeading = closestHeading;
+    }
+  }
+}
+
+document.addEventListener('scroll', updateStickyHeading);
+
+updateStickyHeading();
diff --git a/src/static/site2.css b/src/static/site2.css
index 33553e84..070d89ee 100644
--- a/src/static/site2.css
+++ b/src/static/site2.css
@@ -983,11 +983,15 @@ li > ul {
 
 /* sticky headers */
 
-#content:not(.no-sticky-heading) h1:first-of-type,
+#content:not(.no-sticky-heading) > h1:first-of-type,
 .sidebar:not(.no-sticky-heading) h1:first-of-type {
   position: sticky;
   top: 0;
+}
 
+#content .content-sticky-heading-container h1,
+#content:not(.no-sticky-heading) > h1:first-of-type,
+.sidebar:not(.no-sticky-heading) h1:first-of-type {
   margin: calc(-1 * var(--content-padding));
   margin-bottom: calc(0.5 * var(--content-padding));
   padding:
@@ -997,14 +1001,74 @@ li > ul {
     20px;
 
   background: var(--bg-black-color);
-  border-bottom: 1px dotted rgba(180, 180, 180, 0.4);
+  border-bottom: 1px dotted rgba(220, 220, 220, 0.4);
 
   -webkit-backdrop-filter: blur(6px);
           backdrop-filter: blur(6px);
 }
 
-#content:not(.no-sticky-heading) h1:first-of-type {
+#content .content-sticky-heading-container {
+  position: sticky;
+  top: 0;
+
+  /* Safari doesn't always play nicely with position: sticky,
+   * this seems to fix images sometimes displaying above the
+   * position: absolute subheading (h2) child
+   *
+   * See also: https://stackoverflow.com/questions/50224855/
+   */
+  transform: translate3d(0, 0, 0);
   z-index: 1;
+}
+
+#content .content-sticky-heading-container h1 {
+  margin-bottom: 0;
+}
+
+#content .content-sticky-heading-container h2 {
+  position: absolute;
+  margin: 0 calc(-1 * var(--content-padding));
+  width: 100%;
+  padding: 10px 40px 5px 20px;
+
+  font-size: 0.9em;
+  font-weight: normal;
+  font-style: oblique;
+  color: #eee;
+
+  background: var(--bg-black-color);
+  border-bottom: 1px dotted rgba(220, 220, 220, 0.4);
+
+  -webkit-backdrop-filter: blur(3px);
+          backdrop-filter: blur(3px);
+
+  transition: margin-top 0.35s, opacity 0.25s;
+}
+
+#content .content-sticky-heading-container h2:not(.visible) {
+  margin-top: -20px;
+  opacity: 0;
+}
+
+#content .content-sticky-heading-container h2.visible {
+  margin-top: 0;
+  opacity: 1;
+}
+
+#content:not(.no-sticky-heading) > h1:first-of-type {
+  z-index: 1;
+  box-shadow:
+    inset 0 10px 10px -5px var(--shadow-color),
+    0 4px 4px rgba(0, 0, 0, 0.8);
+}
+
+#content .content-sticky-heading-container h1 {
+  box-shadow:
+    inset 0 10px 10px -5px var(--shadow-color),
+    0 4px 4px rgba(0, 0, 0, 0.8);
+}
+
+#content .content-sticky-heading-container h2.visible {
   box-shadow:
     inset 0 10px 10px -5px var(--shadow-color),
     0 4px 4px rgba(0, 0, 0, 0.8);
diff --git a/src/upd8.js b/src/upd8.js
index 89984c08..bb03c1a0 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -85,6 +85,7 @@ import {
   generateCoverLink,
   generateInfoGalleryLinks,
   generateNavigationLinks,
+  generateStickyHeadingContainer,
   generateTrackListDividedByGroups,
   getAlbumGridHTML,
   getAlbumStylesheet,
@@ -2462,6 +2463,10 @@ async function main() {
           language,
         });
 
+        bound.generateStickyHeadingContainer = bindOpts(generateStickyHeadingContainer, {
+          html,
+        });
+
         bound.generateChronologyLinks = bindOpts(generateChronologyLinks, {
           html,
           language,