« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/content
diff options
context:
space:
mode:
Diffstat (limited to 'src/content')
-rw-r--r--src/content/dependencies/generateAdditionalFilesShortcut.js27
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js72
-rw-r--r--src/content/dependencies/generateAlbumCommentarySidebar.js47
-rw-r--r--src/content/dependencies/generateAlbumGalleryPage.js6
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js29
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js77
-rw-r--r--src/content/dependencies/generateAlbumSecondaryNav.js11
-rw-r--r--src/content/dependencies/generateAlbumSidebarGroupBox.js27
-rw-r--r--src/content/dependencies/generateAlbumTrackList.js6
-rw-r--r--src/content/dependencies/generateAlbumTrackListItem.js4
-rw-r--r--src/content/dependencies/generateArtTagGalleryPage.js2
-rw-r--r--src/content/dependencies/generateArtistGalleryPage.js4
-rw-r--r--src/content/dependencies/generateArtistGroupContributionsInfo.js8
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js12
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js4
-rw-r--r--src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js4
-rw-r--r--src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js5
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkedList.js6
-rw-r--r--src/content/dependencies/generateChronologyLinks.js40
-rw-r--r--src/content/dependencies/generateChronologyLinksScopeSwitcher.js67
-rw-r--r--src/content/dependencies/generateColorStyleVariables.js2
-rw-r--r--src/content/dependencies/generateCommentaryEntry.js20
-rw-r--r--src/content/dependencies/generateCommentarySection.js17
-rw-r--r--src/content/dependencies/generateContentHeading.js23
-rw-r--r--src/content/dependencies/generateCoverArtwork.js19
-rw-r--r--src/content/dependencies/generateFlashCoverArtwork.js24
-rw-r--r--src/content/dependencies/generateFlashIndexPage.js8
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js42
-rw-r--r--src/content/dependencies/generateGroupInfoPage.js25
-rw-r--r--src/content/dependencies/generateGroupSecondaryNav.js18
-rw-r--r--src/content/dependencies/generateGroupSidebar.js25
-rw-r--r--src/content/dependencies/generateListingPage.js72
-rw-r--r--src/content/dependencies/generateListingSidebar.js16
-rw-r--r--src/content/dependencies/generatePageLayout.js136
-rw-r--r--src/content/dependencies/generatePageSidebar.js79
-rw-r--r--src/content/dependencies/generatePageSidebarBox.js8
-rw-r--r--src/content/dependencies/generateSearchSidebarBox.js57
-rw-r--r--src/content/dependencies/generateStickyHeadingContainer.js15
-rw-r--r--src/content/dependencies/generateTrackChronologyLinks.js166
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js152
-rw-r--r--src/content/dependencies/generateTrackListDividedByGroups.js155
-rw-r--r--src/content/dependencies/generateTrackReleaseInfo.js28
-rw-r--r--src/content/dependencies/generateWikiHomeAlbumsRow.js8
-rw-r--r--src/content/dependencies/generateWikiHomeNewsBox.js2
-rw-r--r--src/content/dependencies/generateWikiHomePage.js3
-rw-r--r--src/content/dependencies/image.js21
-rw-r--r--src/content/dependencies/linkContribution.js14
-rw-r--r--src/content/dependencies/linkExternalAsIcon.js2
-rw-r--r--src/content/dependencies/listArtistsByDuration.js12
-rw-r--r--src/content/dependencies/listArtistsByGroup.js152
-rw-r--r--src/content/dependencies/listArtistsByLatestContribution.js3
-rw-r--r--src/content/dependencies/listTracksByDate.js3
-rw-r--r--src/content/dependencies/transformContent.js11
-rw-r--r--src/content/util/getChronologyRelations.js26
-rw-r--r--src/content/util/groupTracksByGroup.js23
55 files changed, 1155 insertions, 690 deletions
diff --git a/src/content/dependencies/generateAdditionalFilesShortcut.js b/src/content/dependencies/generateAdditionalFilesShortcut.js
deleted file mode 100644
index 9e119bce..00000000
--- a/src/content/dependencies/generateAdditionalFilesShortcut.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import {empty} from '#sugar';
-
-export default {
-  extraDependencies: ['html', 'language'],
-
-  data(additionalFiles) {
-    return {
-      titles: additionalFiles.map(fileGroup => fileGroup.title),
-    };
-  },
-
-  generate(data, {html, language}) {
-    if (empty(data.titles)) {
-      return html.blank();
-    }
-
-    return language.$('releaseInfo.additionalFiles.shortcut', {
-      anchorLink:
-        html.tag('a',
-          {href: '#additional-files'},
-          language.$('releaseInfo.additionalFiles.shortcut.anchorLink')),
-
-      titles:
-        language.formatUnitList(data.titles),
-    });
-  },
-}
diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js
index 751a0c91..05dbdcf3 100644
--- a/src/content/dependencies/generateAlbumCommentaryPage.js
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -1,16 +1,15 @@
-import {empty, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateAlbumCommentarySidebar',
     'generateAlbumCoverArtwork',
     'generateAlbumNavAccent',
-    'generateAlbumSidebarTrackSection',
     'generateAlbumStyleRules',
     'generateCommentaryEntry',
     'generateContentHeading',
     'generateTrackCoverArtwork',
     'generatePageLayout',
-    'generatePageSidebar',
     'linkAlbum',
     'linkExternal',
     'linkTrack',
@@ -25,7 +24,7 @@ export default {
       relation('generatePageLayout');
 
     relations.sidebar =
-      relation('generatePageSidebar');
+      relation('generateAlbumCommentarySidebar', album);
 
     relations.albumStyleRules =
       relation('generateAlbumStyleRules', album, null);
@@ -86,13 +85,6 @@ export default {
           track.commentary
             .map(entry => relation('generateCommentaryEntry', entry)));
 
-    relations.sidebarAlbumLink =
-      relation('linkAlbum', album);
-
-    relations.sidebarTrackSections =
-      album.trackSections.map(trackSection =>
-        relation('generateAlbumSidebarTrackSection', album, null, trackSection));
-
     return relations;
   },
 
@@ -174,17 +166,22 @@ export default {
                   album: relations.albumCommentaryLink,
                 }),
 
+              stickyTitle:
+                language.$('albumCommentaryPage.entry.title.albumCommentary.sticky', {
+                  album: data.name,
+                }),
+
               accent:
-                !empty(relations.albumCommentaryListeningLinks) &&
-                  language.$('albumCommentaryPage.entry.title.albumCommentary.accent', {
-                    listeningLinks:
-                      language.formatUnitList(
-                        relations.albumCommentaryListeningLinks
-                          .map(link => link.slots({
-                            context: 'album',
-                            tab: 'separate',
-                          }))),
-                  }),
+                language.$('albumCommentaryPage.entry.title.albumCommentary.accent', {
+                  [language.onlyIfOptions]: ['listeningLinks'],
+                  listeningLinks:
+                    language.formatUnitList(
+                      relations.albumCommentaryListeningLinks
+                        .map(link => link.slots({
+                          context: 'album',
+                          tab: 'separate',
+                        }))),
+                }),
             }),
 
             relations.albumCommentaryCover
@@ -212,7 +209,7 @@ export default {
             }) => [
               heading.slots({
                 tag: 'h3',
-                id: directory,
+                attributes: {id: directory},
                 color,
 
                 title:
@@ -221,13 +218,13 @@ export default {
                   }),
 
                 accent:
-                  !empty(listeningLinks) &&
-                    language.$('albumCommentaryPage.entry.title.trackCommentary.accent', {
-                      listeningLinks:
-                        language.formatUnitList(
-                          listeningLinks.map(link =>
-                            link.slot('tab', 'separate'))),
-                    }),
+                  language.$('albumCommentaryPage.entry.title.trackCommentary.accent', {
+                    [language.onlyIfOptions]: ['listeningLinks'],
+                    listeningLinks:
+                      language.formatUnitList(
+                        listeningLinks.map(link =>
+                          link.slot('tab', 'separate'))),
+                  }),
               }),
 
               cover?.slots({mode: 'commentary'}),
@@ -253,22 +250,7 @@ export default {
           },
         ],
 
-        leftSidebar:
-          relations.sidebar.slots({
-            attributes: {class: 'commentary-track-list-sidebar-box'},
-
-            stickyMode: 'column',
-
-            content: [
-              html.tag('h1', relations.sidebarAlbumLink),
-              relations.sidebarTrackSections.map(section =>
-                section.slots({
-                  anchor: true,
-                  open: true,
-                  mode: 'commentary',
-                })),
-            ],
-          }),
+        leftSidebar: relations.sidebar,
       });
   },
 };
diff --git a/src/content/dependencies/generateAlbumCommentarySidebar.js b/src/content/dependencies/generateAlbumCommentarySidebar.js
new file mode 100644
index 00000000..435860cb
--- /dev/null
+++ b/src/content/dependencies/generateAlbumCommentarySidebar.js
@@ -0,0 +1,47 @@
+export default {
+  contentDependencies: [
+    'generateAlbumSidebarTrackSection',
+    'generatePageSidebar',
+    'generatePageSidebarBox',
+    'linkAlbum',
+  ],
+
+  extraDependencies: ['html'],
+
+  relations: (relation, album) => ({
+    sidebar:
+      relation('generatePageSidebar'),
+
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
+    albumLink:
+      relation('linkAlbum', album),
+
+    trackSections:
+      album.trackSections.map(trackSection =>
+        relation('generateAlbumSidebarTrackSection',
+          album,
+          null,
+          trackSection)),
+  }),
+
+  generate: (relations, {html}) =>
+    relations.sidebar.slots({
+      stickyMode: 'column',
+      boxes: [
+        relations.sidebarBox.slots({
+          attributes: {class: 'commentary-track-list-sidebar-box'},
+          content: [
+            html.tag('h1', relations.albumLink),
+            relations.trackSections.map(section =>
+              section.slots({
+                anchor: true,
+                open: true,
+                mode: 'commentary',
+              })),
+          ],
+        }),
+      ]
+    }),
+}
diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js
index b4f9268c..aa025688 100644
--- a/src/content/dependencies/generateAlbumGalleryPage.js
+++ b/src/content/dependencies/generateAlbumGalleryPage.js
@@ -30,7 +30,7 @@ export default {
       const allCoverArtistArrays =
         tracksWithUniqueCoverArt
           .map(track => track.coverArtistContribs)
-          .map(contribs => contribs.map(contrib => contrib.who));
+          .map(contribs => contribs.map(contrib => contrib.artist));
 
       const allSameCoverArtists =
         allCoverArtistArrays
@@ -116,7 +116,7 @@ export default {
 
     data.coverArtists = [
       (album.hasCoverArt
-        ? album.coverArtistContribs.map(({who: artist}) => artist.name)
+        ? album.coverArtistContribs.map(({artist}) => artist.name)
         : null),
 
       ...
@@ -126,7 +126,7 @@ export default {
           }
 
           if (track.hasUniqueCoverArt) {
-            return track.coverArtistContribs.map(({who: artist}) => artist.name);
+            return track.coverArtistContribs.map(({artist}) => artist.name);
           }
 
           return null;
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index e0f23bd0..d4ea52de 100644
--- a/src/content/dependencies/generateAlbumInfoPage.js
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -5,7 +5,6 @@ import getChronologyRelations from '../util/getChronologyRelations.js';
 
 export default {
   contentDependencies: [
-    'generateAdditionalFilesShortcut',
     'generateAlbumAdditionalFilesList',
     'generateAlbumBanner',
     'generateAlbumCoverArtwork',
@@ -107,11 +106,6 @@ export default {
         relation('linkAlbumCommentary', album);
     }
 
-    if (!empty(album.additionalFiles)) {
-      extra.additionalFilesShortcut =
-        relation('generateAdditionalFilesShortcut', album.additionalFiles);
-    }
-
     // Section: Track list
 
     relations.trackList =
@@ -180,7 +174,12 @@ export default {
             {[html.joinChildren]: html.tag('br')},
 
             [
-              sec.extra.additionalFilesShortcut,
+              sec.additionalFiles &&
+                language.$('releaseInfo.additionalFiles.shortcut', {
+                  link: html.tag('a',
+                    {href: '#additional-files'},
+                    language.$('releaseInfo.additionalFiles.shortcut.link')),
+                }),
 
               sec.extra.galleryLink && sec.extra.commentaryLink &&
                 language.$('releaseInfo.viewGalleryOrCommentary', {
@@ -214,21 +213,17 @@ export default {
             {[html.joinChildren]: html.tag('br')},
 
             [
-              data.dateAddedToWiki &&
-                language.$('releaseInfo.addedToWiki', {
-                  date: language.formatDate(data.dateAddedToWiki),
-                }),
+              language.$('releaseInfo.addedToWiki', {
+                [language.onlyIfOptions]: ['date'],
+                date: language.formatDate(data.dateAddedToWiki),
+              }),
             ]),
 
           sec.additionalFiles && [
             sec.additionalFiles.heading
               .slots({
-                id: 'additional-files',
-                title:
-                  language.$('releaseInfo.additionalFiles.heading', {
-                    additionalFiles:
-                      language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}),
-                  }),
+                attributes: {id: 'additional-files'},
+                title: language.$('releaseInfo.additionalFiles.heading'),
               }),
 
             sec.additionalFiles.additionalFilesList,
diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js
index 6fc1375b..1cd638ce 100644
--- a/src/content/dependencies/generateAlbumReleaseInfo.js
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -1,4 +1,4 @@
-import {accumulateSum, empty} from '#sugar';
+import {accumulateSum} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -23,11 +23,9 @@ export default {
     relations.bannerArtistContributionsLine =
       relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs);
 
-    if (!empty(album.urls)) {
-      relations.externalLinks =
-        album.urls.map(url =>
-          relation('linkExternal', url));
-    }
+    relations.externalLinks =
+      album.urls.map(url =>
+        relation('linkExternal', url));
 
     return relations;
   },
@@ -70,41 +68,42 @@ export default {
           relations.bannerArtistContributionsLine
             .slots({stringKey: 'releaseInfo.bannerArtBy'}),
 
-          data.date &&
-            language.$('releaseInfo.released', {
-              date: language.formatDate(data.date),
-            }),
-
-          data.coverArtDate &&
-            language.$('releaseInfo.artReleased', {
-              date: language.formatDate(data.coverArtDate),
-            }),
-
-          data.duration &&
-            language.$('releaseInfo.duration', {
-              duration:
-                language.formatDuration(data.duration, {
-                  approximate: data.durationApproximate,
-                }),
-            }),
+          language.$('releaseInfo.released', {
+            [language.onlyIfOptions]: ['date'],
+            date: language.formatDate(data.date),
+          }),
+
+          language.$('releaseInfo.artReleased', {
+            [language.onlyIfOptions]: ['date'],
+            date: language.formatDate(data.coverArtDate),
+          }),
+
+          language.$('releaseInfo.duration', {
+            [language.onlyIfOptions]: ['duration'],
+            duration:
+              language.formatDuration(data.duration, {
+                approximate: data.durationApproximate,
+              }),
+          }),
         ]),
 
-      relations.externalLinks &&
-        html.tag('p',
-          language.$('releaseInfo.listenOn', {
-            links:
-              language.formatDisjunctionList(
-                relations.externalLinks
-                  .map(link =>
-                    link.slot('context', [
-                      'album',
-                      (data.numTracks === 0
-                        ? 'albumNoTracks'
-                     : data.numTracks === 1
-                        ? 'albumOneTrack'
-                        : 'albumMultipleTracks'),
-                    ]))),
-          })),
+      html.tag('p',
+        {[html.onlyIfContent]: true},
+        language.$('releaseInfo.listenOn', {
+          [language.onlyIfOptions]: ['links'],
+          links:
+            language.formatDisjunctionList(
+              relations.externalLinks
+                .map(link =>
+                  link.slot('context', [
+                    'album',
+                    (data.numTracks === 0
+                      ? 'albumNoTracks'
+                   : data.numTracks === 1
+                      ? 'albumOneTrack'
+                      : 'albumMultipleTracks'),
+                  ]))),
+        })),
     ]);
   },
 };
diff --git a/src/content/dependencies/generateAlbumSecondaryNav.js b/src/content/dependencies/generateAlbumSecondaryNav.js
index 400420ba..d6ff8a0a 100644
--- a/src/content/dependencies/generateAlbumSecondaryNav.js
+++ b/src/content/dependencies/generateAlbumSecondaryNav.js
@@ -59,11 +59,11 @@ export default {
       relation('generateSecondaryNav');
 
     relations.groupLinks =
-      album.groups
+      query.groups
         .map(group => relation('linkGroup', group));
 
     relations.colorStyles =
-      album.groups
+      query.groups
         .map(group => relation('generateColorStyleAttribute', group.color));
 
     if (album.date) {
@@ -102,7 +102,7 @@ export default {
   generate(relations, slots, {html, language}) {
     const navLinksShouldShowPreviousNext =
       (slots.mode === 'track'
-        ? Array.from(relations.previousNextLinks, () => false)
+        ? Array.from(relations.previousNextLinks ?? [], () => false)
         : stitchArrays({
             previousAlbumLink: relations.previousAlbumLinks ?? null,
             nextAlbumLink: relations.nextAlbumLinks ?? null,
@@ -151,11 +151,8 @@ export default {
       stitchArrays({
         content: navLinkContents,
         colorStyle: relations.colorStyles,
-      }).map(({content, colorStyle}, index) =>
+      }).map(({content, colorStyle}) =>
           html.tag('span', {class: 'nav-link'},
-            index > 0 &&
-              {class: 'has-divider'},
-
             colorStyle.slot('context', 'primary-only'),
 
             content));
diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js
index 00a96c31..cc9b2c13 100644
--- a/src/content/dependencies/generateAlbumSidebarGroupBox.js
+++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js
@@ -1,5 +1,5 @@
 import {sortChronologically} from '#sort';
-import {atOffset, empty} from '#sugar';
+import {atOffset} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -89,26 +89,31 @@ export default {
           relations.description
             ?.slot('mode', 'multiline'),
 
-        !empty(relations.externalLinks) &&
-          html.tag('p',
-            language.$('releaseInfo.visitOn', {
-              links:
-                language.formatDisjunctionList(
-                  relations.externalLinks
-                    .map(link => link.slot('context', 'group'))),
-            })),
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+
+          language.$('releaseInfo.visitOn', {
+            [language.onlyIfOptions]: ['links'],
+
+            links:
+              language.formatDisjunctionList(
+                relations.externalLinks
+                  .map(link => link.slot('context', 'group'))),
+          })),
 
         slots.mode === 'album' &&
-        relations.nextAlbumLink &&
           html.tag('p', {class: 'group-chronology-link'},
+            {[html.onlyIfContent]: true},
             language.$('albumSidebar.groupBox.next', {
+              [language.onlyIfOptions]: ['album'],
               album: relations.nextAlbumLink,
             })),
 
         slots.mode === 'album' &&
-        relations.previousAlbumLink &&
           html.tag('p', {class: 'group-chronology-link'},
+            {[html.onlyIfContent]: true},
             language.$('albumSidebar.groupBox.previous', {
+              [language.onlyIfOptions]: ['album'],
               album: relations.previousAlbumLink,
             })),
       ],
diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js
index ee06b9e6..dd3e85e3 100644
--- a/src/content/dependencies/generateAlbumTrackList.js
+++ b/src/content/dependencies/generateAlbumTrackList.js
@@ -149,6 +149,7 @@ export default {
             }) => [
               heading.slots({
                 tag: 'dt',
+
                 title:
                   (duration === 0
                     ? language.$('trackList.section', {
@@ -161,6 +162,11 @@ export default {
                             approximate: durationApproximate,
                           }),
                       })),
+
+                stickyTitle:
+                  language.$('trackList.section.sticky', {
+                    section: name,
+                  }),
               }),
 
               html.tag('dd',
diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js
index 18980740..7190fb4c 100644
--- a/src/content/dependencies/generateAlbumTrackListItem.js
+++ b/src/content/dependencies/generateAlbumTrackListItem.js
@@ -64,8 +64,8 @@ export default {
       !empty(track.artistContribs) &&
        (empty(album.artistContribs) ||
         !compareArrays(
-          track.artistContribs.map(c => c.who),
-          album.artistContribs.map(c => c.who),
+          track.artistContribs.map(contrib => contrib.artist),
+          album.artistContribs.map(contrib => contrib.artist),
           {checkOrder: false}));
 
     return data;
diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js
index 338d18fe..eae48f05 100644
--- a/src/content/dependencies/generateArtTagGalleryPage.js
+++ b/src/content/dependencies/generateArtTagGalleryPage.js
@@ -80,7 +80,7 @@ export default {
     data.coverArtists =
       query.things.map(thing =>
         thing.coverArtistContribs
-          .map(({who: artist}) => artist.name));
+          .map(({artist}) => artist.name));
 
     return data;
   },
diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js
index 36343c18..db8f123f 100644
--- a/src/content/dependencies/generateArtistGalleryPage.js
+++ b/src/content/dependencies/generateArtistGalleryPage.js
@@ -75,8 +75,8 @@ export default {
       query.things.map(thing =>
         (thing.coverArtistContribs.length > 1
           ? thing.coverArtistContribs
-              .filter(({who}) => who !== artist)
-              .map(({who}) => who.name)
+              .filter(({artist: otherArtist}) => otherArtist !== artist)
+              .map(({artist: otherArtist}) => otherArtist.name)
           : null));
 
     return data;
diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js
index 1725d4b9..ef81739d 100644
--- a/src/content/dependencies/generateArtistGroupContributionsInfo.js
+++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js
@@ -161,9 +161,15 @@ export default {
       slots.visible && 'visible',
     ];
 
+    // TODO: It feels pretty awkward that this component is the only one that
+    // has enough knowledge to decide if the sort button is even applicable...
+    const switchingSortPossible =
+      !empty(relations.groupLinksSortedByCount) &&
+      !empty(relations.groupLinksSortedByDuration);
+
     return html.tags([
       html.tag('dt', {class: topLevelClasses},
-        (slots.showSortButton
+        (switchingSortPossible && slots.showSortButton
           ? language.$('artistPage.groupContributions.title.withSortButton', {
               title: slots.title,
               sort:
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
index ac9209a7..28b9e1d3 100644
--- a/src/content/dependencies/generateArtistInfoPage.js
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -153,7 +153,9 @@ export default {
 
         mainContent: [
           sec.contextNotes && [
-            html.tag('p', language.$('releaseInfo.note')),
+            html.tag('p',
+              language.$('releaseInfo.note')),
+
             html.tag('blockquote',
               sec.contextNotes.content),
           ],
@@ -206,7 +208,7 @@ export default {
             sec.tracks.heading
               .slots({
                 tag: 'h2',
-                id: 'tracks',
+                attributes: {id: 'tracks'},
                 title: language.$('artistPage.trackList.title'),
               }),
 
@@ -251,7 +253,7 @@ export default {
             sec.artworks.heading
               .slots({
                 tag: 'h2',
-                id: 'art',
+                attributes: {id: 'art'},
                 title: language.$('artistPage.artList.title'),
               }),
 
@@ -280,7 +282,7 @@ export default {
             sec.flashes.heading
               .slots({
                 tag: 'h2',
-                id: 'flashes',
+                attributes: {id: 'flashes'},
                 title: language.$('artistPage.flashList.title'),
               }),
 
@@ -291,7 +293,7 @@ export default {
             sec.commentary.heading
               .slots({
                 tag: 'h2',
-                id: 'commentary',
+                attributes: {id: 'commentary'},
                 title: language.$('artistPage.commentaryList.title'),
               }),
 
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
index 0beeb271..44fb42f2 100644
--- a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js
@@ -171,8 +171,8 @@ export default {
         query.chunks.map(({chunk}) =>
           chunk.map(({contribs}) =>
             contribs
-              .find(({who}) => who === artist)
-              .what)),
+              .find(contrib => contrib.artist === artist)
+              .annotation)),
     };
   },
 
diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
index 88a97af2..447e697e 100644
--- a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js
@@ -92,8 +92,8 @@ export default {
         query.chunks.map(({chunk}) =>
           chunk.map(({contribs}) =>
             contribs
-              .find(({who}) => who === artist)
-              .what)),
+              .find(contrib => contrib.artist === artist)
+              .annotation)),
     };
   },
 
diff --git a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
index dea7742a..471ee26c 100644
--- a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
+++ b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js
@@ -4,7 +4,8 @@ export default {
   contentDependencies: ['linkArtist'],
 
   relations(relation, contribs, artist) {
-    const otherArtistContribs = contribs.filter(({who}) => who !== artist);
+    const otherArtistContribs =
+      contribs.filter(contrib => contrib.artist !== artist);
 
     if (empty(otherArtistContribs)) {
       return {};
@@ -12,7 +13,7 @@ export default {
 
     const otherArtistLinks =
       otherArtistContribs
-        .map(({who}) => relation('linkArtist', who));
+        .map(contrib => relation('linkArtist', contrib.artist));
 
     return {otherArtistLinks};
   },
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
index f003779d..bce6cedf 100644
--- a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js
@@ -150,7 +150,7 @@ export default {
         query.chunks.map(({chunk}) =>
           chunk
             .map(({contribs}) =>
-              contribs.filter(({who}) => who === artist))
+              contribs.filter(contrib => contrib.artist === artist))
             .map(ownContribs => ({
               creditedAsArtist:
                 ownContribs
@@ -162,7 +162,7 @@ export default {
 
               annotatedContribs:
                 ownContribs
-                  .filter(({what}) => what),
+                  .filter(({annotation}) => annotation),
             }))
             .map(({annotatedContribs, ...rest}) => ({
               ...rest,
@@ -203,7 +203,7 @@ export default {
               ];
             })
             .map(contribs =>
-              contribs.map(({what}) => what))
+              contribs.map(({annotation}) => annotation))
             .map(contributions =>
               (empty(contributions)
                 ? null
diff --git a/src/content/dependencies/generateChronologyLinks.js b/src/content/dependencies/generateChronologyLinks.js
index 8ec6ee0a..7f24ded7 100644
--- a/src/content/dependencies/generateChronologyLinks.js
+++ b/src/content/dependencies/generateChronologyLinks.js
@@ -4,6 +4,16 @@ export default {
   extraDependencies: ['html', 'language'],
 
   slots: {
+    allowCollapsing: {
+      type: 'boolean',
+      default: true,
+    },
+
+    showOnly: {
+      type: 'boolean',
+      default: false,
+    },
+
     chronologyInfoSets: {
       validate: v =>
         v.strictArrayOf(
@@ -11,6 +21,8 @@ export default {
             headingString: v.isString,
             contributions: v.strictArrayOf(v.validateProperties({
               index: v.isCountingNumber,
+              only: v.isBoolean,
+              artistDirectory: v.isDirectory,
               artistLink: v.isHTML,
               previousLink: v.isHTML,
               nextLink: v.isHTML,
@@ -24,22 +36,35 @@ export default {
       return html.blank();
     }
 
+    let infoSets = slots.chronologyInfoSets;
+
+    if (!slots.showOnly) {
+      infoSets = infoSets
+        .map(({contributions, ...entry}) => ({
+          ...entry,
+          contributions:
+            contributions
+              .filter(({only}) => !only),
+        }))
+        .filter(({contributions}) => !empty(contributions));
+    }
+
     const totalContributionCount =
       accumulateSum(
-        slots.chronologyInfoSets,
+        infoSets,
         ({contributions}) => contributions.length);
 
     if (totalContributionCount === 0) {
       return html.blank();
     }
 
-    if (totalContributionCount > 8) {
+    if (slots.allowCollapsing && totalContributionCount > 8) {
       return html.tag('div', {class: 'chronology'},
         language.$('misc.chronology.seeArtistPages'));
     }
 
     return html.tags(
-      slots.chronologyInfoSets.map(({
+      infoSets.map(({
         headingString,
         contributions,
       }) =>
@@ -48,16 +73,21 @@ export default {
           artistLink,
           previousLink,
           nextLink,
+          only,
         }) => {
           const heading =
             html.tag('span', {class: 'heading'},
               language.$(headingString, {
-                index: language.formatIndex(index),
+                index:
+                  (only
+                    ? language.formatString('misc.chronology.heading.onlyIndex')
+                    : language.formatIndex(index)),
+
                 artist: artistLink,
               }));
 
           const navigation =
-            (previousLink || nextLink) &&
+            !only &&
               html.tag('span', {class: 'buttons'},
                 language.formatUnitList([
                   previousLink?.slots({
diff --git a/src/content/dependencies/generateChronologyLinksScopeSwitcher.js b/src/content/dependencies/generateChronologyLinksScopeSwitcher.js
new file mode 100644
index 00000000..23c44268
--- /dev/null
+++ b/src/content/dependencies/generateChronologyLinksScopeSwitcher.js
@@ -0,0 +1,67 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  slots: {
+    scopes: {
+      validate: v => v.strictArrayOf(v.isStringNonEmpty),
+    },
+
+    contents: {
+      validate: v => v.strictArrayOf(v.isHTML),
+    },
+
+    open: {
+      type: 'boolean',
+      default: true,
+    },
+  },
+
+  generate(slots, {html, language}) {
+    // TODO: Manual [html.onlyIfContent]-alike here is a bit unfortunate.
+    // We can't use a normal [html.onlyIfContent] because the summary counts
+    // as content - we'd need to encode that we want to exclude it from the
+    // content check (for the <details> element), somehow.
+    if (slots.contents.every(content => html.isBlank(content))) {
+      return html.blank();
+    }
+
+    const summary =
+      html.tag('summary',
+        {class: 'underline-white'},
+
+        html.tag('span',
+          language.$('trackPage.nav.chronology.scope.title', {
+            scope:
+              slots.scopes.map((scope, index) =>
+                html.tag('a', {class: 'switcher-link'},
+                  {href: '#'},
+
+                  (index === 0
+                    ? {style: 'display: inline'}
+                    : {style: 'display: none'}),
+
+                  language.$('trackPage.nav.chronology.scope', scope))),
+          })));
+
+    const scopeContents =
+      stitchArrays({
+        scope: slots.scopes,
+        content: slots.contents,
+      }).map(({scope, content}, index) =>
+          html.tag('div', {class: 'scope-' + scope},
+            (index === 0
+              ? {style: 'display: block'}
+              : {style: 'display: none'}),
+
+            content));
+
+    return (
+      html.tag('details', {class: 'scoped-chronology-switcher'},
+        slots.open &&
+          {open: true},
+
+        [summary, scopeContents]));
+  },
+};
diff --git a/src/content/dependencies/generateColorStyleVariables.js b/src/content/dependencies/generateColorStyleVariables.js
index 069d85dd..5270dbe4 100644
--- a/src/content/dependencies/generateColorStyleVariables.js
+++ b/src/content/dependencies/generateColorStyleVariables.js
@@ -32,6 +32,7 @@ export default {
       dim,
       deep,
       deepGhost,
+      lightGhost,
       bg,
       bgBlack,
       shadow,
@@ -43,6 +44,7 @@ export default {
       `--dim-color: ${dim}`,
       `--deep-color: ${deep}`,
       `--deep-ghost-color: ${deepGhost}`,
+      `--light-ghost-color: ${lightGhost}`,
       `--bg-color: ${bg}`,
       `--bg-black-color: ${bgBlack}`,
       `--shadow-color: ${shadow}`,
diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js
index 522a0284..036f8a6f 100644
--- a/src/content/dependencies/generateCommentaryEntry.js
+++ b/src/content/dependencies/generateCommentaryEntry.js
@@ -61,19 +61,14 @@ export default {
         relations.annotationContent.slot('mode', 'inline');
     }
 
-    if (data.date) {
-      accentParts.push('withDate');
-      accentOptions.date =
-        language.formatDate(data.date);
-    }
-
     const accent =
       (accentParts.length > 1
         ? html.tag('span', {class: 'commentary-entry-accent'},
             language.$(...accentParts, accentOptions))
         : null);
 
-    const titleParts = ['misc.artistCommentary.entry.title'];
+    const titlePrefix = 'misc.artistCommentary.entry.title';
+    const titleParts = [titlePrefix];
     const titleOptions = {artists: artistsSpan};
 
     if (accent) {
@@ -88,7 +83,16 @@ export default {
     return html.tags([
       html.tag('p', {class: 'commentary-entry-heading'},
         style,
-        language.$(...titleParts, titleOptions)),
+        [
+          html.tag('time',
+            {[html.onlyIfContent]: true},
+            language.$(titlePrefix, 'date', {
+              [language.onlyIfOptions]: ['date'],
+              date: language.formatDate(data.date),
+            })),
+
+          language.$(...titleParts, titleOptions),
+        ]),
 
       html.tag('blockquote', {class: 'commentary-entry-body'},
         style,
diff --git a/src/content/dependencies/generateCommentarySection.js b/src/content/dependencies/generateCommentarySection.js
index 8ae1b2d0..39727360 100644
--- a/src/content/dependencies/generateCommentarySection.js
+++ b/src/content/dependencies/generateCommentarySection.js
@@ -16,12 +16,23 @@ export default {
         relation('generateCommentaryEntry', entry)),
   }),
 
-  generate: (relations, {html, language}) =>
+  data: (entries) => ({
+    firstEntryIsDated:
+      (entries[0]
+        ? !!entries[0].date
+        : null),
+  }),
+
+  generate: (data, relations, {html, language}) =>
     html.tags([
       relations.heading
         .slots({
-          id: 'artist-commentary',
-          title: language.$('misc.artistCommentary')
+          title: language.$('misc.artistCommentary'),
+          attributes: [
+            {id: 'artist-commentary'},
+            data.firstEntryIsDated &&
+              {class: 'first-entry-is-dated'},
+          ],
         }),
 
       relations.entries,
diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js
index 469db876..eafe77d8 100644
--- a/src/content/dependencies/generateContentHeading.js
+++ b/src/content/dependencies/generateContentHeading.js
@@ -12,23 +12,34 @@ export default {
       mutable: false,
     },
 
+    stickyTitle: {
+      type: 'html',
+      mutable: false,
+    },
+
     accent: {
       type: 'html',
       mutable: false,
     },
 
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
     color: {validate: v => v.isColor},
 
-    id: {type: 'string'},
-    tag: {type: 'string', default: 'p'},
+    tag: {
+      type: 'string',
+      default: 'p',
+    },
   },
 
   generate: (relations, slots, {html}) =>
     html.tag(slots.tag, {class: 'content-heading'},
       {tabindex: '0'},
 
-      slots.id &&
-        {id: slots.id},
+      slots.attributes,
 
       slots.color &&
         relations.colorStyle.slot('color', slots.color),
@@ -38,6 +49,10 @@ export default {
           {[html.onlyIfContent]: true},
           slots.title),
 
+        html.tag('template', {class: 'content-heading-sticky-title'},
+          {[html.onlyIfContent]: true},
+          slots.stickyTitle),
+
         html.tag('span', {class: 'content-heading-accent'},
           {[html.onlyIfContent]: true},
           slots.accent),
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index 90c9db98..3d5a614f 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -1,4 +1,4 @@
-import {empty, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: ['image', 'linkArtTag'],
@@ -89,14 +89,15 @@ export default {
             ...sizeSlots,
           }),
 
-          !empty(relations.tagLinks) &&
-            html.tag('ul', {class: 'image-details'},
-              stitchArrays({
-                tagLink: relations.tagLinks,
-                preferShortName: data.preferShortName,
-              }).map(({tagLink, preferShortName}) =>
-                  html.tag('li',
-                    tagLink.slot('preferShortName', preferShortName)))),
+          html.tag('ul', {class: 'image-details'},
+            {[html.onlyIfContent]: true},
+
+            stitchArrays({
+              tagLink: relations.tagLinks,
+              preferShortName: data.preferShortName,
+            }).map(({tagLink, preferShortName}) =>
+                html.tag('li',
+                  tagLink.slot('preferShortName', preferShortName)))),
         ]);
 
       case 'thumbnail':
diff --git a/src/content/dependencies/generateFlashCoverArtwork.js b/src/content/dependencies/generateFlashCoverArtwork.js
index 374fa3f8..af03ae6b 100644
--- a/src/content/dependencies/generateFlashCoverArtwork.js
+++ b/src/content/dependencies/generateFlashCoverArtwork.js
@@ -1,12 +1,26 @@
 export default {
   contentDependencies: ['generateCoverArtwork'],
 
-  relations: (relation) =>
-    ({coverArtwork: relation('generateCoverArtwork')}),
+  relations: (relation) => ({
+    coverArtwork:
+      relation('generateCoverArtwork'),
+  }),
 
-  data: (flash) =>
-    ({path: ['media.flashArt', flash.directory, flash.coverArtFileExtension]}),
+  data: (flash) => ({
+    path:
+      ['media.flashArt', flash.directory, flash.coverArtFileExtension],
+
+    color:
+      flash.color,
+
+    dimensions:
+      flash.coverArtDimensions,
+  }),
 
   generate: (data, relations) =>
-    relations.coverArtwork.slot('path', data.path),
+    relations.coverArtwork.slots({
+      path: data.path,
+      color: data.color,
+      dimensions: data.dimensions,
+    }),
 };
diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js
index 36bfabae..eaea7e9c 100644
--- a/src/content/dependencies/generateFlashIndexPage.js
+++ b/src/content/dependencies/generateFlashIndexPage.js
@@ -1,4 +1,4 @@
-import {empty, stitchArrays} from '#sugar';
+import {stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -87,11 +87,13 @@ export default {
 
       mainClasses: ['flash-index'],
       mainContent: [
-        !empty(data.jumpLinkLabels) && [
+        html.tags([
           html.tag('p', {class: 'quick-info'},
+            {[html.onlyIfSiblings]: true},
             language.$('misc.jumpTo')),
 
           html.tag('ul', {class: 'quick-info'},
+            {[html.onlyIfContent]: true},
             stitchArrays({
               colorStyle: relations.jumpLinkColorStyles,
               anchor: data.jumpLinkAnchors,
@@ -102,7 +104,7 @@ export default {
                     {href: '#' + anchor},
                     colorStyle,
                     label)))),
-        ],
+        ]),
 
         stitchArrays({
           colorStyle: relations.actColorStyles,
diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
index 05964936..eec32157 100644
--- a/src/content/dependencies/generateFlashInfoPage.js
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -19,16 +19,14 @@ export default {
   query(flash) {
     const query = {};
 
-    if (flash.page || !empty(flash.urls)) {
-      query.urls = [];
+    query.urls = [];
 
-      if (flash.page) {
-        query.urls.push(`https://homestuck.com/story/${flash.page}`);
-      }
+    if (flash.page) {
+      query.urls.push(`https://homestuck.com/story/${flash.page}`);
+    }
 
-      if (!empty(flash.urls)) {
-        query.urls.push(...flash.urls);
-      }
+    if (!empty(flash.urls)) {
+      query.urls.push(...flash.urls);
     }
 
     return query;
@@ -44,10 +42,9 @@ export default {
     relations.sidebar =
       relation('generateFlashActSidebar', flash.act, flash);
 
-    if (query.urls) {
-      relations.externalLinks =
-        query.urls.map(url => relation('linkExternal', url));
-    }
+    relations.externalLinks =
+      query.urls
+        .map(url => relation('linkExternal', url));
 
     // TODO: Flashes always have cover art (#175)
     /* eslint-disable-next-line no-constant-condition */
@@ -135,14 +132,15 @@ export default {
             date: language.formatDate(data.date),
           })),
 
-        relations.externalLinks &&
-          html.tag('p',
-            language.$('releaseInfo.playOn', {
-              links:
-                language.formatDisjunctionList(
-                  relations.externalLinks
-                    .map(link => link.slot('context', 'flash'))),
-            })),
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          language.$('releaseInfo.playOn', {
+            [language.onlyIfOptions]: ['links'],
+            links:
+              language.formatDisjunctionList(
+                relations.externalLinks
+                  .map(link => link.slot('context', 'flash'))),
+          })),
 
         html.tag('p',
           {[html.onlyIfContent]: true},
@@ -160,7 +158,7 @@ export default {
         sec.featuredTracks && [
           sec.featuredTracks.heading
             .slots({
-              id: 'features',
+              attributes: {id: 'features'},
               title:
                 language.$('releaseInfo.tracksFeatured', {
                   flash: html.tag('i', data.name),
@@ -173,7 +171,7 @@ export default {
         sec.contributors && [
           sec.contributors.heading
             .slots({
-              id: 'contributors',
+              attributes: {id: 'contributors'},
               title: language.$('releaseInfo.contributors'),
             }),
 
diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js
index b5b456aa..e6b0ded1 100644
--- a/src/content/dependencies/generateGroupInfoPage.js
+++ b/src/content/dependencies/generateGroupInfoPage.js
@@ -69,11 +69,9 @@ export default {
 
     sec.info = {};
 
-    if (!empty(group.urls)) {
-      sec.info.visitLinks =
-        group.urls
-          .map(url => relation('linkExternal', url));
-    }
+    sec.info.visitLinks =
+      group.urls
+        .map(url => relation('linkExternal', url));
 
     if (group.description) {
       sec.info.description =
@@ -131,14 +129,15 @@ export default {
         color: data.color,
 
         mainContent: [
-          sec.info.visitLinks &&
-            html.tag('p',
-              language.$('releaseInfo.visitOn', {
-                links:
-                  language.formatDisjunctionList(
-                    sec.info.visitLinks
-                      .map(link => link.slot('context', 'group'))),
-              })),
+          html.tag('p',
+            {[html.onlyIfContent]: true},
+            language.$('releaseInfo.visitOn', {
+              [language.onlyIfOptions]: ['links'],
+              links:
+                language.formatDisjunctionList(
+                  sec.info.visitLinks
+                    .map(link => link.slot('context', 'group'))),
+            })),
 
           html.tag('blockquote',
             {[html.onlyIfContent]: true},
diff --git a/src/content/dependencies/generateGroupSecondaryNav.js b/src/content/dependencies/generateGroupSecondaryNav.js
index 17eb5083..a4f81313 100644
--- a/src/content/dependencies/generateGroupSecondaryNav.js
+++ b/src/content/dependencies/generateGroupSecondaryNav.js
@@ -69,12 +69,16 @@ export default {
   }),
 
   generate(data, relations, {html, language}) {
-    const {content: previousNextPart} =
-      relations.previousNextLinks.slots({
-        previousLink: relations.previousGroupLink,
-        nextLink: relations.nextGroupLink,
-        id: true,
-      });
+    const previousNextPart =
+      (relations.previousNextLinks
+        ? relations.previousNextLinks
+            .slots({
+              previousLink: relations.previousGroupLink,
+              nextLink: relations.nextGroupLink,
+              id: true,
+            })
+            .content /* TODO: Kludge. */
+        : null);
 
     const {categoryLink} = relations;
 
@@ -83,7 +87,7 @@ export default {
     return relations.secondaryNav.slots({
       class: 'nav-links-groups',
       content:
-        (relations.previousGroupLink || relations.nextGroupLink
+        (previousNextPart
           ? html.tag('span', {class: 'nav-link'},
               relations.colorStyle.slot('context', 'primary-only'),
 
diff --git a/src/content/dependencies/generateGroupSidebar.js b/src/content/dependencies/generateGroupSidebar.js
index 3abb3392..0888cbbe 100644
--- a/src/content/dependencies/generateGroupSidebar.js
+++ b/src/content/dependencies/generateGroupSidebar.js
@@ -2,6 +2,7 @@ export default {
   contentDependencies: [
     'generateGroupSidebarCategoryDetails',
     'generatePageSidebar',
+    'generatePageSidebarBox',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
@@ -12,6 +13,9 @@ export default {
     sidebar:
       relation('generatePageSidebar'),
 
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
     categoryDetails:
       sprawl.groupCategoryData.map(category =>
         relation('generateGroupSidebarCategoryDetails', category, group)),
@@ -25,15 +29,18 @@ export default {
 
   generate: (relations, slots, {html, language}) =>
     relations.sidebar.slots({
-      attributes: {class: 'category-map-sidebar-box'},
-
-      content: [
-        html.tag('h1',
-          language.$('groupSidebar.title')),
-
-        relations.categoryDetails
-          .map(details =>
-            details.slot('currentExtra', slots.currentExtra)),
+      boxes: [
+        relations.sidebarBox.slots({
+          attributes: {class: 'category-map-sidebar-box'},
+          content: [
+            html.tag('h1',
+              language.$('groupSidebar.title')),
+
+            relations.categoryDetails
+              .map(details =>
+                details.slot('currentExtra', slots.currentExtra)),
+          ],
+        }),
       ],
     }),
 };
diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js
index 23377afb..5f9a99a9 100644
--- a/src/content/dependencies/generateListingPage.js
+++ b/src/content/dependencies/generateListingPage.js
@@ -34,13 +34,15 @@ export default {
       relations.sameTargetListingLinks =
         listing.target.listings
           .map(listing => relation('linkListing', listing));
+    } else {
+      relations.sameTargetListingLinks = [];
     }
 
-    if (!empty(listing.seeAlso)) {
-      relations.seeAlsoLinks =
-        listing.seeAlso
-          .map(listing => relation('linkListing', listing));
-    }
+    relations.seeAlsoLinks =
+      (!empty(listing.seeAlso)
+        ? listing.seeAlso
+            .map(listing => relation('linkListing', listing))
+        : []);
 
     return relations;
   },
@@ -167,33 +169,37 @@ export default {
       headingMode: 'sticky',
 
       mainContent: [
-        relations.sameTargetListingLinks &&
-          html.tag('p',
-            language.$('listingPage.listingsFor', {
-              target:
-                language.$('listingPage.target', data.targetStringsKey),
-
-              listings:
-                language.formatUnitList(
-                  stitchArrays({
-                    link: relations.sameTargetListingLinks,
-                    stringsKey: data.sameTargetListingStringsKeys,
-                  }).map(({link, stringsKey}, index) =>
-                      html.tag('span',
-                        index === data.sameTargetListingsCurrentIndex &&
-                          {class: 'current'},
-
-                        link.slots({
-                          attributes: {class: 'nowrap'},
-                          content: language.$('listingPage', stringsKey, 'title.short'),
-                        })))),
-            })),
-
-        relations.seeAlsoLinks &&
-          html.tag('p',
-            language.$('listingPage.seeAlso', {
-              listings: language.formatUnitList(relations.seeAlsoLinks),
-            })),
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          language.$('listingPage.listingsFor', {
+            [language.onlyIfOptions]: ['listings'],
+
+            target:
+              language.$('listingPage.target', data.targetStringsKey),
+
+            listings:
+              language.formatUnitList(
+                stitchArrays({
+                  link: relations.sameTargetListingLinks,
+                  stringsKey: data.sameTargetListingStringsKeys,
+                }).map(({link, stringsKey}, index) =>
+                    html.tag('span',
+                      index === data.sameTargetListingsCurrentIndex &&
+                        {class: 'current'},
+
+                      link.slots({
+                        attributes: {class: 'nowrap'},
+                        content: language.$('listingPage', stringsKey, 'title.short'),
+                      })))),
+          })),
+
+        html.tag('p',
+          {[html.onlyIfContent]: true},
+          language.$('listingPage.seeAlso', {
+            [language.onlyIfOptions]: ['listings'],
+            listings:
+              language.formatUnitList(relations.seeAlsoLinks),
+          })),
 
         slots.content,
 
@@ -243,7 +249,7 @@ export default {
                   .clone()
                   .slots({
                     tag: 'dt',
-                    id,
+                    attributes: [id && {id}],
 
                     title:
                       formatListingString({
diff --git a/src/content/dependencies/generateListingSidebar.js b/src/content/dependencies/generateListingSidebar.js
index 1e5c8bfc..aeac05cf 100644
--- a/src/content/dependencies/generateListingSidebar.js
+++ b/src/content/dependencies/generateListingSidebar.js
@@ -2,6 +2,7 @@ export default {
   contentDependencies: [
     'generateListingIndexList',
     'generatePageSidebar',
+    'generatePageSidebarBox',
     'linkListingIndex',
   ],
 
@@ -11,6 +12,9 @@ export default {
     sidebar:
       relation('generatePageSidebar'),
 
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+
     listingIndexLink:
       relation('linkListingIndex'),
 
@@ -20,10 +24,14 @@ export default {
 
   generate: (relations, {html}) =>
     relations.sidebar.slots({
-      attributes: {class: 'listing-map-sidebar-box'},
-      content: [
-        html.tag('h1', relations.listingIndexLink),
-        relations.listingIndexList.slot('mode', 'sidebar'),
+      boxes: [
+        relations.sidebarBox.slots({
+          attributes: {class: 'listing-map-sidebar-box'},
+          content: [
+            html.tag('h1', relations.listingIndexLink),
+            relations.listingIndexList.slot('mode', 'sidebar'),
+          ],
+        }),
       ],
     }),
 };
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js
index cbfc905a..e138a981 100644
--- a/src/content/dependencies/generatePageLayout.js
+++ b/src/content/dependencies/generatePageLayout.js
@@ -5,12 +5,13 @@ export default {
   contentDependencies: [
     'generateColorStyleRules',
     'generateFooterLocalizationLinks',
+    'generatePageSidebar',
+    'generateSearchSidebarBox',
     'generateStickyHeadingContainer',
     'transformContent',
   ],
 
   extraDependencies: [
-    'cachebust',
     'getColors',
     'html',
     'language',
@@ -21,6 +22,7 @@ export default {
 
   sprawl({wikiInfo}) {
     return {
+      enableSearch: wikiInfo.enableSearch,
       footerContent: wikiInfo.footerContent,
       wikiColor: wikiInfo.color,
       wikiName: wikiInfo.nameShort,
@@ -43,6 +45,14 @@ export default {
     relations.stickyHeadingContainer =
       relation('generateStickyHeadingContainer');
 
+    relations.sidebar =
+      relation('generatePageSidebar');
+
+    if (sprawl.enableSearch) {
+      relations.searchBox =
+        relation('generateSearchSidebarBox');
+    }
+
     if (sprawl.footerContent) {
       relations.defaultFooterContent =
         relation('transformContent', sprawl.footerContent);
@@ -65,6 +75,11 @@ export default {
       default: true,
     },
 
+    showSearch: {
+      type: 'boolean',
+      default: true,
+    },
+
     additionalNames: {
       type: 'html',
       mutable: false,
@@ -209,7 +224,6 @@ export default {
   },
 
   generate(data, relations, slots, {
-    cachebust,
     getColors,
     html,
     language,
@@ -348,9 +362,6 @@ export default {
                     showAsCurrent &&
                       {class: 'current'},
 
-                    i > 0 &&
-                      {class: 'has-divider'},
-
                     [
                       html.tag('span', {class: 'nav-link-content'},
                         // Use inline-block styling on the content span,
@@ -377,29 +388,54 @@ export default {
             slots.navContent),
         ]);
 
-    const getSidebar = (side, id) =>
-      (html.isBlank(slots[side])
-        ? html.blank()
-        : slots[side].slots({
-            attributes:
-              slots[side]
-                .getSlotValue('attributes')
-                .with({id}),
-          }));
+    const getSidebar = (side, id, needed) => {
+      const sidebar =
+        (html.isBlank(slots[side])
+          ? (needed
+              ? relations.sidebar.clone()
+              : html.blank())
+          : slots[side]);
+
+      if (html.isBlank(sidebar) && !needed) {
+        return sidebar;
+      }
+
+      return sidebar.slots({
+        attributes:
+          sidebar
+            .getSlotValue('attributes')
+            .with({id}),
+      });
+    }
+
+    const willShowSearch =
+      slots.showSearch && relations.searchBox;
+
+    let showingSidebarLeft;
+    let showingSidebarRight;
+
+    const leftSidebar = getSidebar('leftSidebar', 'sidebar-left', willShowSearch);
+    const rightSidebar = getSidebar('rightSidebar', 'sidebar-right', false);
 
-    const leftSidebar = getSidebar('leftSidebar', 'sidebar-left');
-    const rightSidebar = getSidebar('rightSidebar', 'sidebar-right');
+    if (willShowSearch) {
+      if (html.isBlank(leftSidebar)) {
+        leftSidebar.setSlot('initiallyHidden', true);
+        showingSidebarLeft = false;
+      }
+
+      leftSidebar.setSlot(
+        'boxes',
+        html.tags([
+          relations.searchBox,
+          leftSidebar.getSlotValue('boxes'),
+        ]));
+    }
 
     const hasSidebarLeft = !html.isBlank(html.resolve(leftSidebar));
     const hasSidebarRight = !html.isBlank(html.resolve(rightSidebar));
 
-    const collapseSidebars =
-      (hasSidebarLeft
-        ? leftSidebar.getSlotValue('collapse')
-        : true) &&
-      (hasSidebarRight
-        ? rightSidebar.getSlotValue('collapse')
-        : true);
+    showingSidebarLeft ??= hasSidebarLeft;
+    showingSidebarRight ??= hasSidebarRight;
 
     const processSkippers = skipperList =>
       skipperList
@@ -407,8 +443,11 @@ export default {
           (condition === undefined
             ? hasID(id)
             : condition))
+
         .map(({id, string}) =>
           html.tag('span', {class: 'skipper'},
+            {'data-for': id},
+
             html.tag('a',
               {href: `#${id}`},
               language.$('misc.skippers', string))));
@@ -512,15 +551,11 @@ export default {
 
       slots.secondaryNav,
 
-      html.tag('div', {class: 'layout-columns'},
-        !collapseSidebars &&
-          {class: 'vertical-when-thin'},
-
-        [
-          leftSidebar,
-          mainHTML,
-          rightSidebar,
-        ]),
+      html.tag('div', {class: 'layout-columns'}, [
+        leftSidebar,
+        mainHTML,
+        rightSidebar,
+      ]),
 
       slots.bannerPosition === 'bottom' &&
         slots.banner,
@@ -543,6 +578,8 @@ export default {
         {'data-rebase-localized': to('localized.root')},
         {'data-rebase-shared': to('shared.root')},
         {'data-rebase-media': to('media.root')},
+        {'data-rebase-thumb': to('thumb.root')},
+        {'data-rebase-lib': to('staticLib.root')},
         {'data-rebase-data': to('data.root')},
 
         [
@@ -613,7 +650,7 @@ export default {
 
             html.tag('link', {
               rel: 'stylesheet',
-              href: to('shared.staticFile', 'site6.css', cachebust),
+              href: to('staticCSS.path', 'site.css'),
             }),
 
             html.tag('style', [
@@ -623,25 +660,29 @@ export default {
             ]),
 
             html.tag('script', {
-              src: to('shared.staticFile', 'lazy-loading.js', cachebust),
+              src: to('staticLib.path', 'chroma-js/chroma.min.js'),
+            }),
+
+            html.tag('script', {
+              blocking: 'render',
+              src: to('staticJS.path', 'lazy-loading.js'),
+            }),
+
+            html.tag('script', {
+              blocking: 'render',
+              type: 'module',
+              src: to('staticJS.path', 'client.js'),
             }),
           ]),
 
           html.tag('body',
             [
               html.tag('div', {id: 'page-container'},
-                (hasSidebarLeft || hasSidebarRight
-                  ? {class: 'has-one-sidebar'}
-                  : {class: 'has-zero-sidebars'}),
-
-                hasSidebarLeft && hasSidebarRight &&
-                  {class: 'has-two-sidebars'},
+                showingSidebarLeft &&
+                  {class: 'showing-sidebar-left'},
 
-                hasSidebarLeft &&
-                  {class: 'has-sidebar-left'},
-
-                hasSidebarRight &&
-                  {class: 'has-sidebar-right'},
+                showingSidebarRight &&
+                  {class: 'showing-sidebar-right'},
 
                 [
                   skippersHTML,
@@ -650,11 +691,6 @@ export default {
 
               // infoCardHTML,
               imageOverlayHTML,
-
-              html.tag('script', {
-                type: 'module',
-                src: to('shared.staticFile', 'client3.js', cachebust),
-              }),
             ]),
         ])
     ]).toString();
diff --git a/src/content/dependencies/generatePageSidebar.js b/src/content/dependencies/generatePageSidebar.js
index a7da3d1d..d3b55580 100644
--- a/src/content/dependencies/generatePageSidebar.js
+++ b/src/content/dependencies/generatePageSidebar.js
@@ -1,29 +1,16 @@
 export default {
-  contentDependencies: ['generatePageSidebarBox'],
   extraDependencies: ['html'],
 
-  relations: (relation) => ({
-    box:
-      relation('generatePageSidebarBox'),
-  }),
-
   slots: {
-    // Content is a flat HTML array. It'll all be placed into one sidebar box
-    // if specified.
-    content: {
-      type: 'html',
-      mutable: false,
-    },
-
-    // Attributes to apply to the whole sidebar. If specifying multiple
-    // sections, this be added to the containing sidebar-column, arr - specify
-    // attributes on each section if that's more suitable.
+    // Attributes to apply to the whole sidebar. This be added to the
+    // containing sidebar-column, arr - specify attributes on each section if
+    // that's more suitable.
     attributes: {
       type: 'attributes',
       mutable: false,
     },
 
-    // Chunks of content to be split into separate boxes in the sidebar.
+    // Content boxes to line up vertically in the sidebar.
     boxes: {
       type: 'html',
       mutable: false,
@@ -32,27 +19,16 @@ export default {
     // Sticky mode controls which sidebar sections, if any, follow the
     // scroll position, "sticking" to the top of the browser viewport.
     //
-    // 'last' - last or only sidebar box is sticky
     // 'column' - entire column, incl. multiple boxes from top, is sticky
     // 'static' - sidebar not sticky at all, stays at top of page
     //
     // Note: This doesn't affect the content of any sidebar section, only
     // the whole section's containing box (or the sidebar column as a whole).
     stickyMode: {
-      validate: v => v.is('last', 'column', 'static'),
+      validate: v => v.is('column', 'static'),
       default: 'static',
     },
 
-    // Collapsing sidebars disappear when the viewport is sufficiently
-    // thin. (This is the default.) Override as false to make the sidebar
-    // stay visible in thinner viewports, where the page layout will be
-    // reflowed so the sidebar is as wide as the screen and appears below
-    // nav, above the main content.
-    collapse: {
-      type: 'boolean',
-      default: true,
-    },
-
     // Wide sidebars generally take up more horizontal space in the normal
     // page layout, and should be used if the content of the sidebar has
     // a greater than typical focus compared to main content.
@@ -60,9 +36,19 @@ export default {
       type: 'boolean',
       default: false,
     },
+
+    // Provide to include all the HTML for the sidebar in place as usual,
+    // but start it out totally invisible. This is mainly so client-side
+    // JavaScript can show the sidebar if it needs to (and has a target
+    // to slot its own content into). If there are no boxes and this
+    // option *isn't* provided, then the sidebar will just be blank.
+    initiallyHidden: {
+      type: 'boolean',
+      default: false,
+    },
   },
 
-  generate(relations, slots, {html}) {
+  generate(slots, {html}) {
     const attributes =
       html.attributes({class: [
         'sidebar-column',
@@ -71,33 +57,34 @@ export default {
 
     attributes.add(slots.attributes);
 
-    if (slots.class) {
-      attributes.add('class', slots.class);
-    }
-
     if (slots.wide) {
       attributes.add('class', 'wide');
     }
 
-    if (!slots.collapse) {
-      attributes.add('class', 'no-hide');
-    }
-
     if (slots.stickyMode !== 'static') {
       attributes.add('class', `sticky-${slots.stickyMode}`);
     }
 
-    const boxes =
-      (!html.isBlank(slots.boxes)
-        ? slots.boxes
-     : !html.isBlank(slots.content)
-        ? relations.box.slot('content', slots.content)
-        : html.blank());
+    const {content: boxes} = html.smooth(slots.boxes);
+
+    const allBoxesCollapsible =
+      boxes.every(box =>
+        html.resolve(box)
+          .attributes
+          .has('class', 'collapsible'));
+
+    if (allBoxesCollapsible) {
+      attributes.add('class', 'all-boxes-collapsible');
+    }
+
+    if (slots.initiallyHidden) {
+      attributes.add('class', 'initially-hidden');
+    }
 
-    if (html.isBlank(boxes)) {
+    if (html.isBlank(slots.boxes) && !slots.initiallyHidden) {
       return html.blank();
     } else {
-      return html.tag('div', attributes, boxes);
+      return html.tag('div', attributes, slots.boxes);
     }
   },
 };
diff --git a/src/content/dependencies/generatePageSidebarBox.js b/src/content/dependencies/generatePageSidebarBox.js
index 51835452..e11efc3f 100644
--- a/src/content/dependencies/generatePageSidebarBox.js
+++ b/src/content/dependencies/generatePageSidebarBox.js
@@ -11,10 +11,18 @@ export default {
       type: 'attributes',
       mutable: false,
     },
+
+    collapsible: {
+      type: 'boolean',
+      default: true,
+    },
   },
 
   generate: (slots, {html}) =>
     html.tag('div', {class: 'sidebar'},
+      slots.collapsible &&
+        {class: 'collapsible'},
+
       slots.attributes,
       slots.content),
 };
diff --git a/src/content/dependencies/generateSearchSidebarBox.js b/src/content/dependencies/generateSearchSidebarBox.js
new file mode 100644
index 00000000..6607c789
--- /dev/null
+++ b/src/content/dependencies/generateSearchSidebarBox.js
@@ -0,0 +1,57 @@
+export default {
+  contentDependencies: ['generatePageSidebarBox'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation) => ({
+    sidebarBox:
+      relation('generatePageSidebarBox'),
+  }),
+
+  generate: (relations, {html, language}) =>
+    relations.sidebarBox.slots({
+      attributes: {class: 'wiki-search-sidebar-box'},
+      collapsible: false,
+
+      content: [
+        html.tag('input', {class: 'wiki-search-input'},
+          {
+            placeholder:
+              language.$('misc.search.placeholder').toString(),
+          },
+          {type: 'search'}),
+
+        html.tag('template', {class: 'wiki-search-preparing-string'},
+          language.$('misc.search.preparing')),
+
+        html.tag('template', {class: 'wiki-search-loading-data-string'},
+          language.$('misc.search.loadingData')),
+
+        html.tag('template', {class: 'wiki-search-searching-string'},
+          language.$('misc.search.searching')),
+
+        html.tag('template', {class: 'wiki-search-failed-string'},
+          language.$('misc.search.failed')),
+
+        html.tag('template', {class: 'wiki-search-no-results-string'},
+          language.$('misc.search.noResults')),
+
+        html.tag('template', {class: 'wiki-search-current-result-string'},
+          language.$('misc.search.currentResult')),
+
+        html.tag('template', {class: 'wiki-search-end-search-string'},
+          language.$('misc.search.endSearch')),
+
+        html.tag('template', {class: 'wiki-search-album-result-kind-string'},
+          language.$('misc.search.resultKind.album')),
+
+        html.tag('template', {class: 'wiki-search-artist-result-kind-string'},
+          language.$('misc.search.resultKind.artist')),
+
+        html.tag('template', {class: 'wiki-search-group-result-kind-string'},
+          language.$('misc.search.resultKind.group')),
+
+        html.tag('template', {class: 'wiki-search-tag-result-kind-string'},
+          language.$('misc.search.resultKind.artTag')),
+      ],
+    }),
+};
diff --git a/src/content/dependencies/generateStickyHeadingContainer.js b/src/content/dependencies/generateStickyHeadingContainer.js
index 9becfb26..7f271715 100644
--- a/src/content/dependencies/generateStickyHeadingContainer.js
+++ b/src/content/dependencies/generateStickyHeadingContainer.js
@@ -22,10 +22,17 @@ export default {
         html.tag('div', {class: 'content-sticky-heading-row'}, [
           html.tag('h1', slots.title),
 
-          !html.isBlank(slots.cover) &&
-            html.tag('div', {class: 'content-sticky-heading-cover-container'},
-              html.tag('div', {class: 'content-sticky-heading-cover'},
-                slots.cover.slot('mode', 'thumbnail'))),
+          html.tag('div', {class: 'content-sticky-heading-cover-container'},
+            {[html.onlyIfContent]: true},
+
+            html.tag('div', {class: 'content-sticky-heading-cover'},
+              {[html.onlyIfContent]: true},
+
+              // TODO: We shouldn't need to do an isBlank check here,
+              // but a live blank value doesn't have a slot functions, so.
+              (html.isBlank(slots.cover)
+                ? html.blank()
+                : slots.cover.slot('mode', 'thumbnail')))),
         ]),
 
         html.tag('div', {class: 'content-sticky-subheading-row'},
diff --git a/src/content/dependencies/generateTrackChronologyLinks.js b/src/content/dependencies/generateTrackChronologyLinks.js
new file mode 100644
index 00000000..5f6b0771
--- /dev/null
+++ b/src/content/dependencies/generateTrackChronologyLinks.js
@@ -0,0 +1,166 @@
+import {sortAlbumsTracksChronologically} from '#sort';
+import {accumulateSum, stitchArrays} from '#sugar';
+
+import getChronologyRelations from '../util/getChronologyRelations.js';
+
+export default {
+  contentDependencies: [
+    'generateChronologyLinks',
+    'generateChronologyLinksScopeSwitcher',
+    'linkAlbum',
+    'linkArtist',
+    'linkTrack',
+  ],
+
+  relations(relation, track) {
+    function getScopedRelations(album) {
+      const albumFilter =
+        (album
+          ? track => track.album === album
+          : () => true);
+
+      return {
+        chronologyLinks:
+          relation('generateChronologyLinks'),
+
+        artistChronologyContributions:
+          getChronologyRelations(track, {
+            contributions: [
+              ...track.artistContribs ?? [],
+              ...track.contributorContribs ?? [],
+            ],
+
+            linkArtist: artist => relation('linkArtist', artist),
+            linkThing: track => relation('linkTrack', track),
+
+            getThings(artist) {
+              const getDate = thing => thing.date;
+
+              const things =
+                ([
+                  ...artist.tracksAsArtist,
+                  ...artist.tracksAsContributor,
+                ]).filter(getDate)
+                  .filter(albumFilter);
+
+              return sortAlbumsTracksChronologically(things, {getDate});
+            },
+          }),
+
+        coverArtistChronologyContributions:
+          getChronologyRelations(track, {
+            contributions: track.coverArtistContribs ?? [],
+
+            linkArtist: artist => relation('linkArtist', artist),
+
+            linkThing: trackOrAlbum =>
+              (trackOrAlbum.album
+                ? relation('linkTrack', trackOrAlbum)
+                : relation('linkAlbum', trackOrAlbum)),
+
+            getThings(artist) {
+              const getDate = thing => thing.coverArtDate ?? thing.date;
+
+              const things =
+                ([
+                  ...artist.albumsAsCoverArtist,
+                  ...artist.tracksAsCoverArtist,
+                ]).filter(getDate)
+                  .filter(albumFilter);
+
+              return sortAlbumsTracksChronologically(things, {getDate});
+            },
+          }),
+      };
+    }
+
+    const relations = {};
+
+    relations.scopeSwitcher =
+      relation('generateChronologyLinksScopeSwitcher');
+
+    relations.wiki =
+      getScopedRelations(null);
+
+    relations.album =
+      getScopedRelations(track.album);
+
+    for (const setKey of [
+      'artistChronologyContributions',
+      'coverArtistChronologyContributions',
+    ]) {
+      const wikiSet = relations.wiki[setKey];
+      const albumSet = relations.album[setKey];
+
+      const wikiArtistDirectories =
+        wikiSet
+          .map(({artistDirectory}) => artistDirectory);
+
+      albumSet.sort((a, b) =>
+        (a.only === b.only && a.index === b.index
+          ? (wikiArtistDirectories.indexOf(a.artistDirectory)
+           - wikiArtistDirectories.indexOf(b.artistDirectory))
+          : 0));
+    }
+
+    return relations;
+  },
+
+  generate(relations) {
+    function slotScopedRelations({content, artworkHeadingString}) {
+      return content.chronologyLinks.slots({
+        showOnly: true,
+        allowCollapsing: false,
+
+        chronologyInfoSets: [
+          {
+            headingString: 'misc.chronology.heading.track',
+            contributions: content.artistChronologyContributions,
+          },
+          {
+            headingString: `misc.chronology.heading.${artworkHeadingString}`,
+            contributions: content.coverArtistChronologyContributions,
+          },
+        ],
+      });
+    }
+
+    const scopes = [
+      'wiki',
+      'album',
+    ];
+
+    const contents = [
+      relations.wiki,
+      relations.album,
+    ];
+
+    const artworkHeadingStrings = [
+      'coverArt',
+      'trackArt',
+    ];
+
+    const totalContributionCount =
+      Math.max(...
+        contents.map(content =>
+          accumulateSum([
+            content.artistChronologyContributions,
+            content.coverArtistChronologyContributions,
+          ], contributions => contributions.length)));
+
+    relations.scopeSwitcher.setSlots({
+      scopes,
+
+      open:
+        totalContributionCount <= 5,
+
+      contents:
+        stitchArrays({
+          content: contents,
+          artworkHeadingString: artworkHeadingStrings,
+        }).map(slotScopedRelations),
+    });
+
+    return relations.scopeSwitcher;
+  },
+};
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 1b5fbbf8..ec93ab71 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -1,18 +1,14 @@
-import {sortAlbumsTracksChronologically, sortFlashesChronologically}
-  from '#sort';
+import {sortFlashesChronologically} from '#sort';
 import {empty, stitchArrays} from '#sugar';
 
-import getChronologyRelations from '../util/getChronologyRelations.js';
-
 export default {
   contentDependencies: [
     'generateAbsoluteDatetimestamp',
-    'generateAdditionalFilesShortcut',
     'generateAlbumAdditionalFilesList',
     'generateAlbumNavAccent',
+    'generateAlbumSecondaryNav',
     'generateAlbumSidebar',
     'generateAlbumStyleRules',
-    'generateChronologyLinks',
     'generateColorStyleAttribute',
     'generateCommentarySection',
     'generateContentHeading',
@@ -20,13 +16,13 @@ export default {
     'generatePageLayout',
     'generateRelativeDatetimestamp',
     'generateTrackAdditionalNamesBox',
+    'generateTrackChronologyLinks',
     'generateTrackCoverArtwork',
     'generateTrackList',
     'generateTrackListDividedByGroups',
     'generateTrackReleaseInfo',
     'generateTrackSocialEmbed',
     'linkAlbum',
-    'linkArtist',
     'linkFlash',
     'linkTrack',
     'transformContent',
@@ -55,51 +51,6 @@ export default {
     relations.socialEmbed =
       relation('generateTrackSocialEmbed', track);
 
-    relations.artistChronologyContributions =
-      getChronologyRelations(track, {
-        contributions: [
-          ...track.artistContribs ?? [],
-          ...track.contributorContribs ?? [],
-        ],
-
-        linkArtist: artist => relation('linkArtist', artist),
-        linkThing: track => relation('linkTrack', track),
-
-        getThings(artist) {
-          const getDate = thing => thing.date;
-
-          const things = [
-            ...artist.tracksAsArtist,
-            ...artist.tracksAsContributor,
-          ].filter(getDate);
-
-          return sortAlbumsTracksChronologically(things, {getDate});
-        },
-      });
-
-    relations.coverArtistChronologyContributions =
-      getChronologyRelations(track, {
-        contributions: track.coverArtistContribs ?? [],
-
-        linkArtist: artist => relation('linkArtist', artist),
-
-        linkThing: trackOrAlbum =>
-          (trackOrAlbum.album
-            ? relation('linkTrack', trackOrAlbum)
-            : relation('linkAlbum', trackOrAlbum)),
-
-        getThings(artist) {
-          const getDate = thing => thing.coverArtDate ?? thing.date;
-
-          const things = [
-            ...artist.albumsAsCoverArtist,
-            ...artist.tracksAsCoverArtist,
-          ].filter(getDate);
-
-          return sortAlbumsTracksChronologically(things, {getDate});
-        },
-      }),
-
     relations.albumLink =
       relation('linkAlbum', track.album);
 
@@ -109,8 +60,11 @@ export default {
     relations.albumNavAccent =
       relation('generateAlbumNavAccent', track.album, track);
 
-    relations.chronologyLinks =
-      relation('generateChronologyLinks');
+    relations.trackChronologyLinks =
+      relation('generateTrackChronologyLinks', track);
+
+    relations.secondaryNav =
+      relation('generateAlbumSecondaryNav', track.album);
 
     relations.sidebar =
       relation('generateAlbumSidebar', track.album, track);
@@ -134,15 +88,6 @@ export default {
     relations.releaseInfo =
       relation('generateTrackReleaseInfo', track);
 
-    // Section: Extra links
-
-    const extra = sections.extra = {};
-
-    if (!empty(track.additionalFiles)) {
-      extra.additionalFilesShortcut =
-        relation('generateAdditionalFilesShortcut', track.additionalFiles);
-    }
-
     // Section: Other releases
 
     if (!empty(track.otherReleases)) {
@@ -371,7 +316,11 @@ export default {
                 }),
 
               sec.additionalFiles &&
-                sec.extra.additionalFilesShortcut,
+                language.$('releaseInfo.additionalFiles.shortcut', {
+                  link: html.tag('a',
+                    {href: '#midi-project-files'},
+                    language.$('releaseInfo.additionalFiles.shortcut.link')),
+                }),
 
               sec.artistCommentary &&
                 language.$('releaseInfo.readCommentary', {
@@ -384,7 +333,7 @@ export default {
           sec.otherReleases && [
             sec.otherReleases.heading
               .slots({
-                id: 'also-released-as',
+                attributes: {id: 'also-released-as'},
                 title: language.$('releaseInfo.alsoReleasedAs'),
               }),
 
@@ -425,7 +374,7 @@ export default {
           sec.contributors && [
             sec.contributors.heading
               .slots({
-                id: 'contributors',
+                attributes: {id: 'contributors'},
                 title: language.$('releaseInfo.contributors'),
               }),
 
@@ -435,11 +384,15 @@ export default {
           sec.references && [
             sec.references.heading
               .slots({
-                id: 'references',
+                attributes: {id: 'references'},
+
                 title:
                   language.$('releaseInfo.tracksReferenced', {
                     track: html.tag('i', data.name),
                   }),
+
+                stickyTitle:
+                  language.$('releaseInfo.tracksReferenced.sticky'),
               }),
 
             sec.references.list,
@@ -448,11 +401,15 @@ export default {
           sec.samples && [
             sec.samples.heading
               .slots({
-                id: 'samples',
+                attributes: {id: 'samples'},
+
                 title:
                   language.$('releaseInfo.tracksSampled', {
                     track: html.tag('i', data.name),
                   }),
+
+                stickyTitle:
+                  language.$('releaseInfo.tracksSampled.sticky'),
               }),
 
             sec.samples.list,
@@ -461,37 +418,55 @@ export default {
           sec.referencedBy && [
             sec.referencedBy.heading
               .slots({
-                id: 'referenced-by',
+                attributes: {id: 'referenced-by'},
+
                 title:
                   language.$('releaseInfo.tracksThatReference', {
                     track: html.tag('i', data.name),
                   }),
+
+                stickyTitle:
+                  language.$('releaseInfo.tracksThatReference.sticky'),
               }),
 
-            sec.referencedBy.list,
+            sec.referencedBy.list
+              .slots({
+                headingString: 'releaseInfo.tracksThatReference',
+              }),
           ],
 
           sec.sampledBy && [
             sec.sampledBy.heading
               .slots({
-                id: 'referenced-by',
+                attributes: {id: 'referenced-by'},
+
                 title:
                   language.$('releaseInfo.tracksThatSample', {
                     track: html.tag('i', data.name),
                   }),
+
+                stickyTitle:
+                  language.$('releaseInfo.tracksThatSample.sticky'),
               }),
 
-            sec.sampledBy.list,
+            sec.sampledBy.list
+              .slots({
+                headingString: 'releaseInfo.tracksThatSample',
+              }),
           ],
 
           sec.flashesThatFeature && [
             sec.flashesThatFeature.heading
               .slots({
-                id: 'featured-in',
+                attributes: {id: 'featured-in'},
+
                 title:
                   language.$('releaseInfo.flashesThatFeature', {
                     track: html.tag('i', data.name),
                   }),
+
+                stickyTitle:
+                  language.$('releaseInfo.flashesThatFeature.sticky'),
               }),
 
             html.tag('ul', sec.flashesThatFeature.entries.map(({flashLink, trackLink}) =>
@@ -510,7 +485,7 @@ export default {
           sec.lyrics && [
             sec.lyrics.heading
               .slots({
-                id: 'lyrics',
+                attributes: {id: 'lyrics'},
                 title: language.$('releaseInfo.lyrics'),
               }),
 
@@ -522,7 +497,7 @@ export default {
           sec.sheetMusicFiles && [
             sec.sheetMusicFiles.heading
               .slots({
-                id: 'sheet-music-files',
+                attributes: {id: 'sheet-music-files'},
                 title: language.$('releaseInfo.sheetMusicFiles.heading'),
               }),
 
@@ -532,7 +507,7 @@ export default {
           sec.midiProjectFiles && [
             sec.midiProjectFiles.heading
               .slots({
-                id: 'midi-project-files',
+                attributes: {id: 'midi-project-files'},
                 title: language.$('releaseInfo.midiProjectFiles.heading'),
               }),
 
@@ -542,12 +517,8 @@ export default {
           sec.additionalFiles && [
             sec.additionalFiles.heading
               .slots({
-                id: 'additional-files',
-                title:
-                  language.$('releaseInfo.additionalFiles.heading', {
-                    additionalFiles:
-                      language.countAdditionalFiles(data.numAdditionalFiles, {unit: true}),
-                  }),
+                attributes: {id: 'additional-files'},
+                title: language.$('releaseInfo.additionalFiles.heading'),
               }),
 
             sec.additionalFiles.list,
@@ -582,18 +553,11 @@ export default {
           }),
 
         navContent:
-          relations.chronologyLinks.slots({
-            chronologyInfoSets: [
-              {
-                headingString: 'misc.chronology.heading.track',
-                contributions: relations.artistChronologyContributions,
-              },
-              {
-                headingString: 'misc.chronology.heading.coverArt',
-                contributions: relations.coverArtistChronologyContributions,
-              },
-            ],
-          }),
+          relations.trackChronologyLinks,
+
+        secondaryNav:
+          relations.secondaryNav
+            .slot('mode', 'track'),
 
         leftSidebar: relations.sidebar,
 
diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js
index e070ac35..327865f0 100644
--- a/src/content/dependencies/generateTrackListDividedByGroups.js
+++ b/src/content/dependencies/generateTrackListDividedByGroups.js
@@ -1,53 +1,132 @@
-import {empty} from '#sugar';
-
-import groupTracksByGroup from '../util/groupTracksByGroup.js';
+import {empty, filterMultipleArrays, stitchArrays} from '#sugar';
 
 export default {
-  contentDependencies: ['generateTrackList', 'linkGroup'],
+  contentDependencies: [
+    'generateContentHeading',
+    'generateTrackList',
+    'linkGroup',
+  ],
+
   extraDependencies: ['html', 'language'],
 
-  relations(relation, tracks, groups) {
-    if (empty(tracks)) {
-      return {};
+  query(tracks, dividingGroups) {
+    const groupings = new Map();
+    const ungroupedTracks = [];
+
+    // Entry order matters! Add blank lists for each group
+    // in the order that those groups are provided.
+    for (const group of dividingGroups) {
+      groupings.set(group, []);
     }
 
-    if (empty(groups)) {
-      return {
-        flatList:
-          relation('generateTrackList', tracks),
-      };
+    for (const track of tracks) {
+      const firstMatchingGroup =
+        dividingGroups.find(group => group.albums.includes(track.album));
+
+      if (firstMatchingGroup) {
+        groupings.get(firstMatchingGroup).push(track);
+      } else {
+        ungroupedTracks.push(track);
+      }
     }
 
-    const lists = groupTracksByGroup(tracks, groups);
+    const groups = Array.from(groupings.keys());
+    const groupedTracks = Array.from(groupings.values());
 
-    return {
-      groupedLists:
-        Array.from(lists.entries()).map(([groupOrOther, tracks]) => ({
-          ...(groupOrOther === 'other'
-                ? {other: true}
-                : {groupLink: relation('linkGroup', groupOrOther)}),
+    // Drop the empty lists, so just the groups which
+    // at least a single track matched are left.
+    filterMultipleArrays(
+      groups,
+      groupedTracks,
+      (_group, tracks) => !empty(tracks));
 
-          list:
-            relation('generateTrackList', tracks),
-        })),
-    };
+    return {groups, groupedTracks, ungroupedTracks};
   },
 
-  generate(relations, {html, language}) {
-    if (relations.flatList) {
-      return relations.flatList;
-    }
+  relations: (relation, query, tracks, groups) => ({
+    flatList:
+      (empty(groups)
+        ? relation('generateTrackList', tracks)
+        : null),
+
+    contentHeading:
+      relation('generateContentHeading'),
+
+    groupLinks:
+      query.groups
+        .map(group => relation('linkGroup', group)),
+
+    groupedTrackLists:
+      query.groupedTracks
+        .map(tracks => relation('generateTrackList', tracks)),
+
+    ungroupedTrackList:
+      (empty(query.ungroupedTracks)
+        ? null
+        : relation('generateTrackList', query.ungroupedTracks)),
+  }),
 
-    return html.tag('dl',
-      relations.groupedLists.map(({other, groupLink, list}) => [
-        html.tag('dt',
-          (other
-            ? language.$('trackList.group.fromOther')
-            : language.$('trackList.group', {
-                group: groupLink
-              }))),
-
-        html.tag('dd', list),
-      ]));
+  data: (query) => ({
+    groupNames:
+      query.groups
+        .map(group => group.name),
+  }),
+
+  slots: {
+    headingString: {
+      type: 'string',
+    },
   },
+
+  generate: (data, relations, slots, {html, language}) =>
+    relations.flatList ??
+    html.tag('dl', [
+      stitchArrays({
+        groupName: data.groupNames,
+        groupLink: relations.groupLinks,
+        trackList: relations.groupedTrackLists,
+      }).map(({
+          groupName,
+          groupLink,
+          trackList,
+        }) => [
+          (slots.headingString
+            ? relations.contentHeading.clone().slots({
+                tag: 'dt',
+
+                title:
+                  language.$('trackList.fromGroup', {
+                    group: groupLink
+                  }),
+
+                stickyTitle:
+                  language.$(slots.headingString, 'sticky', 'fromGroup', {
+                    group: groupName,
+                  }),
+              })
+            : html.tag('dt',
+                language.$('trackList.fromGroup', {
+                  group: groupLink
+                }))),
+
+          html.tag('dd', trackList),
+        ]),
+
+      relations.ungroupedTrackList && [
+        (slots.headingString
+          ? relations.contentHeading.clone().slots({
+              tag: 'dt',
+
+              title:
+                language.$('trackList.fromOther'),
+
+              stickyTitle:
+                language.$(slots.headingString, 'sticky', 'fromOther'),
+            })
+          : html.tag('dt',
+              language.$('trackList.fromOther'))),
+
+        html.tag('dd', relations.ungroupedTrackList),
+      ],
+    ]),
 };
diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js
index 3bdeaa4f..88a4cdc7 100644
--- a/src/content/dependencies/generateTrackReleaseInfo.js
+++ b/src/content/dependencies/generateTrackReleaseInfo.js
@@ -59,20 +59,20 @@ export default {
           relations.coverArtistContributionsLine
             ?.slots({stringKey: 'releaseInfo.coverArtBy'}),
 
-          data.date &&
-            language.$('releaseInfo.released', {
-              date: language.formatDate(data.date),
-            }),
-
-          data.coverArtDate &&
-            language.$('releaseInfo.artReleased', {
-              date: language.formatDate(data.coverArtDate),
-            }),
-
-          data.duration &&
-            language.$('releaseInfo.duration', {
-              duration: language.formatDuration(data.duration),
-            }),
+          language.$('releaseInfo.released', {
+            [language.onlyIfOptions]: ['date'],
+            date: language.formatDate(data.date),
+          }),
+
+          language.$('releaseInfo.artReleased', {
+            [language.onlyIfOptions]: ['date'],
+            date: language.formatDate(data.coverArtDate),
+          }),
+
+          language.$('releaseInfo.duration', {
+            [language.onlyIfOptions]: ['duration'],
+            duration: language.formatDuration(data.duration),
+          }),
         ]),
 
       html.tag('p',
diff --git a/src/content/dependencies/generateWikiHomeAlbumsRow.js b/src/content/dependencies/generateWikiHomeAlbumsRow.js
index a19f104c..16c22bb3 100644
--- a/src/content/dependencies/generateWikiHomeAlbumsRow.js
+++ b/src/content/dependencies/generateWikiHomeAlbumsRow.js
@@ -113,10 +113,10 @@ export default {
           image.slots({
             path,
             missingSourceContent:
-              name &&
-                language.$('misc.albumGrid.noCoverArt', {
-                  album: name,
-                }),
+              language.$('misc.albumGrid.noCoverArt', {
+                [language.onlyIfOptions]: ['album'],
+                album: name,
+              }),
             }));
 
     commonSlots.actionLinks =
diff --git a/src/content/dependencies/generateWikiHomeNewsBox.js b/src/content/dependencies/generateWikiHomeNewsBox.js
index e054edda..bd0e4797 100644
--- a/src/content/dependencies/generateWikiHomeNewsBox.js
+++ b/src/content/dependencies/generateWikiHomeNewsBox.js
@@ -46,6 +46,8 @@ export default {
 
     return relations.box.slots({
       attributes: {class: 'latest-news-sidebar-box'},
+      collapsible: false,
+
       content: [
         html.tag('h1', language.$('homepage.news.title')),
 
diff --git a/src/content/dependencies/generateWikiHomePage.js b/src/content/dependencies/generateWikiHomePage.js
index 35461d03..ee14a587 100644
--- a/src/content/dependencies/generateWikiHomePage.js
+++ b/src/content/dependencies/generateWikiHomePage.js
@@ -79,13 +79,14 @@ export default {
 
       leftSidebar:
         relations.sidebar.slots({
-          collapse: false,
           wide: true,
 
           boxes: [
             relations.customSidebarContent &&
               relations.customSidebarBox.slots({
                 attributes: {class: 'custom-content-sidebar-box'},
+                collapsible: false,
+
                 content:
                   relations.customSidebarContent
                     .slot('mode', 'multiline'),
diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js
index 6b24f386..b1f02819 100644
--- a/src/content/dependencies/image.js
+++ b/src/content/dependencies/image.js
@@ -1,9 +1,8 @@
-import {logInfo, logWarn} from '#cli';
+import {logWarn} from '#cli';
 import {empty} from '#sugar';
 
 export default {
   extraDependencies: [
-    'cachebust',
     'checkIfImagePathHasCachedThumbnails',
     'getDimensionsOfImagePath',
     'getSizeOfImagePath',
@@ -82,7 +81,6 @@ export default {
   },
 
   generate(data, relations, slots, {
-    cachebust,
     checkIfImagePathHasCachedThumbnails,
     getDimensionsOfImagePath,
     getSizeOfImagePath,
@@ -119,10 +117,6 @@ export default {
     const isMissingImageFile =
       missingImagePaths.includes(mediaSrc);
 
-    if (isMissingImageFile) {
-      logInfo`No image file for ${mediaSrc} - build again for list of missing images.`;
-    }
-
     const willLink =
       !isMissingImageFile &&
       (typeof slots.link === 'string' || slots.link);
@@ -137,15 +131,8 @@ export default {
       !isMissingImageFile &&
       !empty(contentWarnings);
 
-    const hasBothDimensions =
-      !!(slots.dimensions &&
-         slots.dimensions[0] !== null &&
-         slots.dimensions[1] !== null);
-
     const willSquare =
-      (hasBothDimensions
-        ? slots.dimensions[0] === slots.dimensions[1]
-        : slots.square);
+      slots.square;
 
     const imgAttributes = html.attributes([
       {class: 'image'},
@@ -156,7 +143,7 @@ export default {
         {width: slots.dimensions[0]},
 
       slots.dimensions?.[1] &&
-        {width: slots.dimensions[1]},
+        {height: slots.dimensions[1]},
     ]);
 
     const isPlaceholder =
@@ -176,7 +163,7 @@ export default {
     if (willReveal) {
       reveal = [
         html.tag('img', {class: 'reveal-symbol'},
-          {src: to('shared.staticFile', 'warning.svg', cachebust)}),
+          {src: to('staticMisc.path', 'warning.svg')}),
 
         html.tag('br'),
 
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
index 41ce1146..1a51c387 100644
--- a/src/content/dependencies/linkContribution.js
+++ b/src/content/dependencies/linkContribution.js
@@ -14,7 +14,7 @@ export default {
     const relations = {};
 
     relations.artistLink =
-      relation('linkArtist', contribution.who);
+      relation('linkArtist', contribution.artist);
 
     relations.textWithTooltip =
       relation('generateTextWithTooltip');
@@ -22,9 +22,9 @@ export default {
     relations.tooltip =
       relation('generateTooltip');
 
-    if (!empty(contribution.who.urls)) {
+    if (!empty(contribution.artist.urls)) {
       relations.artistIcons =
-        contribution.who.urls
+        contribution.artist.urls
           .map(url => relation('linkExternalAsIcon', url));
     }
 
@@ -33,8 +33,8 @@ export default {
 
   data(contribution) {
     return {
-      what: contribution.what,
-      urls: contribution.who.urls,
+      contribution: contribution.annotation,
+      urls: contribution.artist.urls,
     };
   },
 
@@ -50,7 +50,7 @@ export default {
   },
 
   generate(data, relations, slots, {html, language}) {
-    const hasContribution = !!(slots.showContribution && data.what);
+    const hasContribution = !!(slots.showContribution && data.contribution);
     const hasExternalIcons = !!(slots.showIcons && relations.artistIcons);
 
     const parts = ['misc.artistLink'];
@@ -111,7 +111,7 @@ export default {
 
     if (hasContribution) {
       parts.push('withContribution');
-      options.contrib = data.what;
+      options.contrib = data.contribution;
     }
 
     if (hasExternalIcons && slots.iconMode === 'inline') {
diff --git a/src/content/dependencies/linkExternalAsIcon.js b/src/content/dependencies/linkExternalAsIcon.js
index 6f37529e..e2ce4b3c 100644
--- a/src/content/dependencies/linkExternalAsIcon.js
+++ b/src/content/dependencies/linkExternalAsIcon.js
@@ -37,7 +37,7 @@ export default {
             html.tag('title', platformText),
 
           html.tag('use', {
-            href: to('shared.staticIcon', iconId),
+            href: to('staticMisc.icon', iconId),
           }),
         ]),
 
diff --git a/src/content/dependencies/listArtistsByDuration.js b/src/content/dependencies/listArtistsByDuration.js
index f677d82c..66fab8be 100644
--- a/src/content/dependencies/listArtistsByDuration.js
+++ b/src/content/dependencies/listArtistsByDuration.js
@@ -1,5 +1,5 @@
 import {sortAlphabetically, sortByCount} from '#sort';
-import {filterByCount, stitchArrays} from '#sugar';
+import {filterByCount, stitchArrays, unique} from '#sugar';
 import {getTotalDuration} from '#wiki-data';
 
 export default {
@@ -17,10 +17,12 @@ export default {
 
     const durations =
       artists.map(artist =>
-        getTotalDuration([
-          ...(artist.tracksAsArtist ?? []),
-          ...(artist.tracksAsContributor ?? []),
-        ], {originalReleasesOnly: true}));
+        getTotalDuration(
+          unique([
+            ...(artist.tracksAsArtist ?? []),
+            ...(artist.tracksAsContributor ?? []),
+          ]),
+          {originalReleasesOnly: true}));
 
     filterByCount(artists, durations);
     sortByCount(artists, durations, {greatestFirst: true});
diff --git a/src/content/dependencies/listArtistsByGroup.js b/src/content/dependencies/listArtistsByGroup.js
index 30884d24..f221fe8c 100644
--- a/src/content/dependencies/listArtistsByGroup.js
+++ b/src/content/dependencies/listArtistsByGroup.js
@@ -1,6 +1,11 @@
 import {sortAlphabetically} from '#sort';
-import {empty, filterMultipleArrays, stitchArrays, unique} from '#sugar';
-import {getArtistNumContributions} from '#wiki-data';
+import {
+  empty,
+  filterByCount,
+  filterMultipleArrays,
+  stitchArrays,
+  transposeArrays,
+} from '#sugar';
 
 export default {
   contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'],
@@ -15,29 +20,52 @@ export default {
       sortAlphabetically(
         sprawl.artistData.filter(artist => !artist.isAlias));
 
-    const groups =
+    const interestingGroups =
       sprawl.wikiInfo.divideTrackListsByGroups;
 
-    if (empty(groups)) {
-      return {spec, artists};
+    if (empty(interestingGroups)) {
+      return {spec};
     }
 
-    const artistGroups =
+    // We don't actually care about *which* things belong to each group, only
+    // how many belong to each group. So we'll just compute a list of all the
+    // (interesting) groups that each of each artists' things belongs to.
+    const artistThingGroups =
       artists.map(artist =>
-        unique(
-          unique([
-            ...artist.albumsAsAny,
-            ...artist.tracksAsAny.map(track => track.album),
-          ]).flatMap(album => album.groups)))
-
-    const artistsByGroup =
-      groups.map(group =>
-        artists.filter((artist, index) => artistGroups[index].includes(group)));
-
-    filterMultipleArrays(groups, artistsByGroup,
-      (group, artists) => !empty(artists));
-
-    return {spec, groups, artistsByGroup};
+        ([...artist.albumsAsAny.map(album => album.groups),
+          ...artist.tracksAsAny.map(track => track.album.groups)])
+            .map(groups => groups
+              .filter(group => interestingGroups.includes(group))));
+
+    const [artistsByGroup, countsByGroup] =
+      transposeArrays(interestingGroups.map(group => {
+        const counts =
+          artistThingGroups
+            .map(thingGroups => thingGroups
+              .filter(thingGroups => thingGroups.includes(group))
+              .length);
+
+        const filteredArtists = artists.slice();
+
+        filterByCount(filteredArtists, counts);
+
+        return [filteredArtists, counts];
+      }));
+
+    const groups = interestingGroups;
+
+    filterMultipleArrays(
+      groups,
+      artistsByGroup,
+      countsByGroup,
+      (_group, artists, _counts) => !empty(artists));
+
+    return {
+      spec,
+      groups,
+      artistsByGroup,
+      countsByGroup,
+    };
   },
 
   relations(relation, query) {
@@ -46,12 +74,6 @@ export default {
     relations.page =
       relation('generateListingPage', query.spec);
 
-    if (query.artists) {
-      relations.artistLinks =
-        query.artists
-          .map(artist => relation('linkArtist', artist));
-    }
-
     if (query.artistsByGroup) {
       relations.groupLinks =
         query.groups
@@ -69,65 +91,43 @@ export default {
   data(query) {
     const data = {};
 
-    if (query.artists) {
-      data.counts =
-        query.artists
-          .map(artist => getArtistNumContributions(artist));
-    }
-
     if (query.artistsByGroup) {
       data.groupDirectories =
         query.groups
           .map(group => group.directory);
 
       data.countsByGroup =
-        query.artistsByGroup
-          .map(artists => artists
-            .map(artist => getArtistNumContributions(artist)));
+        query.countsByGroup;
     }
 
     return data;
   },
 
-  generate(data, relations, {language}) {
-    return (
-      (relations.artistLinksByGroup
-        ? relations.page.slots({
-            type: 'chunks',
-
-            showSkipToSection: true,
-            chunkIDs:
-              data.groupDirectories
-                .map(directory => `contributed-to-${directory}`),
-
-            chunkTitles:
-              relations.groupLinks.map(groupLink => ({
-                group: groupLink,
-              })),
-
-            chunkRows:
-              stitchArrays({
-                artistLinks: relations.artistLinksByGroup,
-                counts: data.countsByGroup,
-              }).map(({artistLinks, counts}) =>
-                  stitchArrays({
-                    link: artistLinks,
-                    count: counts,
-                  }).map(({link, count}) => ({
-                      artist: link,
-                      contributions: language.countContributions(count, {unit: true}),
-                    }))),
-          })
-        : relations.page.slots({
-            type: 'rows',
-            rows:
-              stitchArrays({
-                link: relations.artistLinks,
-                count: data.counts,
-              }).map(({link, count}) => ({
-                  artist: link,
-                  contributions: language.countContributions(count, {unit: true}),
-                })),
-          })));
-  },
+  generate: (data, relations, {language}) =>
+    relations.page.slots({
+      type: 'chunks',
+
+      showSkipToSection: true,
+      chunkIDs:
+        data.groupDirectories
+          .map(directory => `contributed-to-${directory}`),
+
+      chunkTitles:
+        relations.groupLinks.map(groupLink => ({
+          group: groupLink,
+        })),
+
+      chunkRows:
+        stitchArrays({
+          artistLinks: relations.artistLinksByGroup,
+          counts: data.countsByGroup,
+        }).map(({artistLinks, counts}) =>
+            stitchArrays({
+              link: artistLinks,
+              count: counts,
+            }).map(({link, count}) => ({
+                artist: link,
+                contributions: language.countContributions(count, {unit: true}),
+              }))),
+    }),
 };
diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js
index 0f709577..27a2faa3 100644
--- a/src/content/dependencies/listArtistsByLatestContribution.js
+++ b/src/content/dependencies/listArtistsByLatestContribution.js
@@ -83,7 +83,8 @@ export default {
       });
     };
 
-    const getArtists = (thing, key) => thing[key].map(({who}) => who);
+    const getArtists = (thing, key) =>
+      thing[key].map(({artist}) => artist);
 
     const albumsLatestFirst = sortAlbumsTracksChronologically(sprawl.albumData.slice());
     const tracksLatestFirst = sortAlbumsTracksChronologically(sprawl.trackData.slice());
diff --git a/src/content/dependencies/listTracksByDate.js b/src/content/dependencies/listTracksByDate.js
index 01ce4e2d..0a2bfd6c 100644
--- a/src/content/dependencies/listTracksByDate.js
+++ b/src/content/dependencies/listTracksByDate.js
@@ -15,7 +15,8 @@ export default {
 
       chunks:
         chunkByProperties(
-          sortAlbumsTracksChronologically(trackData.slice()),
+          sortAlbumsTracksChronologically(
+            trackData.filter(track => track.date)),
           ['album', 'date']),
     };
   },
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index 0904cde6..84fbe361 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -262,6 +262,10 @@ export default {
                   height && {height},
                   style && {style},
 
+                  align === 'center' &&
+                  !link &&
+                    {class: 'align-center'},
+
                   pixelate &&
                     {class: 'pixelate'});
 
@@ -271,6 +275,9 @@ export default {
                     {href: link},
                     {target: '_blank'},
 
+                    align === 'center' &&
+                      {class: 'align-center'},
+
                     {title:
                       language.$('misc.external.opensInNewTab', {
                         link:
@@ -530,9 +537,9 @@ export default {
           // Expand line breaks which don't follow a list, quote,
           // or <br> / "  ", and which don't precede or follow
           // indented text (by at least two spaces).
-          .replace(/(?<!^ *-.*|^>.*|^  .*\n*|  $|<br>$)\n+(?!  |\n)/gm, '\n\n') /* eslint-disable-line no-regex-spaces */
+          .replace(/(?<!^ *(?:-|\d\.).*|^>.*|^  .*\n*|  $|<br>$)\n+(?!  |\n)/gm, '\n\n') /* eslint-disable-line no-regex-spaces */
           // Expand line breaks which are at the end of a list.
-          .replace(/(?<=^ *-.*)\n+(?!^ *-)/gm, '\n\n')
+          .replace(/(?<=^ *(?:-|\d\.).*)\n+(?!^ *(?:-|\d\.))/gm, '\n\n')
           // Expand line breaks which are at the end of a quote.
           .replace(/(?<=^>.*)\n+(?!^>)/gm, '\n\n');
 
diff --git a/src/content/util/getChronologyRelations.js b/src/content/util/getChronologyRelations.js
index 67d6d5fa..c601a990 100644
--- a/src/content/util/getChronologyRelations.js
+++ b/src/content/util/getChronologyRelations.js
@@ -18,17 +18,17 @@ export default function getChronologyRelations(thing, {
 
   const artistsSoFar = new Set();
 
-  contributions = contributions.filter(({who}) => {
-    if (artistsSoFar.has(who)) {
+  contributions = contributions.filter(({artist}) => {
+    if (artistsSoFar.has(artist)) {
       return false;
     } else {
-      artistsSoFar.add(who);
+      artistsSoFar.add(artist);
       return true;
     }
   });
 
-  return contributions.map(({who}) => {
-    const things = Array.from(new Set(getThings(who)));
+  return contributions.map(({artist}) => {
+    const things = Array.from(new Set(getThings(artist)));
 
     // Don't show a line if this contribution isn't part of the artist's
     // chronology at all (usually because this thing isn't dated).
@@ -37,19 +37,21 @@ export default function getChronologyRelations(thing, {
       return;
     }
 
-    // Don't show a line if this contribution is the *only* item in the
-    // artist's chronology (since there's nothing to navigate there).
     const previous = things[index - 1];
     const next = things[index + 1];
-    if (!previous && !next) {
-      return;
-    }
 
     return {
       index: index + 1,
-      artistLink: linkArtist(who),
+      artistDirectory: artist.directory,
+      only: !(previous || next),
+
+      artistLink: linkArtist(artist),
       previousLink: previous ? linkThing(previous) : null,
       nextLink: next ? linkThing(next) : null,
     };
-  }).filter(Boolean);
+  }).filter(Boolean)
+    .sort((a, b) =>
+      (a.only === b.only ?  b.index - a.index
+     : a.only            ? +1
+                         : -1))
 }
diff --git a/src/content/util/groupTracksByGroup.js b/src/content/util/groupTracksByGroup.js
deleted file mode 100644
index 4e189007..00000000
--- a/src/content/util/groupTracksByGroup.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import {empty} from '#sugar';
-
-export default function groupTracksByGroup(tracks, groups) {
-  const lists = new Map(groups.map(group => [group, []]));
-  lists.set('other', []);
-
-  for (const track of tracks) {
-    const group = groups.find(group => group.albums.includes(track.album));
-    if (group) {
-      lists.get(group).push(track);
-    } else {
-      lists.get('other').push(track);
-    }
-  }
-
-  for (const [key, tracks] of lists.entries()) {
-    if (empty(tracks)) {
-      lists.delete(key);
-    }
-  }
-
-  return lists;
-}