« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/aggregate.js2
-rw-r--r--src/cli.js132
-rw-r--r--src/common-util/sort.js9
-rw-r--r--src/common-util/sugar.js107
-rw-r--r--src/content/dependencies/generateAlbumCommentaryPage.js5
-rw-r--r--src/content/dependencies/generateAlbumGalleryStatsLine.js84
-rw-r--r--src/content/dependencies/generateAlbumGalleryTrackGrid.js6
-rw-r--r--src/content/dependencies/generateAlbumInfoPage.js45
-rw-r--r--src/content/dependencies/generateAlbumNavAccent.js11
-rw-r--r--src/content/dependencies/generateAlbumReleaseInfo.js40
-rw-r--r--src/content/dependencies/generateAlbumSidebar.js79
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackListBox.js4
-rw-r--r--src/content/dependencies/generateAlbumSidebarTrackSection.js23
-rw-r--r--src/content/dependencies/generateAlbumTrackListItem.js2
-rw-r--r--src/content/dependencies/generateArtistCredit.js47
-rw-r--r--src/content/dependencies/generateArtistCreditWikiEditsPart.js1
-rw-r--r--src/content/dependencies/generateArtistGalleryPage.js6
-rw-r--r--src/content/dependencies/generateArtistGroupContributionsInfo.js137
-rw-r--r--src/content/dependencies/generateArtistInfoPage.js44
-rw-r--r--src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js10
-rw-r--r--src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js4
-rw-r--r--src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js4
-rw-r--r--src/content/dependencies/generateArtistInfoPageTracksChunkItem.js12
-rw-r--r--src/content/dependencies/generateArtistNavLinks.js13
-rw-r--r--src/content/dependencies/generateArtistRollingWindowPage.js428
-rw-r--r--src/content/dependencies/generateCommentaryContentHeading.js33
-rw-r--r--src/content/dependencies/generateContentContentHeading.js39
-rw-r--r--src/content/dependencies/generateContributionTooltip.js152
-rw-r--r--src/content/dependencies/generateContributionTooltipChronologySection.js56
-rw-r--r--src/content/dependencies/generateCoverArtwork.js30
-rw-r--r--src/content/dependencies/generateCoverArtworkOriginDetails.js18
-rw-r--r--src/content/dependencies/generateCoverGrid.js64
-rw-r--r--src/content/dependencies/generateFlashInfoPage.js19
-rw-r--r--src/content/dependencies/generateGroupGalleryPage.js5
-rw-r--r--src/content/dependencies/generateGroupGalleryPageAlbumGrid.js74
-rw-r--r--src/content/dependencies/generateGroupGalleryPageAlbumGridTab.js82
-rw-r--r--src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js22
-rw-r--r--src/content/dependencies/generateGroupGalleryPageStyleSelector.js62
-rw-r--r--src/content/dependencies/generateGroupInfoPageAlbumsListItem.js4
-rw-r--r--src/content/dependencies/generateIntrapageDotSwitcher.js31
-rw-r--r--src/content/dependencies/generateLyricsSection.js29
-rw-r--r--src/content/dependencies/generateReadCommentaryLine.js47
-rw-r--r--src/content/dependencies/generateReleaseInfoListenLine.js159
-rw-r--r--src/content/dependencies/generateTrackArtistCommentarySection.js60
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js113
-rw-r--r--src/content/dependencies/generateTrackList.js13
-rw-r--r--src/content/dependencies/generateTrackListDividedByGroups.js10
-rw-r--r--src/content/dependencies/generateTrackListItem.js4
-rw-r--r--src/content/dependencies/generateTrackReleaseInfo.js31
-rw-r--r--src/content/dependencies/linkAlbum.js10
-rw-r--r--src/content/dependencies/linkAnythingMan.js14
-rw-r--r--src/content/dependencies/linkArtistRollingWindow.js8
-rw-r--r--src/content/dependencies/linkArtwork.js11
-rw-r--r--src/content/dependencies/linkContribution.js2
-rw-r--r--src/content/dependencies/linkReferencedArtworks.js13
-rw-r--r--src/content/dependencies/linkReferencingArtworks.js13
-rw-r--r--src/content/dependencies/listAlbumsByDuration.js8
-rw-r--r--src/content/dependencies/listAlbumsByTracks.js31
-rw-r--r--src/content/dependencies/listArtistsByContributions.js52
-rw-r--r--src/content/dependencies/transformContent.js91
-rw-r--r--src/data/checks.js4
-rw-r--r--src/data/composite/things/album/index.js2
-rw-r--r--src/data/composite/things/album/withCoverArtDate.js (renamed from src/data/composite/wiki-data/withCoverArtDate.js)13
-rw-r--r--src/data/composite/things/artwork/index.js2
-rw-r--r--src/data/composite/things/artwork/withArtTags.js99
-rw-r--r--src/data/composite/things/artwork/withContentWarningArtTags.js27
-rw-r--r--src/data/composite/things/contribution/index.js2
-rw-r--r--src/data/composite/things/contribution/thingPropertyMatches.js45
-rw-r--r--src/data/composite/things/contribution/thingReferenceTypeMatches.js66
-rw-r--r--src/data/composite/wiki-data/exitWithoutArtwork.js45
-rw-r--r--src/data/composite/wiki-data/index.js3
-rw-r--r--src/data/composite/wiki-data/withHasArtwork.js (renamed from src/data/composite/things/album/withHasCoverArt.js)59
-rw-r--r--src/data/things/additional-file.js11
-rw-r--r--src/data/things/additional-name.js10
-rw-r--r--src/data/things/album.js522
-rw-r--r--src/data/things/art-tag.js37
-rw-r--r--src/data/things/artist.js117
-rw-r--r--src/data/things/artwork.js96
-rw-r--r--src/data/things/content.js48
-rw-r--r--src/data/things/contribution.js120
-rw-r--r--src/data/things/flash.js20
-rw-r--r--src/data/things/group.js67
-rw-r--r--src/data/things/homepage-layout.js42
-rw-r--r--src/data/things/language.js58
-rw-r--r--src/data/things/news-entry.js8
-rw-r--r--src/data/things/sorting-rule.js26
-rw-r--r--src/data/things/static-page.js10
-rw-r--r--src/data/things/track.js515
-rw-r--r--src/data/things/wiki-info.js10
-rw-r--r--src/data/yaml.js65
-rw-r--r--src/external-links.js27
-rw-r--r--src/find.js64
-rw-r--r--src/gen-thumbs.js121
-rw-r--r--src/html.js104
-rw-r--r--src/page/album.js25
-rw-r--r--src/page/artist.js10
-rw-r--r--src/replacer.js172
-rw-r--r--src/static/css/site.css385
-rw-r--r--src/static/js/client-util.js7
-rw-r--r--src/static/js/client/artist-rolling-window.js573
-rw-r--r--src/static/js/client/gallery-style-selector.js123
-rw-r--r--src/static/js/client/index.js6
-rw-r--r--src/static/js/client/reveal-all-grid-control.js72
-rw-r--r--src/strings-default.yaml100
-rwxr-xr-xsrc/upd8.js43
-rw-r--r--src/urls-default.yaml3
-rw-r--r--src/write/bind-utilities.js2
107 files changed, 5166 insertions, 1485 deletions
diff --git a/src/aggregate.js b/src/aggregate.js
index cb806e89..d5ea2d73 100644
--- a/src/aggregate.js
+++ b/src/aggregate.js
@@ -604,7 +604,7 @@ export function showAggregate(topError, {
       headerPart += ` ${colors.dim(tracePart)}`;
     }
 
-    const head1 = level % 2 === 0 ? '\u21aa' : colors.dim('\u21aa');
+    const head1 = '\u21aa';
     const bar1 = ' ';
 
     const causePart =
diff --git a/src/cli.js b/src/cli.js
index 24534522..ec72a625 100644
--- a/src/cli.js
+++ b/src/cli.js
@@ -376,77 +376,79 @@ decorateTime.displayTime = function () {
   }
 };
 
-export function progressPromiseAll(msgOrMsgFn, array) {
+const progressUpdateInterval = 1000 / 60;
+
+function progressShow(message, total) {
+  let start = Date.now(), last = 0, done = 0;
+
+  const progress = () => {
+    const messagePart =
+      (typeof message === 'function'
+        ? message()
+        : message);
+
+    const percent =
+      Math.round((done / total) * 1000) / 10 + '%';
+
+    const percentPart =
+      percent.padEnd('99.9%'.length, ' ');
+
+    return `${messagePart} [${percentPart}]`;
+  };
+
+  process.stdout.write(`\r` + progress());
+
+  return () => {
+    done++;
+
+    if (done === total) {
+      process.stdout.write(
+        `\r\x1b[2m` + progress() +
+        `\x1b[0;32m Done! ` +
+        `\x1b[0;2m(${formatDuration(Date.now() - start)}) ` +
+        `\x1b[0m\n`
+      );
+    } else if (Date.now() - last >= progressUpdateInterval) {
+      process.stdout.write('\r' + progress());
+      last = Date.now();
+    }
+  };
+}
+
+export function progressPromiseAll(message, array) {
   if (!array.length) {
     return Promise.resolve([]);
   }
 
-  const msgFn =
-    typeof msgOrMsgFn === 'function' ? msgOrMsgFn : () => msgOrMsgFn;
-
-  let done = 0,
-    total = array.length;
-  process.stdout.write(`\r${msgFn()} [0/${total}]`);
-  const start = Date.now();
-  return Promise.all(
-    array.map((promise) =>
-      Promise.resolve(promise).then((val) => {
-        done++;
-        // const pc = `${done}/${total}`;
-        const pc = (Math.round((done / total) * 1000) / 10 + '%').padEnd(
-          '99.9%'.length,
-          ' '
-        );
-        if (done === total) {
-          const time = Date.now() - start;
-          process.stdout.write(
-            `\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n`
-          );
-        } else {
-          process.stdout.write(`\r${msgFn()} [${pc}] `);
-        }
-        return val;
-      })
-    )
-  );
+  const show = progressShow(message, array.length);
+
+  const next = value => {
+    show();
+
+    return value;
+  };
+
+  const promises =
+    array.map(promise => Promise.resolve(promise).then(next));
+
+  return Promise.all(promises);
 }
 
-export function progressCallAll(msgOrMsgFn, array) {
+export function progressCallAll(message, array) {
   if (!array.length) {
     return [];
   }
 
-  const msgFn =
-    typeof msgOrMsgFn === 'function' ? msgOrMsgFn : () => msgOrMsgFn;
+  const show = progressShow(message, array.length);
 
-  const updateInterval = 1000 / 60;
-
-  let done = 0,
-    total = array.length;
-  process.stdout.write(`\r${msgFn()} [0/${total}]`);
-  const start = Date.now();
-  const vals = [];
-  let lastTime = 0;
+  const values = [];
 
   for (const fn of array) {
-    const val = fn();
-    done++;
-
-    if (done === total) {
-      const pc = '100%'.padEnd('99.9%'.length, ' ');
-      const time = Date.now() - start;
-      process.stdout.write(
-        `\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n`
-      );
-    } else if (Date.now() - lastTime >= updateInterval) {
-      const pc = (Math.round((done / total) * 1000) / 10 + '%').padEnd('99.9%'.length, ' ');
-      process.stdout.write(`\r${msgFn()} [${pc}] `);
-      lastTime = Date.now();
-    }
-    vals.push(val);
+    values.push(fn());
+    show();
   }
 
-  return vals;
+  return values;
 }
 
 export function fileIssue({
@@ -459,6 +461,24 @@ export function fileIssue({
   console.error(colors.red(`- https://github.com/hsmusic/hsmusic-wiki/issues/`));
 }
 
+// Quick'n dirty function to present a duration nicely for command-line use.
+export function formatDuration(timeDelta) {
+  const seconds = timeDelta / 1000;
+
+  if (seconds > 90) {
+    const modSeconds = Math.floor(seconds % 60);
+    const minutes = Math.floor(seconds - seconds % 60) / 60;
+    return `${minutes}m${modSeconds}s`;
+  }
+
+  if (seconds < 0.1) {
+    return 'instant';
+  }
+
+  const precision = (seconds > 1 ? 3 : 2);
+  return `${seconds.toPrecision(precision)}s`;
+}
+
 export async function logicalCWD() {
   if (process.env.PWD) {
     return process.env.PWD;
diff --git a/src/common-util/sort.js b/src/common-util/sort.js
index d93d94c1..bbe4e551 100644
--- a/src/common-util/sort.js
+++ b/src/common-util/sort.js
@@ -370,11 +370,12 @@ export function sortAlbumsTracksChronologically(data, {
   getDate,
 } = {}) {
   // Sort albums before tracks...
-  sortByConditions(data, [(t) => t.album === undefined]);
+  sortByConditions(data, [t => t.isAlbum]);
 
-  // Group tracks by album...
-  sortByDirectory(data, {
-    getDirectory: (t) => (t.album ? t.album.directory : t.directory),
+  // Put albums alphabetically, and group with them...
+  sortAlphabetically(data, {
+    getDirectory: t => t.isTrack ? t.album.directory : t.directory,
+    getName: t => t.isTrack ? t.album.name : t.name,
   });
 
   // Sort tracks by position in album...
diff --git a/src/common-util/sugar.js b/src/common-util/sugar.js
index 699f1091..e931ad59 100644
--- a/src/common-util/sugar.js
+++ b/src/common-util/sugar.js
@@ -116,6 +116,10 @@ export function findIndexOrEnd(array, fn) {
 // returns null (or values in the array are nullish), they'll just be skipped in
 // the sum.
 export function accumulateSum(array, fn = x => x) {
+  if (!Array.isArray(array)) {
+    return accumulateSum(Array.from(array, fn));
+  }
+
   return array.reduce(
     (accumulator, value, index, array) =>
       accumulator +
@@ -254,11 +258,20 @@ export function compareObjects(obj1, obj2, {
 
 // Stolen from jq! Which pro8a8ly stole the concept from other places. Nice.
 export const withEntries = (obj, fn) => {
-  const result = fn(Object.entries(obj));
-  if (result instanceof Promise) {
-    return result.then(entries => Object.fromEntries(entries));
+  if (obj instanceof Map) {
+    const result = fn(Array.from(obj.entries()));
+    if (result instanceof Promise) {
+      return result.then(entries => new map(entries));
+    } else {
+      return new Map(result);
+    }
   } else {
-    return Object.fromEntries(result);
+    const result = fn(Object.entries(obj));
+    if (result instanceof Promise) {
+      return result.then(entries => Object.fromEntries(entries));
+    } else {
+      return Object.fromEntries(result);
+    }
   }
 }
 
@@ -302,34 +315,74 @@ export function filterProperties(object, properties, {
   return filteredObject;
 }
 
-export function queue(array, max = 50) {
-  if (max === 0) {
-    return array.map((fn) => fn());
+export function queue(functionList, queueSize = 50) {
+  if (queueSize === 0) {
+    return functionList.map(fn => fn());
   }
 
-  const begin = [];
-  let current = 0;
-  const ret = array.map(
-    (fn) =>
-      new Promise((resolve, reject) => {
-        begin.push(() => {
-          current++;
-          Promise.resolve(fn()).then((value) => {
-            current--;
-            if (current < max && begin.length) {
-              begin.shift()();
-            }
-            resolve(value);
-          }, reject);
-        });
-      })
-  );
+  const promiseList = [];
+  const resolveList = [];
+  const rejectList = [];
 
-  for (let i = 0; i < max && begin.length; i++) {
-    begin.shift()();
+  for (let i = 0; i < functionList.length; i++) {
+    const promiseWithResolvers = Promise.withResolvers();
+    promiseList.push(promiseWithResolvers.promise);
+    resolveList.push(promiseWithResolvers.resolve);
+    rejectList.push(promiseWithResolvers.reject);
   }
 
-  return ret;
+  let cursor = 0;
+  let running = 0;
+
+  const next = async () => {
+    if (running >= queueSize) {
+      return;
+    }
+
+    if (cursor === functionList.length) {
+      return;
+    }
+
+    const thisFunction = functionList[cursor];
+    const thisResolve = resolveList[cursor];
+    const thisReject = rejectList[cursor];
+
+    delete functionList[cursor];
+    delete resolveList[cursor];
+    delete rejectList[cursor];
+
+    cursor++;
+    running++;
+
+    try {
+      thisResolve(await thisFunction());
+    } catch (error) {
+      thisReject(error);
+    } finally {
+      running--;
+
+      // If the cursor is at 1, this is the first promise that resolved,
+      // so we're now done the "kick start", and can start the remaining
+      // promises (up to queueSize).
+      if (cursor === 1) {
+        // Since only one promise is used for the "kick start", and that one
+        // has just resolved, we know there's none running at all right now,
+        // and can start as many as specified in the queueSize right away.
+        for (let i = 0; i < queueSize; i++) {
+          next();
+        }
+      } else {
+        next();
+      }
+    }
+  };
+
+  // Only start a single promise, as a "kick start", so that it resolves as
+  // early as possible (it will resolve before we use CPU to start the rest
+  // of the promises, up to queueSize).
+  next();
+
+  return promiseList;
 }
 
 export function delay(ms) {
diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js
index 03b145f8..3529c4dc 100644
--- a/src/content/dependencies/generateAlbumCommentaryPage.js
+++ b/src/content/dependencies/generateAlbumCommentaryPage.js
@@ -266,7 +266,10 @@ export default {
                       }),
                   })),
 
-              cover?.slots({mode: 'commentary'}),
+              cover?.slots({
+                mode: 'commentary',
+                color: true,
+              }),
 
               trackDate &&
               trackDate !== data.date &&
diff --git a/src/content/dependencies/generateAlbumGalleryStatsLine.js b/src/content/dependencies/generateAlbumGalleryStatsLine.js
index 75bffb36..09d9a30b 100644
--- a/src/content/dependencies/generateAlbumGalleryStatsLine.js
+++ b/src/content/dependencies/generateAlbumGalleryStatsLine.js
@@ -3,36 +3,56 @@ import {getTotalDuration} from '#wiki-data';
 export default {
   extraDependencies: ['html', 'language'],
 
-  data(album) {
-    return {
-      name: album.name,
-      date: album.date,
-      duration: getTotalDuration(album.tracks),
-      numTracks: album.tracks.length,
-    };
-  },
-
-  generate(data, {html, language}) {
-    const parts = ['albumGalleryPage.statsLine'];
-    const options = {};
-
-    options.tracks =
-      html.tag('b',
-        language.countTracks(data.numTracks, {unit: true}));
-
-    options.duration =
-      html.tag('b',
-        language.formatDuration(data.duration, {unit: true}));
-
-    if (data.date) {
-      parts.push('withDate');
-      options.date =
-        html.tag('b',
-          language.formatDate(data.date));
-    }
-
-    return (
-      html.tag('p', {class: 'quick-info'},
-        language.formatString(...parts, options)));
-  },
+  data: (album) => ({
+    date:
+      album.date,
+
+    hideDuration:
+      album.hideDuration,
+
+    duration:
+      (album.hideDuration
+        ? null
+        : getTotalDuration(album.tracks)),
+
+    tracks:
+      (album.hideDuration
+        ? null
+        : album.tracks.length),
+  }),
+
+  generate: (data, {html, language}) =>
+    html.tag('p', {class: 'quick-info'},
+      {[html.onlyIfContent]: true},
+
+      language.encapsulate('albumGalleryPage.statsLine', workingCapsule => {
+        const workingOptions = {};
+
+        if (data.hideDuration && !data.date) {
+          return html.blank();
+        }
+
+        if (!data.hideDuration) {
+          workingOptions.tracks =
+            html.tag('b',
+              language.countTracks(data.tracks, {unit: true}));
+
+          workingOptions.duration =
+            html.tag('b',
+              language.formatDuration(data.duration, {unit: true}));
+        }
+
+        if (data.date) {
+          workingCapsule += '.withDate';
+          workingOptions.date =
+            html.tag('b',
+              language.formatDate(data.date));
+        }
+
+        if (data.hideDuration) {
+          workingCapsule += '.noDuration';
+        }
+
+        return language.$(workingCapsule, workingOptions);
+      })),
 };
diff --git a/src/content/dependencies/generateAlbumGalleryTrackGrid.js b/src/content/dependencies/generateAlbumGalleryTrackGrid.js
index fb5ed7ea..86c35b6f 100644
--- a/src/content/dependencies/generateAlbumGalleryTrackGrid.js
+++ b/src/content/dependencies/generateAlbumGalleryTrackGrid.js
@@ -77,6 +77,9 @@ export default {
           ? artwork.artistContribs
               .map(contrib => contrib.artist.name)
           : null)),
+
+    allWarnings:
+      query.artworks.flatMap(artwork => artwork?.contentWarnings),
   }),
 
   slots: {
@@ -117,6 +120,9 @@ export default {
                 artists:
                   language.formatUnitList(artists),
               })),
+
+          revealAllWarnings:
+            data.allWarnings,
         }),
       ]),
 };
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js
index 9a4ccfd2..1c5be6e6 100644
--- a/src/content/dependencies/generateAlbumInfoPage.js
+++ b/src/content/dependencies/generateAlbumInfoPage.js
@@ -13,9 +13,12 @@ export default {
     'generateAlbumSocialEmbed',
     'generateAlbumStyleTags',
     'generateAlbumTrackList',
+    'generateCommentaryContentHeading',
     'generateCommentaryEntry',
+    'generateContentContentHeading',
     'generateContentHeading',
     'generatePageLayout',
+    'generateReadCommentaryLine',
     'linkAlbumCommentary',
     'linkAlbumGallery',
   ],
@@ -55,6 +58,9 @@ export default {
     contentHeading:
       relation('generateContentHeading'),
 
+    contentContentHeading:
+      relation('generateContentContentHeading', album),
+
     releaseInfo:
       relation('generateAlbumReleaseInfo', album),
 
@@ -64,16 +70,22 @@ export default {
         : null),
 
     commentaryLink:
-      ([album, ...album.tracks].some(({commentary}) => !empty(commentary))
+      (album.tracks.some(track => !empty(track.commentary))
         ? relation('linkAlbumCommentary', album)
         : null),
 
+    readCommentaryLine:
+      relation('generateReadCommentaryLine', album),
+
     trackList:
       relation('generateAlbumTrackList', album),
 
     additionalFilesList:
       relation('generateAdditionalFilesList', album.additionalFiles),
 
+    commentaryContentHeading:
+      relation('generateCommentaryContentHeading', album),
+
     artistCommentaryEntries:
       album.commentary
         .map(entry => relation('generateCommentaryEntry', entry)),
@@ -156,6 +168,10 @@ export default {
 
                 : html.blank()),
 
+              !relations.commentaryLink &&
+              !html.isBlank(relations.artistCommentaryEntries) &&
+                relations.readCommentaryLine,
+
               !html.isBlank(relations.creditSourceEntries) &&
                 language.encapsulate(capsule, 'readCreditingSources', capsule =>
                   language.$(capsule, {
@@ -170,14 +186,16 @@ export default {
 
           html.tag('p',
             {[html.onlyIfContent]: true},
-            {[html.joinChildren]: html.tag('br')},
 
-            language.encapsulate('releaseInfo', capsule => [
-              language.$(capsule, 'addedToWiki', {
-                [language.onlyIfOptions]: ['date'],
-                date: language.formatDate(data.dateAddedToWiki),
-              }),
-            ])),
+            language.$('releaseInfo.addedToWiki', {
+              [language.onlyIfOptions]: ['date'],
+              date: language.formatDate(data.dateAddedToWiki),
+            })),
+
+          (!html.isBlank(relations.artistCommentaryEntries) ||
+           !html.isBlank(relations.creditSourceEntries))
+          &&
+            html.tag('hr', {class: 'main-separator'}),
 
           language.encapsulate('releaseInfo.additionalFiles', capsule =>
             html.tags([
@@ -191,20 +209,15 @@ export default {
             ])),
 
           html.tags([
-            relations.contentHeading.clone()
-              .slots({
-                attributes: {id: 'artist-commentary'},
-                title: language.$('misc.artistCommentary'),
-              }),
-
+            relations.commentaryContentHeading,
             relations.artistCommentaryEntries,
           ]),
 
           html.tags([
-            relations.contentHeading.clone()
+            relations.contentContentHeading.clone()
               .slots({
                 attributes: {id: 'crediting-sources'},
-                title: language.$('misc.creditingSources'),
+                string: 'misc.creditingSources',
               }),
 
             relations.creditSourceEntries,
diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js
index 432c5f3d..00aec94a 100644
--- a/src/content/dependencies/generateAlbumNavAccent.js
+++ b/src/content/dependencies/generateAlbumNavAccent.js
@@ -64,9 +64,8 @@ export default {
     hasMultipleTracks:
       album.tracks.length > 1,
 
-    commentaryPageIsStub:
-      [album, ...album.tracks]
-        .every(({commentary}) => empty(commentary)),
+    hasSubstantialCommentaryPage:
+      album.tracks.some(track => !empty(track.commentary)),
 
     galleryIsStub:
       album.tracks.every(t => !t.hasUniqueCoverArt),
@@ -97,14 +96,16 @@ export default {
         relations.nextLink.slot('link', relations.nextTrackLink);
 
     const galleryLink =
-      (!data.galleryIsStub || slots.currentExtra === 'gallery') &&
+      (!data.galleryIsStub ||
+       slots.currentExtra === 'gallery') &&
         relations.albumGalleryLink.slots({
           attributes: {class: slots.currentExtra === 'gallery' && 'current'},
           content: language.$(albumNavCapsule, 'gallery'),
         });
 
     const commentaryLink =
-      (!data.commentaryPageIsStub || slots.currentExtra === 'commentary') &&
+      (data.hasSubstantialCommentaryPage ||
+       slots.currentExtra === 'commentary') &&
         relations.albumCommentaryLink.slots({
           attributes: {class: slots.currentExtra === 'commentary' && 'current'},
           content: language.$(albumNavCapsule, 'commentary'),
diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js
index 0abb412c..a156dfec 100644
--- a/src/content/dependencies/generateAlbumReleaseInfo.js
+++ b/src/content/dependencies/generateAlbumReleaseInfo.js
@@ -3,7 +3,7 @@ import {accumulateSum, empty} from '#sugar';
 export default {
   contentDependencies: [
     'generateReleaseInfoContributionsLine',
-    'linkExternal',
+    'generateReleaseInfoListenLine',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -14,15 +14,8 @@ export default {
     relations.artistContributionsLine =
       relation('generateReleaseInfoContributionsLine', album.artistContribs);
 
-    relations.wallpaperArtistContributionsLine =
-      relation('generateReleaseInfoContributionsLine', album.wallpaperArtistContribs);
-
-    relations.bannerArtistContributionsLine =
-      relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs);
-
-    relations.externalLinks =
-      album.urls.map(url =>
-        relation('linkExternal', url));
+    relations.listenLine =
+      relation('generateReleaseInfoListenLine', album);
 
     return relations;
   },
@@ -43,7 +36,7 @@ export default {
         .map(track => track.duration)
         .filter(value => value > 0);
 
-    if (empty(durationTerms)) {
+    if (empty(durationTerms) || album.hideDuration) {
       data.duration = null;
       data.durationApproximate = null;
     } else {
@@ -87,21 +80,16 @@ export default {
         html.tag('p',
           {[html.onlyIfContent]: true},
 
-          language.$(capsule, 'listenOn', {
-            [language.onlyIfOptions]: ['links'],
-
-            links:
-              language.formatDisjunctionList(
-                relations.externalLinks
-                  .map(link =>
-                    link.slot('context', [
-                      'album',
-                      (data.numTracks === 0
-                        ? 'albumNoTracks'
-                     : data.numTracks === 1
-                        ? 'albumOneTrack'
-                        : 'albumMultipleTracks'),
-                    ]))),
+          relations.listenLine.slots({
+            context: [
+              'album',
+
+              (data.numTracks === 0
+                ? 'albumNoTracks'
+             : data.numTracks === 1
+                ? 'albumOneTrack'
+                : 'albumMultipleTracks'),
+            ],
           })),
       ])),
 };
diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js
index 7cf689cc..29d434cd 100644
--- a/src/content/dependencies/generateAlbumSidebar.js
+++ b/src/content/dependencies/generateAlbumSidebar.js
@@ -108,39 +108,65 @@ export default {
         : null),
   }),
 
-  data: (_query, _sprawl, _album, track) => ({
+  data: (_query, _sprawl, album, track) => ({
     isAlbumPage: !track,
     isTrackPage: !!track,
+
+    albumStyle: album.style,
   }),
 
   generate(data, relations, {html}) {
+    const presentGroupsLikeAlbum =
+      data.isAlbumPage ||
+      data.albumStyle === 'single';
+
     for (const box of [
       ...relations.groupBoxes,
       ...relations.seriesBoxes.flat(),
       ...relations.disconnectedSeriesBoxes,
     ]) {
-      box.setSlot('mode',
-        data.isAlbumPage ? 'album' : 'track');
+      box.setSlot('mode', presentGroupsLikeAlbum ? 'album' : 'track');
     }
 
+    const groupBoxes =
+      (presentGroupsLikeAlbum
+        ? [
+            relations.disconnectedSeriesBoxes,
+
+            stitchArrays({
+              groupBox: relations.groupBoxes,
+              seriesBoxes: relations.seriesBoxes,
+            }).map(({groupBox, seriesBoxes}) => [
+                groupBox,
+                seriesBoxes.map(seriesBox => [
+                  html.tag('div',
+                    {class: 'sidebar-box-joiner'},
+                    {class: 'collapsible'}),
+                  seriesBox,
+                ]),
+              ]),
+          ]
+        : [
+            relations.conjoinedBox.slots({
+              attributes: {class: 'conjoined-group-sidebar-box'},
+              boxes:
+                ([relations.disconnectedSeriesBoxes,
+                  stitchArrays({
+                    groupBox: relations.groupBoxes,
+                    seriesBoxes: relations.seriesBoxes,
+                  }).flatMap(({groupBox, seriesBoxes}) => [
+                      groupBox,
+                      ...seriesBoxes,
+                    ]),
+                ]).flat()
+                  .map(box => box.content), /* TODO: Kludge. */
+            })
+          ]);
+
     return relations.sidebar.slots({
       boxes: [
-        data.isAlbumPage && [
-          relations.disconnectedSeriesBoxes,
-
-          stitchArrays({
-            groupBox: relations.groupBoxes,
-            seriesBoxes: relations.seriesBoxes,
-          }).map(({groupBox, seriesBoxes}) => [
-              groupBox,
-              seriesBoxes.map(seriesBox => [
-                html.tag('div',
-                  {class: 'sidebar-box-joiner'},
-                  {class: 'collapsible'}),
-                seriesBox,
-              ]),
-            ]),
-        ],
+        data.isAlbumPage &&
+          groupBoxes,
 
         data.isTrackPage &&
           relations.earlierTrackReleaseBoxes,
@@ -151,20 +177,7 @@ export default {
           relations.laterTrackReleaseBoxes,
 
         data.isTrackPage &&
-          relations.conjoinedBox.slots({
-            attributes: {class: 'conjoined-group-sidebar-box'},
-            boxes:
-              ([relations.disconnectedSeriesBoxes,
-                stitchArrays({
-                  groupBox: relations.groupBoxes,
-                  seriesBoxes: relations.seriesBoxes,
-                }).flatMap(({groupBox, seriesBoxes}) => [
-                    groupBox,
-                    ...seriesBoxes,
-                  ]),
-              ]).flat()
-                .map(box => box.content), /* TODO: Kludge. */
-          }),
+          groupBoxes,
       ],
     });
   },
diff --git a/src/content/dependencies/generateAlbumSidebarTrackListBox.js b/src/content/dependencies/generateAlbumSidebarTrackListBox.js
index 3a244e3a..218e07ab 100644
--- a/src/content/dependencies/generateAlbumSidebarTrackListBox.js
+++ b/src/content/dependencies/generateAlbumSidebarTrackListBox.js
@@ -24,7 +24,9 @@ export default {
       attributes: {class: 'track-list-sidebar-box'},
 
       content: [
-        html.tag('h1', relations.albumLink),
+        html.tag('h1', {[html.onlyIfSiblings]: true},
+          relations.albumLink),
+
         relations.trackSections,
       ],
     })
diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js
index dae5fa03..a158d2d4 100644
--- a/src/content/dependencies/generateAlbumSidebarTrackSection.js
+++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js
@@ -22,10 +22,12 @@ export default {
       !empty(trackSection.tracks);
 
     data.isTrackPage = !!track;
+    data.albumStyle = album.style;
 
     data.name = trackSection.name;
     data.color = trackSection.color;
     data.isDefaultTrackSection = trackSection.isDefaultTrackSection;
+    data.hasSiblingSections = album.trackSections.length > 1;
 
     data.firstTrackNumber =
       (data.hasTrackNumbers
@@ -115,6 +117,21 @@ export default {
                   : trackLink),
             })));
 
+    const list =
+      (data.hasTrackNumbers
+        ? html.tag('ol',
+            {start: data.firstTrackNumber},
+            trackListItems)
+        : html.tag('ul', trackListItems));
+
+    if (data.albumStyle === 'single' && !data.hasSiblingSections) {
+      if (trackListItems.length <= 1) {
+        return html.blank();
+      } else {
+        return list;
+      }
+    }
+
     return html.tag('details',
       data.includesCurrentTrack &&
         {class: 'current'},
@@ -157,11 +174,7 @@ export default {
                 return language.$(workingCapsule, workingOptions);
               })))),
 
-        (data.hasTrackNumbers
-          ? html.tag('ol',
-              {start: data.firstTrackNumber},
-              trackListItems)
-          : html.tag('ul', trackListItems)),
+        list,
       ]);
   },
 };
diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js
index 44297c15..201ca53a 100644
--- a/src/content/dependencies/generateAlbumTrackListItem.js
+++ b/src/content/dependencies/generateAlbumTrackListItem.js
@@ -20,7 +20,7 @@ export default {
     item:
       relation('generateTrackListItem',
         track,
-        track.album.artistContribs),
+        track.album.trackArtistContribs),
   }),
 
   data: (query, track, album) => ({
diff --git a/src/content/dependencies/generateArtistCredit.js b/src/content/dependencies/generateArtistCredit.js
index bab32f7d..2d611ca6 100644
--- a/src/content/dependencies/generateArtistCredit.js
+++ b/src/content/dependencies/generateArtistCredit.js
@@ -162,33 +162,42 @@ export default {
       (slots.showAnnotation && data.normalContributionAnnotationsDifferFromContext) ||
       (data.normalContributionArtistsDifferFromContext);
 
+    let content;
+
     if (empty(relations.featuringContributionLinks)) {
       if (effectivelyDiffers) {
-        return language.$(slots.normalStringKey, {
-          ...slots.additionalStringOptions,
-          artists: artistsList,
-        });
+        content =
+          language.$(slots.normalStringKey, {
+            ...slots.additionalStringOptions,
+            artists: artistsList,
+          });
       } else {
         return html.blank();
       }
-    }
-
-    if (effectivelyDiffers && slots.normalFeaturingStringKey) {
-      return language.$(slots.normalFeaturingStringKey, {
-        ...slots.additionalStringOptions,
-        artists: artistsList,
-        featuring: featuringList,
+    } else if (effectivelyDiffers && slots.normalFeaturingStringKey) {
+      content =
+        language.$(slots.normalFeaturingStringKey, {
+          ...slots.additionalStringOptions,
+          artists: artistsList,
+          featuring: featuringList,
       });
     } else if (slots.featuringStringKey) {
-      return language.$(slots.featuringStringKey, {
-        ...slots.additionalStringOptions,
-        artists: featuringList,
-      });
+      content =
+        language.$(slots.featuringStringKey, {
+          ...slots.additionalStringOptions,
+          artists: featuringList,
+        });
     } else {
-      return language.$(slots.normalStringKey, {
-        ...slots.additionalStringOptions,
-        artists: everyoneList,
-      });
+      content =
+        language.$(slots.normalStringKey, {
+          ...slots.additionalStringOptions,
+          artists: everyoneList,
+        });
     }
+
+    // TODO: This is obviously evil.
+    return (
+      html.metatag('chunkwrap', {split: /,| (?=and)/},
+        html.resolve(content)));
   },
 };
diff --git a/src/content/dependencies/generateArtistCreditWikiEditsPart.js b/src/content/dependencies/generateArtistCreditWikiEditsPart.js
index 70296e39..1b9930ee 100644
--- a/src/content/dependencies/generateArtistCreditWikiEditsPart.js
+++ b/src/content/dependencies/generateArtistCreditWikiEditsPart.js
@@ -48,6 +48,7 @@ export default {
                         showAnnotation: slots.showAnnotation,
                         trimAnnotation: true,
                         preventTooltip: true,
+                        preventWrapping: true,
                       }))),
                 }),
           }),
diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js
index 6a24275e..094edc0c 100644
--- a/src/content/dependencies/generateArtistGalleryPage.js
+++ b/src/content/dependencies/generateArtistGalleryPage.js
@@ -58,6 +58,10 @@ export default {
         .map(artwork => artwork.artistContribs
           .filter(contrib => contrib.artist !== artist)
           .map(contrib => contrib.artist.name)),
+
+    allWarnings:
+      query.artworks
+        .flatMap(artwork => artwork.contentWarnings),
   }),
 
   generate: (data, relations, {html, language}) =>
@@ -93,6 +97,8 @@ export default {
 
                     artists: language.formatUnitList(names),
                   })),
+
+              revealAllWarnings: data.allWarnings,
             }),
         ],
 
diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js
index 3e0cd1d2..e1fa7a0b 100644
--- a/src/content/dependencies/generateArtistGroupContributionsInfo.js
+++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js
@@ -1,83 +1,90 @@
-import {empty, filterProperties, stitchArrays, unique} from '#sugar';
+import {accumulateSum, empty, stitchArrays, withEntries} from '#sugar';
 
 export default {
   contentDependencies: ['linkGroup'],
   extraDependencies: ['html', 'language', 'wikiData'],
 
-  sprawl({groupCategoryData}) {
-    return {
-      groupOrder: groupCategoryData.flatMap(category => category.groups),
-    }
-  },
+  sprawl: ({groupCategoryData}) => ({
+    groupOrder:
+      groupCategoryData.flatMap(category => category.groups),
+  }),
 
-  query(sprawl, tracksAndAlbums) {
-    const filteredAlbums = tracksAndAlbums.filter(thing => !thing.album);
-    const filteredTracks = tracksAndAlbums.filter(thing => thing.album);
+  query(sprawl, contributions) {
+    const allGroupsUnordered =
+      new Set(contributions.flatMap(contrib => contrib.groups));
 
-    const allAlbums = unique([
-      ...filteredAlbums,
-      ...filteredTracks.map(track => track.album),
-    ]);
+    const allGroupsOrdered =
+      sprawl.groupOrder.filter(group => allGroupsUnordered.has(group));
 
-    const allGroupsUnordered = new Set(Array.from(allAlbums).flatMap(album => album.groups));
-    const allGroupsOrdered = sprawl.groupOrder.filter(group => allGroupsUnordered.has(group));
+    const groupToThingsCountedForContributions =
+      new Map(allGroupsOrdered.map(group => [group, new Set]));
 
-    const mapTemplate = allGroupsOrdered.map(group => [group, 0]);
-    const groupToCountMap = new Map(mapTemplate);
-    const groupToDurationMap = new Map(mapTemplate);
-    const groupToDurationCountMap = new Map(mapTemplate);
+    const groupToThingsCountedForDuration =
+      new Map(allGroupsOrdered.map(group => [group, new Set]));
 
-    for (const album of filteredAlbums) {
-      for (const group of album.groups) {
-        groupToCountMap.set(group, groupToCountMap.get(group) + 1);
-      }
-    }
+    for (const contrib of contributions) {
+      for (const group of contrib.groups) {
+        if (contrib.countInContributionTotals) {
+          groupToThingsCountedForContributions.get(group).add(contrib.thing);
+        }
 
-    for (const track of filteredTracks) {
-      for (const group of track.album.groups) {
-        groupToCountMap.set(group, groupToCountMap.get(group) + 1);
-        if (track.duration && track.mainReleaseTrack === null) {
-          groupToDurationMap.set(group, groupToDurationMap.get(group) + track.duration);
-          groupToDurationCountMap.set(group, groupToDurationCountMap.get(group) + 1);
+        if (contrib.countInDurationTotals) {
+          groupToThingsCountedForDuration.get(group).add(contrib.thing);
         }
       }
     }
 
+    const groupToTotalContributions =
+      withEntries(
+        groupToThingsCountedForContributions,
+        entries => entries.map(
+          ([group, things]) =>
+          ([group, things.size])));
+
+    const groupToTotalDuration =
+      withEntries(
+        groupToThingsCountedForDuration,
+        entries => entries.map(
+          ([group, things]) =>
+          ([group, accumulateSum(things, thing => thing.duration)])))
+
     const groupsSortedByCount =
       allGroupsOrdered
-        .slice()
-        .sort((a, b) => groupToCountMap.get(b) - groupToCountMap.get(a));
+        .filter(group => groupToTotalContributions.get(group) > 0)
+        .sort((a, b) =>
+          (groupToTotalContributions.get(b)
+         - groupToTotalContributions.get(a)));
 
-    // The filter here ensures all displayed groups have at least some duration
-    // when sorting by duration.
     const groupsSortedByDuration =
       allGroupsOrdered
-        .filter(group => groupToDurationMap.get(group) > 0)
-        .sort((a, b) => groupToDurationMap.get(b) - groupToDurationMap.get(a));
+        .filter(group => groupToTotalDuration.get(group) > 0)
+        .sort((a, b) =>
+          (groupToTotalDuration.get(b)
+         - groupToTotalDuration.get(a)));
 
     const groupCountsSortedByCount =
       groupsSortedByCount
-        .map(group => groupToCountMap.get(group));
+        .map(group => groupToTotalContributions.get(group));
 
     const groupDurationsSortedByCount =
       groupsSortedByCount
-        .map(group => groupToDurationMap.get(group));
+        .map(group => groupToTotalDuration.get(group));
 
     const groupDurationsApproximateSortedByCount =
       groupsSortedByCount
-        .map(group => groupToDurationCountMap.get(group) > 1);
+        .map(group => groupToThingsCountedForDuration.get(group).size > 1);
 
     const groupCountsSortedByDuration =
       groupsSortedByDuration
-        .map(group => groupToCountMap.get(group));
+        .map(group => groupToTotalContributions.get(group));
 
     const groupDurationsSortedByDuration =
       groupsSortedByDuration
-        .map(group => groupToDurationMap.get(group));
+        .map(group => groupToTotalDuration.get(group));
 
     const groupDurationsApproximateSortedByDuration =
       groupsSortedByDuration
-        .map(group => groupToDurationCountMap.get(group) > 1);
+        .map(group => groupToThingsCountedForDuration.get(group).size > 1);
 
     return {
       groupsSortedByCount,
@@ -93,29 +100,35 @@ export default {
     };
   },
 
-  relations(relation, query) {
-    return {
-      groupLinksSortedByCount:
-        query.groupsSortedByCount
-          .map(group => relation('linkGroup', group)),
+  relations: (relation, query) => ({
+    groupLinksSortedByCount:
+      query.groupsSortedByCount
+        .map(group => relation('linkGroup', group)),
 
-      groupLinksSortedByDuration:
-        query.groupsSortedByDuration
-          .map(group => relation('linkGroup', group)),
-    };
-  },
+    groupLinksSortedByDuration:
+      query.groupsSortedByDuration
+        .map(group => relation('linkGroup', group)),
+  }),
 
-  data(query) {
-    return filterProperties(query, [
-      'groupCountsSortedByCount',
-      'groupDurationsSortedByCount',
-      'groupDurationsApproximateSortedByCount',
+  data: (query) => ({
+    groupCountsSortedByCount:
+      query.groupCountsSortedByCount,
 
-      'groupCountsSortedByDuration',
-      'groupDurationsSortedByDuration',
-      'groupDurationsApproximateSortedByDuration',
-    ]);
-  },
+    groupDurationsSortedByCount:
+      query.groupDurationsSortedByCount,
+
+    groupDurationsApproximateSortedByCount:
+      query.groupDurationsApproximateSortedByCount,
+
+    groupCountsSortedByDuration:
+      query.groupCountsSortedByDuration,
+
+    groupDurationsSortedByDuration:
+      query.groupDurationsSortedByDuration,
+
+    groupDurationsApproximateSortedByDuration:
+      query.groupDurationsApproximateSortedByDuration,
+  }),
 
   slots: {
     title: {
diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js
index 3a3cf8b7..1f738de4 100644
--- a/src/content/dependencies/generateArtistInfoPage.js
+++ b/src/content/dependencies/generateArtistInfoPage.js
@@ -20,29 +20,17 @@ export default {
   extraDependencies: ['html', 'language'],
 
   query: (artist) => ({
-    // Even if an artist has served as both "artist" (compositional) and
-    // "contributor" (instruments, production, etc) on the same track, that
-    // track only counts as one unique contribution in the list.
-    allTracks:
-      unique(
-        ([
-          artist.trackArtistContributions,
-          artist.trackContributorContributions,
-        ]).flat()
-          .map(({thing}) => thing)),
-
-    // Artworks are different, though. We intentionally duplicate album data
-    // objects when the artist has contributed some combination of cover art,
-    // wallpaper, and banner - these each count as a unique contribution.
-    allArtworkThings:
-      ([
-        artist.albumCoverArtistContributions,
-        artist.albumWallpaperArtistContributions,
-        artist.albumBannerArtistContributions,
-        artist.trackCoverArtistContributions,
-      ]).flat()
-        .filter(({annotation}) => !annotation?.startsWith('edits for wiki'))
-        .map(({thing}) => thing.thing),
+    trackContributions: [
+      ...artist.trackArtistContributions,
+      ...artist.trackContributorContributions,
+    ],
+
+    artworkContributions: [
+      ...artist.albumCoverArtistContributions,
+      ...artist.albumWallpaperArtistContributions,
+      ...artist.albumBannerArtistContributions,
+      ...artist.trackCoverArtistContributions,
+    ],
 
     // Banners and wallpapers don't show up in the artist gallery page, only
     // cover art.
@@ -93,7 +81,7 @@ export default {
       relation('generateArtistInfoPageTracksChunkedList', artist),
 
     tracksGroupInfo:
-      relation('generateArtistGroupContributionsInfo', query.allTracks),
+      relation('generateArtistGroupContributionsInfo', query.trackContributions),
 
     artworksChunkedList:
       relation('generateArtistInfoPageArtworksChunkedList', artist, false),
@@ -102,7 +90,7 @@ export default {
       relation('generateArtistInfoPageArtworksChunkedList', artist, true),
 
     artworksGroupInfo:
-      relation('generateArtistGroupContributionsInfo', query.allArtworkThings),
+      relation('generateArtistGroupContributionsInfo', query.artworkContributions),
 
     artistGalleryLink:
       (query.hasGallery
@@ -128,7 +116,11 @@ export default {
         .map(({annotation}) => annotation),
 
     totalTrackCount:
-      query.allTracks.length,
+      unique(
+        query.trackContributions
+          .filter(contrib => contrib.countInContributionTotals)
+          .map(contrib => contrib.thing))
+        .length,
 
     totalDuration:
       artist.totalDuration,
diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
index cb436b0f..98d9ce7a 100644
--- a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
+++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js
@@ -12,11 +12,15 @@ export default {
 
   query: (contrib) => ({
     kind:
-      (contrib.isBannerArtistContribution
+      (contrib.thingProperty === 'bannerArtistContribs' ||
+       (contrib.thing.isArtwork &&
+        contrib.thing.thingProperty === 'bannerArtwork')
         ? 'banner'
-     : contrib.isWallpaperArtistContribution
+     : contrib.thingProperty === 'wallpaperArtistContribs' ||
+       (contrib.thing.isArtwork &&
+        contrib.thing.thingProperty === 'wallpaperArtwork')
         ? 'wallpaper'
-     : contrib.isForAlbum
+     : contrib.thing.isAlbum
         ? 'album-cover'
         : 'track-cover'),
   }),
diff --git a/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js b/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js
index f86dead7..31a223f5 100644
--- a/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js
+++ b/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js
@@ -1,4 +1,4 @@
-import {sortChronologically} from '#sort';
+import {sortAlbumsTracksChronologically} from '#sort';
 import {stitchArrays} from '#sugar';
 
 export default {
@@ -12,7 +12,7 @@ export default {
 
   query: (track) => ({
     rereleases:
-      sortChronologically(track.allReleases).slice(1),
+      sortAlbumsTracksChronologically(track.allReleases).slice(1),
   }),
 
   relations: (relation, query, track, artist) => ({
diff --git a/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js
index 1d849919..853edcb7 100644
--- a/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js
+++ b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js
@@ -1,4 +1,4 @@
-import {sortChronologically} from '#sort';
+import {sortAlbumsTracksChronologically} from '#sort';
 
 export default {
   contentDependencies: [
@@ -11,7 +11,7 @@ export default {
 
   query: (track) => ({
     firstRelease:
-      sortChronologically(track.allReleases)[0],
+      sortAlbumsTracksChronologically(track.allReleases)[0],
   }),
 
   relations: (relation, query, track, artist) => ({
diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
index a42d6fee..877b2fe9 100644
--- a/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
+++ b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js
@@ -1,4 +1,4 @@
-import {sortChronologically} from '#sort';
+import {sortAlbumsTracksChronologically} from '#sort';
 import {empty} from '#sugar';
 
 export default {
@@ -22,11 +22,11 @@ export default {
 
     const creditedAsArtist =
       contribs
-        .some(contrib => contrib.isArtistContribution);
+        .some(contrib => contrib.thingProperty === 'artistContribs');
 
     const creditedAsContributor =
       contribs
-        .some(contrib => contrib.isContributorContribution);
+        .some(contrib => contrib.thingProperty === 'contributorContribs');
 
     const annotatedContribs =
       contribs
@@ -34,11 +34,11 @@ export default {
 
     const annotatedArtistContribs =
       annotatedContribs
-        .filter(contrib => contrib.isArtistContribution);
+        .filter(contrib => contrib.thingProperty === 'artistContribs');
 
     const annotatedContributorContribs =
       annotatedContribs
-        .filter(contrib => contrib.isContributorContribution);
+        .filter(contrib => contrib.thingProperty === 'contributorContribs');
 
     // Don't display annotations associated with crediting in the
     // Contributors field if the artist is also credited as an Artist
@@ -73,7 +73,7 @@ export default {
     // different - and it's the latter that determines whether the
     // track is a rerelease!
     const allReleasesChronologically =
-      sortChronologically(query.track.allReleases);
+      sortAlbumsTracksChronologically(query.track.allReleases);
 
     query.isFirstRelease =
       allReleasesChronologically[0] === query.track;
diff --git a/src/content/dependencies/generateArtistNavLinks.js b/src/content/dependencies/generateArtistNavLinks.js
index 1b4b6eca..1a520e84 100644
--- a/src/content/dependencies/generateArtistNavLinks.js
+++ b/src/content/dependencies/generateArtistNavLinks.js
@@ -5,6 +5,7 @@ export default {
     'generateInterpageDotSwitcher',
     'linkArtist',
     'linkArtistGallery',
+    'linkArtistRollingWindow',
   ],
 
   extraDependencies: ['html', 'language', 'wikiData'],
@@ -34,6 +35,9 @@ export default {
       (query.hasGallery
         ? relation('linkArtistGallery', artist)
         : null),
+
+    artistRollingWindowLink:
+      relation('linkArtistRollingWindow', artist),
   }),
 
   data: (_query, sprawl) => ({
@@ -45,7 +49,7 @@ export default {
     showExtraLinks: {type: 'boolean', default: false},
 
     currentExtra: {
-      validate: v => v.is('gallery'),
+      validate: v => v.is('gallery', 'rolling-window'),
     },
   },
 
@@ -79,6 +83,7 @@ export default {
             }),
 
             slots.showExtraLinks &&
+            slots.currentExtra !== 'rolling-window' &&
               relations.artistGalleryLink?.slots({
                 attributes: [
                   slots.currentExtra === 'gallery' &&
@@ -87,6 +92,12 @@ export default {
 
                 content: language.$('misc.nav.gallery'),
               }),
+
+            slots.currentExtra === 'rolling-window' &&
+              relations.artistRollingWindowLink.slots({
+                attributes: {class: 'current'},
+                content: language.$('misc.nav.rollingWindow'),
+              }),
           ],
         }),
     },
diff --git a/src/content/dependencies/generateArtistRollingWindowPage.js b/src/content/dependencies/generateArtistRollingWindowPage.js
new file mode 100644
index 00000000..33b1501e
--- /dev/null
+++ b/src/content/dependencies/generateArtistRollingWindowPage.js
@@ -0,0 +1,428 @@
+import {sortAlbumsTracksChronologically} from '#sort';
+import Thing from '#thing';
+
+import {
+  chunkByConditions,
+  filterMultipleArrays,
+  empty,
+  sortMultipleArrays,
+  stitchArrays,
+  unique,
+} from '#sugar';
+
+export default {
+  contentDependencies: [
+    'image',
+    'generateArtistNavLinks',
+    'generateCoverGrid',
+    'generatePageLayout',
+    'linkAnythingMan',
+  ],
+
+  extraDependencies: ['html', 'language', 'wikiData'],
+
+  sprawl: ({groupCategoryData}) => ({
+    groupCategoryData,
+  }),
+
+  query(sprawl, artist) {
+    const query = {};
+
+    const musicContributions =
+      artist.musicContributions
+        .filter(contrib => contrib.date);
+
+    const artworkContributions =
+      artist.artworkContributions
+        .filter(contrib =>
+          contrib.date &&
+          contrib.thingProperty !== 'wallpaperArtistContribs' &&
+          contrib.thingProperty !== 'bannerArtistContribs');
+
+    const musicThings =
+      musicContributions
+        .map(contrib => contrib.thing);
+
+    const artworkThings =
+      artworkContributions
+        .map(contrib => contrib.thing.thing);
+
+    const musicContributionDates =
+      musicContributions
+        .map(contrib => contrib.date);
+
+    const artworkContributionDates =
+      artworkContributions
+        .map(contrib => contrib.date);
+
+    const musicContributionKinds =
+      musicContributions
+        .map(() => 'music');
+
+    const artworkContributionKinds =
+      artworkContributions
+        .map(() => 'artwork');
+
+    const allThings = [
+      ...artworkThings,
+      ...musicThings,
+    ];
+
+    const allContributionDates = [
+      ...artworkContributionDates,
+      ...musicContributionDates,
+    ];
+
+    const allContributionKinds = [
+      ...artworkContributionKinds,
+      ...musicContributionKinds,
+    ];
+
+    const sortedThings =
+      sortAlbumsTracksChronologically(allThings.slice(), {latestFirst: true});
+
+    sortMultipleArrays(
+      allThings,
+      allContributionDates,
+      allContributionKinds,
+      (thing1, thing2) =>
+        sortedThings.indexOf(thing1) -
+        sortedThings.indexOf(thing2));
+
+    const sourceIndices =
+      Array.from({length: allThings.length}, (_, i) => i);
+
+    const sourceChunks =
+      chunkByConditions(sourceIndices, [
+        (index1, index2) =>
+          allThings[index1] !==
+          allThings[index2],
+      ]);
+
+    const indicesTo = array => index => array[index];
+
+    query.things =
+      sourceChunks
+        .map(chunks => allThings[chunks[0]]);
+
+    query.thingGroups =
+      query.things.map(thing =>
+        (thing.constructor[Thing.referenceType] === 'album'
+          ? thing.groups
+       : thing.constructor[Thing.referenceType] === 'track'
+          ? thing.album.groups
+          : null));
+
+    query.thingContributionDates =
+      sourceChunks
+        .map(indices => indices
+          .map(indicesTo(allContributionDates)));
+
+    query.thingContributionKinds =
+      sourceChunks
+        .map(indices => indices
+          .map(indicesTo(allContributionKinds)));
+
+    // Matches the "kind" dropdown.
+    const kinds = ['artwork', 'music', 'flash'];
+
+    const allKinds =
+      unique(query.thingContributionKinds.flat(2));
+
+    query.kinds =
+      kinds
+        .filter(kind => allKinds.includes(kind));
+
+    query.firstKind =
+      query.kinds.at(0);
+
+    query.thingArtworks =
+      stitchArrays({
+        thing: query.things,
+        kinds: query.thingContributionKinds,
+      }).map(({thing, kinds}) =>
+          (kinds.includes('artwork')
+            ? (thing.coverArtworks ?? thing.trackArtworks ?? [])
+                .find(artwork => artwork.artistContribs
+                  .some(contrib => contrib.artist === artist))
+            : (thing.coverArtworks ?? thing.trackArtworks)?.[0] ??
+              thing.album?.coverArtworks[0] ??
+              null));
+
+    const allGroups =
+      unique(query.thingGroups.flat());
+
+    query.groupCategories =
+      sprawl.groupCategoryData.slice();
+
+    query.groupCategoryGroups =
+      sprawl.groupCategoryData
+        .map(category => category.groups
+          .filter(group => allGroups.includes(group)));
+
+    filterMultipleArrays(
+      query.groupCategories,
+      query.groupCategoryGroups,
+      (_category, groups) => !empty(groups));
+
+    const groupsMatchingFirstKind =
+      unique(
+        stitchArrays({
+          thing: query.things,
+          groups: query.thingGroups,
+          kinds: query.thingContributionKinds,
+        }).filter(({kinds}) => kinds.includes(query.firstKind))
+          .flatMap(({groups}) => groups));
+
+    query.firstGroup =
+      sprawl.groupCategoryData
+        .flatMap(category => category.groups)
+        .find(group => groupsMatchingFirstKind.includes(group));
+
+    query.firstGroupCategory =
+      query.firstGroup.category;
+
+    return query;
+  },
+
+  relations: (relation, query, sprawl, artist) => ({
+    layout:
+      relation('generatePageLayout'),
+
+    artistNavLinks:
+      relation('generateArtistNavLinks', artist),
+
+    sourceGrid:
+      relation('generateCoverGrid'),
+
+    sourceGridImages:
+      query.thingArtworks
+        .map(artwork => relation('image', artwork)),
+
+    sourceGridLinks:
+      query.things
+        .map(thing => relation('linkAnythingMan', thing)),
+  }),
+
+  data: (query, sprawl, artist) => ({
+    name:
+      artist.name,
+
+    categoryGroupDirectories:
+      query.groupCategoryGroups
+        .map(groups => groups
+          .map(group => group.directory)),
+
+    categoryGroupNames:
+      query.groupCategoryGroups
+        .map(groups => groups
+          .map(group => group.name)),
+
+    firstGroupCategoryIndex:
+      query.groupCategories
+        .indexOf(query.firstGroupCategory),
+
+    firstGroupIndex:
+      stitchArrays({
+        category: query.groupCategories,
+        groups: query.groupCategoryGroups,
+      }).find(({category}) => category === query.firstGroupCategory)
+        .groups
+          .indexOf(query.firstGroup),
+
+    kinds:
+      query.kinds,
+
+    sourceGridNames:
+      query.things
+        .map(thing => thing.name),
+
+    sourceGridGroupDirectories:
+      query.thingGroups
+        .map(groups => groups
+          .map(group => group.directory)),
+
+    sourceGridGroupNames:
+      query.thingGroups
+        .map(groups => groups
+          .map(group => group.name)),
+
+    sourceGridContributionKinds:
+      query.thingContributionKinds,
+
+    sourceGridContributionDates:
+      query.thingContributionDates,
+  }),
+
+  generate: (data, relations, {html, language}) =>
+    relations.layout.slots({
+      title:
+        language.$('artistRollingWindowPage.title', {
+          artist: data.name,
+        }),
+
+      mainClasses: ['top-index'],
+      mainContent: [
+        html.tag('p', {id: 'timeframe-configuration'},
+          language.$('artistRollingWindowPage.windowConfigurationLine', {
+            timeBefore:
+              language.$('artistRollingWindowPage.timeframe.months', {
+                input:
+                  html.tag('input', {id: 'timeframe-months-before'},
+                    {type: 'number'},
+                    {value: 3, min: 0}),
+              }),
+
+            timeAfter:
+              language.$('artistRollingWindowPage.timeframe.months', {
+                input:
+                  html.tag('input', {id: 'timeframe-months-after'},
+                    {type: 'number'},
+                    {value: 3, min: 1}),
+              }),
+
+            peek:
+              language.$('artistRollingWindowPage.timeframe.months', {
+                input:
+                  html.tag('input', {id: 'timeframe-months-peek'},
+                    {type: 'number'},
+                    {value: 1, min: 0}),
+              }),
+          })),
+
+        html.tag('p', {id: 'contribution-configuration'},
+          language.$('artistRollingWindowPage.contributionConfigurationLine', {
+            kind:
+              html.tag('select', {id: 'contribution-kind'},
+                data.kinds.map(kind =>
+                  html.tag('option', {value: kind},
+                    language.$('artistRollingWindowPage.contributionKind', kind)))),
+
+            group:
+              html.tag('select', {id: 'contribution-group'}, [
+                html.tag('option', {value: '-'},
+                  language.$('artistRollingWindowPage.contributionGroup.all')),
+
+                stitchArrays({
+                  names: data.categoryGroupNames,
+                  directories: data.categoryGroupDirectories,
+                }).map(({names, directories}, categoryIndex) => [
+                    html.tag('hr'),
+
+                    stitchArrays({name: names, directory: directories})
+                      .map(({name, directory}, groupIndex) =>
+                        html.tag('option', {value: directory},
+                          categoryIndex === data.firstGroupCategoryIndex &&
+                          groupIndex === data.firstGroupIndex &&
+                            {selected: true},
+
+                          language.$('artistRollingWindowPage.contributionGroup.group', {
+                            group: name,
+                          }))),
+                  ]),
+              ]),
+          })),
+
+        html.tag('p', {id: 'timeframe-selection-info'}, [
+          html.tag('span', {id: 'timeframe-selection-some'},
+            {style: 'display: none'},
+
+            language.$('artistRollingWindowPage.timeframeSelectionLine', {
+              contributions:
+                html.tag('b', {id: 'timeframe-selection-contribution-count'}),
+
+              timeframes:
+                html.tag('b', {id: 'timeframe-selection-timeframe-count'}),
+
+              firstDate:
+                html.tag('b', {id: 'timeframe-selection-first-date'}),
+
+              lastDate:
+                html.tag('b', {id: 'timeframe-selection-last-date'}),
+            })),
+
+          html.tag('span', {id: 'timeframe-selection-none'},
+            {style: 'display: none'},
+            language.$('artistRollingWindowPage.timeframeSelectionLine.none')),
+        ]),
+
+        html.tag('p', {id: 'timeframe-selection-control'},
+          {style: 'display: none'},
+
+          language.$('artistRollingWindowPage.timeframeSelectionControl', {
+            timeframes:
+              html.tag('select', {id: 'timeframe-selection-menu'}),
+
+            previous:
+              html.tag('a', {id: 'timeframe-selection-previous'},
+                {href: '#'},
+                language.$('artistRollingWindowPage.timeframeSelectionControl.previous')),
+
+            next:
+              html.tag('a', {id: 'timeframe-selection-next'},
+                {href: '#'},
+                language.$('artistRollingWindowPage.timeframeSelectionControl.next')),
+          })),
+
+        html.tag('div', {id: 'timeframe-source-area'}, [
+          html.tag('p', {id: 'timeframe-empty'},
+            {style: 'display: none'},
+            language.$('artistRollingWindowPage.emptyTimeframeLine')),
+
+          relations.sourceGrid.slots({
+            attributes: {style: 'display: none'},
+
+            lazy: true,
+
+            links:
+              relations.sourceGridLinks.map(link =>
+                link.slot('attributes', {target: '_blank'})),
+
+            names:
+              data.sourceGridNames,
+
+            images:
+              relations.sourceGridImages,
+
+            info:
+              stitchArrays({
+                contributionKinds: data.sourceGridContributionKinds,
+                contributionDates: data.sourceGridContributionDates,
+                groupDirectories: data.sourceGridGroupDirectories,
+                groupNames: data.sourceGridGroupNames,
+              }).map(({
+                  contributionKinds,
+                  contributionDates,
+                  groupDirectories,
+                  groupNames,
+                }) => [
+                  stitchArrays({
+                    directory: groupDirectories,
+                    name: groupNames,
+                  }).map(({directory, name}) =>
+                    html.tag('data', {class: 'contribution-group'},
+                      {value: directory},
+                      name)),
+
+                  stitchArrays({
+                    kind: contributionKinds,
+                    date: contributionDates,
+                  }).map(({kind, date}) =>
+                      html.tag('time', {class: `${kind}-contribution-date`},
+                        {datetime: date.toUTCString()},
+                        language.formatDate(date))),
+                ]),
+          }),
+        ]),
+      ],
+
+      navLinkStyle: 'hierarchical',
+      navLinks:
+        relations.artistNavLinks
+          .slots({
+            showExtraLinks: true,
+            currentExtra: 'rolling-window',
+          })
+          .content,
+    }),
+}
diff --git a/src/content/dependencies/generateCommentaryContentHeading.js b/src/content/dependencies/generateCommentaryContentHeading.js
new file mode 100644
index 00000000..92405010
--- /dev/null
+++ b/src/content/dependencies/generateCommentaryContentHeading.js
@@ -0,0 +1,33 @@
+export default {
+  contentDependencies: ['generateContentContentHeading'],
+  extraDependencies: ['language'],
+
+  relations: (relation, thing) => ({
+    contentContentHeading:
+      relation('generateContentContentHeading', thing),
+  }),
+
+  data: (thing) => ({
+    hasWikiEditorCommentary:
+      thing.commentary
+        .some(entry => entry.isWikiEditorCommentary),
+
+    onlyWikiEditorCommentary:
+      thing.commentary
+        .every(entry => entry.isWikiEditorCommentary),
+  }),
+
+  generate: (data, relations, {language}) =>
+    relations.contentContentHeading.slots({
+      // It's #artist-commentary for legacy reasons... Sorry...
+      attributes: {id: 'artist-commentary'},
+
+      string:
+        language.encapsulate('misc.artistCommentary', capsule =>
+          (data.onlyWikiEditorCommentary
+            ? language.encapsulate(capsule, 'onlyWikiCommentary')
+         : data.hasWikiEditorCommentary
+            ? language.encapsulate(capsule, 'withWikiCommentary')
+            : capsule)),
+    }),
+};
diff --git a/src/content/dependencies/generateContentContentHeading.js b/src/content/dependencies/generateContentContentHeading.js
new file mode 100644
index 00000000..314ef197
--- /dev/null
+++ b/src/content/dependencies/generateContentContentHeading.js
@@ -0,0 +1,39 @@
+export default {
+  contentDependencies: ['generateContentHeading'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, _thing) => ({
+    contentHeading:
+      relation('generateContentHeading'),
+  }),
+
+  data: (thing) => ({
+    name:
+      thing.name,
+  }),
+
+  slots: {
+    attributes: {
+      type: 'attributes',
+      mutable: false,
+    },
+
+    string: {
+      type: 'string',
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    relations.contentHeading.slots({
+      attributes: slots.attributes,
+
+      title:
+        language.$(slots.string, {
+          thing:
+            html.tag('i', data.name),
+        }),
+
+      stickyTitle:
+        language.$(slots.string, 'sticky'),
+    }),
+};
diff --git a/src/content/dependencies/generateContributionTooltip.js b/src/content/dependencies/generateContributionTooltip.js
index 3a31014d..1eba7273 100644
--- a/src/content/dependencies/generateContributionTooltip.js
+++ b/src/content/dependencies/generateContributionTooltip.js
@@ -1,3 +1,36 @@
+function compareReleaseContributions(a, b) {
+  if (a === b) {
+    return true;
+  }
+
+  const {previous: aPrev, next: aNext} = getSiblings(a);
+  const {previous: bPrev, next: bNext} = getSiblings(b);
+
+  const effective = contrib =>
+    (contrib?.thing.isAlbum && contrib.thing.style === 'single'
+      ? contrib.thing.tracks[0]
+      : contrib?.thing);
+
+  return (
+    effective(aPrev) === effective(bPrev) &&
+    effective(aNext) === effective(bNext)
+  );
+}
+
+function getSiblings(contribution) {
+  let previous = contribution;
+  while (previous && previous.thing === contribution.thing) {
+    previous = previous.previousBySameArtist;
+  }
+
+  let next = contribution;
+  while (next && next.thing === contribution.thing) {
+    next = next.nextBySameArtist;
+  }
+
+  return {previous, next};
+}
+
 export default {
   contentDependencies: [
     'generateContributionTooltipChronologySection',
@@ -5,17 +38,50 @@ export default {
     'generateTooltip',
   ],
 
-  extraDependencies: ['html'],
+  extraDependencies: ['html', 'language'],
 
-  relations: (relation, contribution) => ({
+  query: (contribution) => ({
+    albumArtistContribution:
+      (contribution.thing.isTrack
+        ? contribution.thing.album.artistContribs
+            .find(artistContrib => artistContrib.artist === contribution.artist)
+        : null),
+  }),
+
+  relations: (relation, query, contribution) => ({
     tooltip:
       relation('generateTooltip'),
 
     externalLinkSection:
       relation('generateContributionTooltipExternalLinkSection', contribution),
 
-    chronologySection:
+    ownChronologySection:
       relation('generateContributionTooltipChronologySection', contribution),
+
+    artistReleaseChronologySection:
+      (query.albumArtistContribution
+        ? relation('generateContributionTooltipChronologySection',
+            query.albumArtistContribution)
+        : null),
+  }),
+
+  data: (query, contribution) => ({
+    artistName:
+      contribution.artist.name,
+
+    isAlbumArtistContribution:
+      contribution.thing.isAlbum &&
+      contribution.thingProperty === 'artistContribs',
+
+    isSingleTrackArtistContribution:
+      contribution.thing.isTrack &&
+      contribution.thingProperty === 'artistContribs' &&
+      contribution.thing.album.style === 'single',
+
+    artistReleaseChronologySectionDiffers:
+      (query.albumArtistContribution
+        ? !compareReleaseContributions(contribution, query.albumArtistContribution)
+        : null),
   }),
 
   slots: {
@@ -25,24 +91,64 @@ export default {
     chronologyKind: {type: 'string'},
   },
 
-  generate: (relations, slots, {html}) =>
-    relations.tooltip.slots({
-      attributes:
-        {class: 'contribution-tooltip'},
-
-      contentAttributes: {
-        [html.joinChildren]:
-          html.tag('span', {class: 'tooltip-divider'}),
-      },
-
-      content: [
-        slots.showExternalLinks &&
-          relations.externalLinkSection,
-
-        slots.showChronology &&
-          relations.chronologySection.slots({
-            kind: slots.chronologyKind,
-          }),
-      ],
-    }),
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('misc.artistLink', capsule =>
+      relations.tooltip.slots({
+        attributes:
+          {class: 'contribution-tooltip'},
+
+        contentAttributes: {
+          [html.joinChildren]:
+            html.tag('span', {class: 'tooltip-divider'}),
+        },
+
+        content: [
+          slots.showExternalLinks &&
+            relations.externalLinkSection,
+
+          slots.showChronology &&
+            language.encapsulate(capsule, 'chronology', capsule => {
+              const chronologySections = [];
+
+              if (data.isAlbumArtistContribution) {
+                relations.ownChronologySection.setSlots({
+                  kind: 'release',
+                  heading:
+                    language.$(capsule, 'heading.artistReleases', {
+                      artist: data.artistName,
+                    }),
+                });
+              } else {
+                relations.ownChronologySection.setSlot('kind', slots.chronologyKind);
+              }
+
+              if (
+                data.isSingleTrackArtistContribution &&
+                !html.isBlank(relations.artistReleaseChronologySection)
+              ) {
+                relations.artistReleaseChronologySection.setSlot('kind', 'release');
+
+                relations.artistReleaseChronologySection.setSlot('heading',
+                  language.$(capsule, 'heading.artistReleases', {
+                    artist: data.artistName,
+                  }));
+
+                chronologySections.push(relations.artistReleaseChronologySection);
+
+                if (data.artistReleaseChronologySectionDiffers) {
+                  relations.ownChronologySection.setSlot('heading',
+                    language.$(capsule, 'heading.artistTracks', {
+                      artist: data.artistName,
+                    }));
+
+                  chronologySections.push(relations.ownChronologySection);
+                }
+              } else {
+                chronologySections.push(relations.ownChronologySection);
+              }
+
+              return chronologySections;
+            }),
+        ],
+      })),
 };
diff --git a/src/content/dependencies/generateContributionTooltipChronologySection.js b/src/content/dependencies/generateContributionTooltipChronologySection.js
index 378c0e1c..4ee9bb35 100644
--- a/src/content/dependencies/generateContributionTooltipChronologySection.js
+++ b/src/content/dependencies/generateContributionTooltipChronologySection.js
@@ -1,36 +1,36 @@
-import Thing from '#thing';
-
 function getName(thing) {
   if (!thing) {
     return null;
   }
 
-  const referenceType = thing.constructor[Thing.referenceType];
-
-  if (referenceType === 'artwork') {
+  if (thing.isArtwork) {
     return thing.thing.name;
   }
 
   return thing.name;
 }
 
+function getSiblings(contribution) {
+  let previous = contribution;
+  while (previous && previous.thing === contribution.thing) {
+    previous = previous.previousBySameArtist;
+  }
+
+  let next = contribution;
+  while (next && next.thing === contribution.thing) {
+    next = next.nextBySameArtist;
+  }
+
+  return {previous, next};
+}
+
 export default {
   contentDependencies: ['linkAnythingMan'],
   extraDependencies: ['html', 'language'],
 
-  query(contribution) {
-    let previous = contribution;
-    while (previous && previous.thing === contribution.thing) {
-      previous = previous.previousBySameArtist;
-    }
-
-    let next = contribution;
-    while (next && next.thing === contribution.thing) {
-      next = next.nextBySameArtist;
-    }
-
-    return {previous, next};
-  },
+  query: (contribution) => ({
+    ...getSiblings(contribution),
+  }),
 
   relations: (relation, query, _contribution) => ({
     previousLink:
@@ -53,23 +53,19 @@ export default {
   }),
 
   slots: {
-    kind: {
-      validate: v =>
-        v.is(
-          'album',
-          'bannerArt',
-          'coverArt',
-          'flash',
-          'track',
-          'trackArt',
-          'trackContribution',
-          'wallpaperArt'),
-    },
+    heading: {type: 'html', mutable: false},
+    kind: {type: 'string'},
   },
 
   generate: (data, relations, slots, {html, language}) =>
     language.encapsulate('misc.artistLink.chronology', capsule =>
       html.tags([
+        html.tag('span', {class: 'chronology-heading'},
+          {[html.onlyIfContent]: true},
+          {[html.onlyIfSiblings]: true},
+
+          slots.heading),
+
         html.tags([
           relations.previousLink?.slots({
             attributes: {class: 'chronology-link'},
diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js
index c1a23bbd..78a6103b 100644
--- a/src/content/dependencies/generateCoverArtwork.js
+++ b/src/content/dependencies/generateCoverArtwork.js
@@ -1,5 +1,6 @@
 export default {
   contentDependencies: [
+    'generateColorStyleAttribute',
     'generateCoverArtworkArtTagDetails',
     'generateCoverArtworkArtistDetails',
     'generateCoverArtworkOriginDetails',
@@ -10,6 +11,9 @@ export default {
   extraDependencies: ['html'],
 
   relations: (relation, artwork) => ({
+    colorStyleAttribute:
+      relation('generateColorStyleAttribute'),
+
     image:
       relation('image', artwork),
 
@@ -46,7 +50,8 @@ export default {
     alt: {type: 'string'},
 
     color: {
-      validate: v => v.isColor,
+      validate: v => v.anyOf(v.isBoolean, v.isColor),
+      default: false,
     },
 
     mode: {
@@ -68,10 +73,7 @@ export default {
   generate(data, relations, slots, {html}) {
     const {image} = relations;
 
-    image.setSlots({
-      color: slots.color ?? data.color,
-      alt: slots.alt,
-    });
+    image.setSlot('alt', slots.alt);
 
     const square =
       (data.dimensions
@@ -84,6 +86,22 @@ export default {
       image.setSlot('dimensions', data.dimensions);
     }
 
+    const attributes = html.attributes();
+
+    let color = null;
+    if (typeof slots.color === 'boolean') {
+      if (slots.color) {
+        color = data.color;
+      }
+    } else if (slots.color) {
+      color = slots.color;
+    }
+
+    if (color) {
+      relations.colorStyleAttribute.setSlot('color', color);
+      attributes.add(relations.colorStyleAttribute);
+    }
+
     return html.tags([
       data.attachAbove &&
         html.tag('div', {class: 'cover-artwork-joiner'}),
@@ -96,6 +114,8 @@ export default {
         data.attachedArtworkIsMainArtwork &&
           {class: 'attached-artwork-is-main-artwork'},
 
+        attributes,
+
         (slots.mode === 'primary'
           ? [
               relations.image.slots({
diff --git a/src/content/dependencies/generateCoverArtworkOriginDetails.js b/src/content/dependencies/generateCoverArtworkOriginDetails.js
index 8628179e..ddd44286 100644
--- a/src/content/dependencies/generateCoverArtworkOriginDetails.js
+++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js
@@ -1,5 +1,3 @@
-import Thing from '#thing';
-
 export default {
   contentDependencies: [
     'generateArtistCredit',
@@ -11,9 +9,6 @@ export default {
   extraDependencies: ['html', 'language', 'pagePath'],
 
   query: (artwork) => ({
-    artworkThingType:
-      artwork.thing.constructor[Thing.referenceType],
-
     attachedArtistContribs:
       (artwork.attachedArtwork
         ? artwork.attachedArtwork.artistContribs
@@ -33,7 +28,7 @@ export default {
       relation('transformContent', artwork.originDetails),
 
     albumLink:
-      (query.artworkThingType === 'album'
+      (artwork.thing.isAlbum
         ? relation('linkAlbum', artwork.thing)
         : null),
 
@@ -48,8 +43,12 @@ export default {
     label:
       artwork.label,
 
-    artworkThingType:
-      query.artworkThingType,
+    forAlbum:
+      artwork.thing.isAlbum,
+
+    forSingleStyleAlbum:
+      artwork.thing.isAlbum &&
+      artwork.thing.style === 'single',
   }),
 
   generate: (data, relations, {html, language, pagePath}) =>
@@ -97,7 +96,8 @@ export default {
 
           const trackArtFromAlbum =
             pagePath[0] === 'track' &&
-            data.artworkThingType === 'album' &&
+            data.forAlbum &&
+            !data.forSingleStyleAlbum &&
               language.$(capsule, 'trackArtFromAlbum', {
                 album:
                   relations.albumLink.slot('color', false),
diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js
index e4dfd905..89371015 100644
--- a/src/content/dependencies/generateCoverGrid.js
+++ b/src/content/dependencies/generateCoverGrid.js
@@ -1,4 +1,4 @@
-import {stitchArrays} from '#sugar';
+import {empty, stitchArrays, unique} from '#sugar';
 
 export default {
   contentDependencies: ['generateGridActionLinks'],
@@ -11,10 +11,13 @@ export default {
   },
 
   slots: {
+    attributes: {type: 'attributes', mutable: false},
+
     images: {validate: v => v.strictArrayOf(v.isHTML)},
     links: {validate: v => v.strictArrayOf(v.isHTML)},
     names: {validate: v => v.strictArrayOf(v.isHTML)},
     info: {validate: v => v.strictArrayOf(v.isHTML)},
+    tab: {validate: v => v.strictArrayOf(v.isHTML)},
     notFromThisGroup: {validate: v => v.strictArrayOf(v.isBoolean)},
 
     // Differentiating from sparseArrayOf here - this list of classes should
@@ -30,37 +33,91 @@ export default {
               v.isString))),
     },
 
+    itemAttributes: {
+      validate: v =>
+        v.strictArrayOf(
+          v.optional(v.isAttributes)),
+    },
+
     lazy: {validate: v => v.anyOf(v.isWholeNumber, v.isBoolean)},
     actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)},
+
+    revealAllWarnings: {
+      validate: v => v.looseArrayOf(v.isString),
+    },
   },
 
   generate: (relations, slots, {html, language}) =>
     html.tag('div', {class: 'grid-listing'},
+      slots.attributes,
       {[html.onlyIfContent]: true},
 
       [
+        !empty((slots.revealAllWarnings ?? []).filter(Boolean)) &&
+          language.encapsulate('misc.coverGrid.revealAll', capsule =>
+            html.tag('div', {class: 'reveal-all-container'},
+              ((slots.tab ?? [])
+                .slice(0, 4)
+                .some(tab => tab && !html.isBlank(tab))) &&
+
+                {class: 'has-nearby-tab'},
+
+              html.tag('p', {class: 'reveal-all'}, [
+                html.tag('a', {href: '#'}, [
+                  html.tag('span', {class: 'reveal-label'},
+                    language.$(capsule, 'reveal')),
+
+                  html.tag('span', {class: 'conceal-label'},
+                    {style: 'display: none'},
+                    language.$(capsule, 'conceal')),
+                ]),
+
+                html.tag('br'),
+
+                html.tag('span', {class: 'warnings'},
+                  language.$(capsule, 'warnings', {
+                    warnings:
+                      language.formatUnitList(
+                        unique(slots.revealAllWarnings.filter(Boolean))
+                          .sort()
+                          .map(warning => html.tag('b', warning))),
+                  })),
+              ]))),
+
         stitchArrays({
           classes: slots.classes,
+          attributes: slots.itemAttributes,
           image: slots.images,
           link: slots.links,
           name: slots.names,
           info: slots.info,
+          tab: slots.tab,
 
           notFromThisGroup:
             slots.notFromThisGroup ??
             Array.from(slots.links).fill(null)
         }).map(({
             classes,
+            attributes,
             image,
             link,
             name,
             info,
+            tab,
             notFromThisGroup,
           }, index) =>
             link.slots({
               attributes: [
+                link.getSlotValue('attributes'),
+
                 {class: ['grid-item', 'box']},
 
+                tab &&
+                !html.isBlank(tab) &&
+                  {class: 'has-tab'},
+
+                attributes,
+
                 (classes
                   ? {class: classes}
                   : null),
@@ -69,6 +126,11 @@ export default {
               colorContext: 'image-box',
 
               content: [
+                html.tag('span',
+                  {[html.onlyIfContent]: true},
+
+                  tab),
+
                 image.slots({
                   thumb: 'medium',
                   square: true,
diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js
index cb652b1c..7f047cad 100644
--- a/src/content/dependencies/generateFlashInfoPage.js
+++ b/src/content/dependencies/generateFlashInfoPage.js
@@ -4,6 +4,8 @@ export default {
   contentDependencies: [
     'generateAdditionalNamesBox',
     'generateCommentaryEntry',
+    'generateCommentaryContentHeading',
+    'generateContentContentHeading',
     'generateContentHeading',
     'generateContributionList',
     'generateFlashActSidebar',
@@ -53,6 +55,12 @@ export default {
     contentHeading:
       relation('generateContentHeading'),
 
+    contentContentHeading:
+      relation('generateContentContentHeading', flash),
+
+    commentaryContentHeading:
+      relation('generateCommentaryContentHeading', flash),
+
     flashActLink:
       relation('linkFlashAct', flash.act),
 
@@ -168,20 +176,15 @@ export default {
           ]),
 
           html.tags([
-            relations.contentHeading.clone()
-              .slots({
-                attributes: {id: 'artist-commentary'},
-                title: language.$('misc.artistCommentary'),
-              }),
-
+            relations.commentaryContentHeading,
             relations.artistCommentaryEntries,
           ]),
 
           html.tags([
-            relations.contentHeading.clone()
+            relations.contentContentHeading.clone()
               .slots({
                 attributes: {id: 'crediting-sources'},
-                title: language.$('misc.creditingSources'),
+                string: 'misc.creditingSources',
               }),
 
             relations.creditSourceEntries,
diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js
index dfdad0e8..8e11f9e5 100644
--- a/src/content/dependencies/generateGroupGalleryPage.js
+++ b/src/content/dependencies/generateGroupGalleryPage.js
@@ -183,7 +183,10 @@ export default {
                 }))),
           */
 
-          relations.albumsByDateView,
+          relations.albumsByDateView.slots({
+            showTitle:
+              !html.isBlank(relations.albumsBySeriesView),
+          }),
 
           relations.albumsBySeriesView.slots({
             attributes: [
diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js b/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js
index 7d9aa2d2..7b90fd68 100644
--- a/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js
+++ b/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js
@@ -2,34 +2,60 @@ import {stitchArrays} from '#sugar';
 import {getTotalDuration} from '#wiki-data';
 
 export default {
-  contentDependencies: ['generateCoverGrid', 'image', 'linkAlbum'],
-  extraDependencies: ['language'],
+  contentDependencies: [
+    'generateCoverGrid',
+    'generateGroupGalleryPageAlbumGridTab',
+    'image',
+    'linkAlbum',
+  ],
 
-  relations: (relation, albums, _group) => ({
+  extraDependencies: ['language', 'wikiData'],
+
+  query: (albums, _group) => ({
+    artworks:
+      albums.map(album =>
+        (album.hasCoverArt
+          ? album.coverArtworks[0]
+          : null)),
+  }),
+
+  relations: (relation, query, albums, group) => ({
     coverGrid:
       relation('generateCoverGrid'),
 
     links:
-      albums.map(album =>
-        relation('linkAlbum', album)),
+      albums
+        .map(album => relation('linkAlbum', album)),
 
     images:
-      albums.map(album =>
-        (album.hasCoverArt
-          ? relation('image', album.coverArtworks[0])
-          : relation('image')))
+      query.artworks
+        .map(artwork => relation('image', artwork)),
+
+    tabs:
+      albums
+        .map(album =>
+          relation('generateGroupGalleryPageAlbumGridTab', album, group)),
   }),
 
-  data: (albums, group) => ({
+  data: (query, albums, group) => ({
     names:
       albums.map(album => album.name),
 
-    durations:
-      albums.map(album => getTotalDuration(album.tracks)),
+    styles:
+      albums.map(album => album.style),
 
     tracks:
       albums.map(album => album.tracks.length),
 
+    allWarnings:
+      query.artworks.flatMap(artwork => artwork?.contentWarnings),
+
+    durations:
+      albums.map(album =>
+        (album.hideDuration
+          ? null
+          : getTotalDuration(album.tracks))),
+
     notFromThisGroup:
       albums.map(album => !album.groups.includes(group)),
   }),
@@ -53,14 +79,28 @@ export default {
                   }),
               })),
 
+        itemAttributes:
+          data.styles.map(style => ({'data-style': style})),
+
+        tab: relations.tabs,
+
         info:
           stitchArrays({
+            style: data.styles,
             tracks: data.tracks,
             duration: data.durations,
-          }).map(({tracks, duration}) =>
-              language.$(capsule, 'details.albumLength', {
-                tracks: language.countTracks(tracks, {unit: true}),
-                time: language.formatDuration(duration),
-              })),
+          }).map(({style, tracks, duration}) =>
+              (style === 'single' && duration
+                ? language.$(capsule, 'details.albumLength.single', {
+                    time: language.formatDuration(duration),
+                  })
+             : duration
+                ? language.$(capsule, 'details.albumLength', {
+                    tracks: language.countTracks(tracks, {unit: true}),
+                    time: language.formatDuration(duration),
+                  })
+                : null)),
+
+        revealAllWarnings: data.allWarnings,
       })),
 };
diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumGridTab.js b/src/content/dependencies/generateGroupGalleryPageAlbumGridTab.js
new file mode 100644
index 00000000..d86b61e1
--- /dev/null
+++ b/src/content/dependencies/generateGroupGalleryPageAlbumGridTab.js
@@ -0,0 +1,82 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: ['generateArtistCredit'],
+  extraDependencies: ['language'],
+
+  query(album, group) {
+    if (album.groups.length > 1) {
+      const contextGroup = group;
+
+      const candidateGroupCategory =
+        album.groups
+          .filter(group => !group.excludeFromGalleryTabs)
+          .find(group => group.category !== contextGroup.category)
+          ?.category ??
+        null;
+
+      const candidateGroups =
+        album.groups
+          .filter(group => !group.excludeFromGalleryTabs)
+          .filter(group => group.category === candidateGroupCategory);
+
+      if (!empty(candidateGroups)) {
+        return {
+          mode: 'groups',
+          notedGroups: candidateGroups,
+        };
+      }
+    }
+
+    if (!empty(album.artistContribs)) {
+      if (
+        album.artistContribs.length === 1 &&
+        !empty(group.closelyLinkedArtists) &&
+        (album.artistContribs[0].artist.name ===
+         group.closelyLinkedArtists[0].artist.name)
+      ) {
+        return {mode: null};
+      }
+
+      return {
+        mode: 'artists',
+        notedArtistContribs: album.artistContribs,
+      };
+    }
+
+    return {mode: null};;
+  },
+
+  relations: (relation, query, _album, _group) => ({
+    artistCredit:
+      (query.mode === 'artists'
+        ? relation('generateArtistCredit', query.notedArtistContribs, [])
+        : null),
+  }),
+
+  data: (query, _album, _group) => ({
+    mode: query.mode,
+
+    groupNames:
+      (query.mode === 'groups'
+        ? query.notedGroups.map(group => group.name)
+        : null),
+  }),
+
+  generate: (data, relations, {language}) =>
+    language.encapsulate('misc.coverGrid.tab', capsule =>
+      (data.mode === 'groups'
+        ? language.$(capsule, 'groups', {
+            groups:
+              language.formatUnitList(data.groupNames),
+          })
+     : data.mode === 'artists'
+        ? relations.artistCredit.slots({
+            normalStringKey:
+              capsule + '.artists',
+
+            normalFeaturingStringKey:
+              capsule + '.artists.featuring',
+          })
+        : null)),
+};
diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js b/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js
index b7d01eb5..58375f3e 100644
--- a/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js
+++ b/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js
@@ -1,7 +1,11 @@
 import {sortChronologically} from '#sort';
 
 export default {
-  contentDependencies: ['generateGroupGalleryPageAlbumGrid'],
+  contentDependencies: [
+    'generateGroupGalleryPageAlbumGrid',
+    'generateGroupGalleryPageStyleSelector',
+  ],
+
   extraDependencies: ['html', 'language'],
 
   query: (group) => ({
@@ -10,6 +14,11 @@ export default {
   }),
 
   relations: (relation, query, group) => ({
+    styleSelector:
+      (group.divideAlbumsByStyle
+        ? relation('generateGroupGalleryPageStyleSelector', group)
+        : null),
+
     albumGrid:
       relation('generateGroupGalleryPageAlbumGrid',
         query.albums,
@@ -17,6 +26,10 @@ export default {
   }),
 
   slots: {
+    showTitle: {
+      type: 'boolean',
+    },
+
     attributes: {
       type: 'attributes',
       mutable: false,
@@ -31,8 +44,11 @@ export default {
         {[html.onlyIfContent]: true},
 
         html.tag('section', [
-          html.tag('h2',
-            language.$(capsule, 'title')),
+          slots.showTitle &&
+            html.tag('h2',
+              language.$(capsule, 'title')),
+
+          relations.styleSelector,
 
           relations.albumGrid,
         ]))),
diff --git a/src/content/dependencies/generateGroupGalleryPageStyleSelector.js b/src/content/dependencies/generateGroupGalleryPageStyleSelector.js
new file mode 100644
index 00000000..4f9d02a9
--- /dev/null
+++ b/src/content/dependencies/generateGroupGalleryPageStyleSelector.js
@@ -0,0 +1,62 @@
+import {unique} from '#sugar';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  query: (group) => ({
+    styles:
+      unique(group.albums.map(album => album.style)),
+  }),
+
+  data: (query, group) => ({
+    albums:
+      group.albums.length,
+
+    styles:
+      query.styles,
+  }),
+
+  generate: (data, {html, language}) =>
+    language.encapsulate('groupGalleryPage', pageCapsule =>
+      (data.styles.length <= 1
+        ? html.blank()
+        : html.tag('p', {class: 'gallery-style-selector'},
+            {class: ['drop', 'shiny']},
+
+            language.encapsulate(pageCapsule, 'albumStyleSwitcher', capsule => [
+              html.tag('span',
+                language.$(capsule)),
+
+              html.tag('br'),
+
+              html.tag('span', {class: 'styles'},
+                data.styles.map(style =>
+                  html.tag('label', {'data-style': style}, [
+                    html.tag('input', {type: 'checkbox'},
+                      {checked: true}),
+
+                    html.tag('span',
+                      language.$(capsule, style)),
+                  ]))),
+
+              html.tag('br'),
+
+              html.tag('span', {class: ['count', 'all']},
+                language.$(capsule, 'count.all', {
+                  total: data.albums,
+                })),
+
+              html.tag('span', {class: ['count', 'filtered']},
+                {style: 'display: none'},
+
+                language.$(capsule, 'count.filtered', {
+                  count: html.tag('span'),
+                  total: data.albums,
+                })),
+
+              html.tag('span', {class: ['count', 'none']},
+                {style: 'display: none'},
+
+                language.$(capsule, 'count.none')),
+            ])))),
+};
diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
index 4680cb46..cec18240 100644
--- a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
+++ b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js
@@ -127,9 +127,7 @@ export default {
             workingCapsule += '.withArtists';
             workingOptions.by =
               html.tag('span', {class: 'by'},
-                // TODO: This is obviously evil.
-                html.metatag('chunkwrap', {split: /,| (?=and)/},
-                  html.resolve(artistCredit)));
+                artistCredit);
           }
 
           return language.$(workingCapsule, workingOptions);
diff --git a/src/content/dependencies/generateIntrapageDotSwitcher.js b/src/content/dependencies/generateIntrapageDotSwitcher.js
index 1d58367d..cd92b165 100644
--- a/src/content/dependencies/generateIntrapageDotSwitcher.js
+++ b/src/content/dependencies/generateIntrapageDotSwitcher.js
@@ -39,11 +39,32 @@ export default {
         stitchArrays({
           title: slots.titles,
           targetID: slots.targetIDs,
-        }).map(({title, targetID}) =>
-            html.tag('a', {href: '#'},
-              {'data-target-id': targetID},
-              {[html.onlyIfContent]: true},
+        }).map(({title, targetID}) => {
+            const {content} = html.smush(title);
 
-              language.sanitize(title))),
+            const customCue =
+              content.find(item =>
+                item?.tagName === 'span' &&
+                item.attributes.has('class', 'dot-switcher-interaction-cue'));
+
+            const cue =
+              (customCue && !html.isBlank(customCue)
+                ? customCue.content
+                : language.sanitize(title));
+
+            const a =
+              html.tag('a', {href: '#'},
+                {'data-target-id': targetID},
+                {[html.onlyIfContent]: true},
+
+                cue);
+
+            if (customCue) {
+              content.splice(content.indexOf(customCue), 1, a);
+              return html.tags(content, {[html.joinChildren]: ''});
+            } else {
+              return a;
+            }
+          }),
     }),
 };
diff --git a/src/content/dependencies/generateLyricsSection.js b/src/content/dependencies/generateLyricsSection.js
index f6b719a9..64676d3b 100644
--- a/src/content/dependencies/generateLyricsSection.js
+++ b/src/content/dependencies/generateLyricsSection.js
@@ -21,10 +21,10 @@ export default {
       entries
         .map(entry => relation('generateLyricsEntry', entry)),
 
-    annotations:
+    annotationParts:
       entries
-        .map(entry => entry.annotation)
-        .map(annotation => relation('transformContent', annotation)),
+        .map(entry => entry.annotationParts
+          .map(part => relation('transformContent', part))),
   }),
 
   data: (entries) => ({
@@ -54,11 +54,24 @@ export default {
                 initialOptionIndex: 0,
 
                 titles:
-                  relations.annotations.map(annotation =>
-                    annotation.slots({
-                      mode: 'inline',
-                      textOnly: true,
-                    })),
+                  relations.annotationParts
+                    .map(([first, ...rest]) =>
+                      language.formatUnitList([
+                        html.tag('span',
+                          {class: 'dot-switcher-interaction-cue'},
+                          {[html.onlyIfContent]: true},
+
+                          first?.slots({
+                            mode: 'inline',
+                            textOnly: true,
+                          })),
+
+                        ...rest.map(part =>
+                          part.slots({
+                            mode: 'inline',
+                            textOnly: true,
+                          })),
+                      ])),
 
                 targetIDs:
                   data.ids,
diff --git a/src/content/dependencies/generateReadCommentaryLine.js b/src/content/dependencies/generateReadCommentaryLine.js
new file mode 100644
index 00000000..a7a7a4da
--- /dev/null
+++ b/src/content/dependencies/generateReadCommentaryLine.js
@@ -0,0 +1,47 @@
+import {empty} from '#sugar';
+
+export default {
+  extraDependencies: ['html', 'language'],
+
+  query: (thing) => ({
+    entries:
+      (thing.isTrack
+        ? [...thing.commentary, ...thing.commentaryFromMainRelease]
+        : thing.commentary),
+  }),
+
+  data: (query, _thing) => ({
+    hasWikiEditorCommentary:
+      query.entries
+        .some(entry => entry.isWikiEditorCommentary),
+
+    onlyWikiEditorCommentary:
+      !empty(query.entries) &&
+      query.entries
+        .every(entry => entry.isWikiEditorCommentary),
+
+    hasAnyCommentary:
+      !empty(query.entries),
+  }),
+
+  generate: (data, {html, language}) =>
+    language.encapsulate('releaseInfo.readCommentary', capsule =>
+      language.$(capsule, {
+        [language.onlyIfOptions]: ['link'],
+
+        link:
+          html.tag('a',
+            {[html.onlyIfContent]: true},
+
+            {href: '#artist-commentary'},
+
+            language.encapsulate(capsule, 'link', capsule =>
+              (data.onlyWikiEditorCommentary
+                ? language.$(capsule, 'onlyWikiCommentary')
+             : data.hasWikiEditorCommentary
+                ? language.$(capsule, 'withWikiCommentary')
+             : data.hasAnyCommentary
+                ? language.$(capsule)
+                : html.blank()))),
+      })),
+};
diff --git a/src/content/dependencies/generateReleaseInfoListenLine.js b/src/content/dependencies/generateReleaseInfoListenLine.js
new file mode 100644
index 00000000..b02ff6f9
--- /dev/null
+++ b/src/content/dependencies/generateReleaseInfoListenLine.js
@@ -0,0 +1,159 @@
+import {isExternalLinkContext} from '#external-links';
+import {empty, stitchArrays, unique} from '#sugar';
+
+function getReleaseContext(urlString, {
+  _artistURLs,
+  albumArtistURLs,
+}) {
+  const composerBandcampDomains =
+    albumArtistURLs
+      .filter(url => url.hostname.endsWith('.bandcamp.com'))
+      .map(url => url.hostname);
+
+  const url = new URL(urlString);
+
+  if (url.hostname === 'homestuck.bandcamp.com') {
+    return 'officialRelease';
+  }
+
+  if (composerBandcampDomains.includes(url.hostname)) {
+    return 'composerRelease';
+  }
+
+  return null;
+}
+
+export default {
+  contentDependencies: ['linkExternal'],
+  extraDependencies: ['html', 'language'],
+
+  query(thing) {
+    const query = {};
+
+    query.album =
+      (thing.album
+        ? thing.album
+        : thing);
+
+    query.urls =
+      (!empty(thing.urls)
+        ? thing.urls
+     : thing.album &&
+       thing.album.style === 'single' &&
+       thing.album.tracks[0] === thing
+        ? thing.album.urls
+        : []);
+
+    query.artists =
+      thing.artistContribs
+        .map(contrib => contrib.artist);
+
+    query.artistGroups =
+      query.artists
+        .flatMap(artist => artist.closelyLinkedGroups)
+        .map(({group}) => group);
+
+    query.albumArtists =
+      query.album.artistContribs
+        .map(contrib => contrib.artist);
+
+    query.albumArtistGroups =
+      query.albumArtists
+        .flatMap(artist => artist.closelyLinkedGroups)
+        .map(({group}) => group);
+
+    return query;
+  },
+
+  relations: (relation, query, _thing) => ({
+    links:
+      query.urls.map(url => relation('linkExternal', url)),
+  }),
+
+  data(query, thing) {
+    const data = {};
+
+    data.name = thing.name;
+
+    const artistURLs =
+      unique([
+        ...query.artists.flatMap(artist => artist.urls),
+        ...query.artistGroups.flatMap(group => group.urls),
+      ]).map(url => new URL(url));
+
+    const albumArtistURLs =
+      unique([
+        ...query.albumArtists.flatMap(artist => artist.urls),
+        ...query.albumArtistGroups.flatMap(group => group.urls),
+      ]).map(url => new URL(url));
+
+    const boundGetReleaseContext = urlString =>
+      getReleaseContext(urlString, {
+        artistURLs,
+        albumArtistURLs,
+      });
+
+    let releaseContexts =
+      query.urls.map(boundGetReleaseContext);
+
+    const albumReleaseContexts =
+      query.album.urls.map(boundGetReleaseContext);
+
+    const presentReleaseContexts =
+      unique(releaseContexts.filter(Boolean));
+
+    const presentAlbumReleaseContexts =
+      unique(albumReleaseContexts.filter(Boolean));
+
+    if (
+      presentReleaseContexts.length <= 1 &&
+      presentAlbumReleaseContexts.length <= 1
+    ) {
+      releaseContexts =
+        query.urls.map(() => null);
+    }
+
+    data.releaseContexts = releaseContexts;
+
+    return data;
+  },
+
+  slots: {
+    visibleWithoutLinks: {
+      type: 'boolean',
+      default: false,
+    },
+
+    context: {
+      validate: () => isExternalLinkContext,
+      default: 'generic',
+    },
+  },
+
+  generate: (data, relations, slots, {html, language}) =>
+    language.encapsulate('releaseInfo.listenOn', capsule =>
+      (empty(relations.links) && slots.visibleWithoutLinks
+        ? language.$(capsule, 'noLinks', {
+            name:
+              html.tag('i', data.name),
+          })
+
+        : language.$('releaseInfo.listenOn', {
+            [language.onlyIfOptions]: ['links'],
+
+            links:
+              language.formatDisjunctionList(
+                stitchArrays({
+                  link: relations.links,
+                  releaseContext: data.releaseContexts,
+                }).map(({link, releaseContext}) =>
+                    link.slot('context', [
+                      ...
+                      (Array.isArray(slots.context)
+                        ? slots.context
+                        : [slots.context]),
+
+                      releaseContext,
+                    ]))),
+          }))),
+};
diff --git a/src/content/dependencies/generateTrackArtistCommentarySection.js b/src/content/dependencies/generateTrackArtistCommentarySection.js
index 6650ff2b..5ed24d6c 100644
--- a/src/content/dependencies/generateTrackArtistCommentarySection.js
+++ b/src/content/dependencies/generateTrackArtistCommentarySection.js
@@ -2,8 +2,8 @@ import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
+    'generateCommentaryContentHeading',
     'generateCommentaryEntry',
-    'generateContentHeading',
     'linkAlbum',
     'linkTrack',
   ],
@@ -18,8 +18,8 @@ export default {
   }),
 
   relations: (relation, query, track) => ({
-    contentHeading:
-      relation('generateContentHeading'),
+    commentaryContentHeading:
+      relation('generateCommentaryContentHeading', track),
 
     mainReleaseTrackLink:
       (track.isSecondaryRelease
@@ -28,7 +28,7 @@ export default {
 
     mainReleaseArtistCommentaryEntries:
       (track.isSecondaryRelease
-        ? track.mainReleaseTrack.commentary
+        ? track.commentaryFromMainRelease
             .map(entry => relation('generateCommentaryEntry', entry))
         : null),
 
@@ -78,42 +78,40 @@ export default {
   generate: (data, relations, {html, language}) =>
     language.encapsulate('misc.artistCommentary', capsule =>
       html.tags([
-        relations.contentHeading.clone()
-          .slots({
-            attributes: {id: 'artist-commentary'},
-            title: language.$('misc.artistCommentary'),
-          }),
-
+        relations.commentaryContentHeading,
         relations.artistCommentaryEntries,
 
         data.isSecondaryRelease &&
-          html.tags([
-            html.tag('p', {class: ['drop', 'commentary-drop']},
-              {[html.onlyIfSiblings]: true},
+          html.tag('div', {class: 'inherited-commentary-section'},
+            {[html.onlyIfContent]: true},
+
+            [
+              html.tag('p', {class: ['drop', 'commentary-drop']},
+                {[html.onlyIfSiblings]: true},
 
-              language.encapsulate(capsule, 'info.fromMainRelease', workingCapsule => {
-                const workingOptions = {};
+                language.encapsulate(capsule, 'info.fromMainRelease', workingCapsule => {
+                  const workingOptions = {};
 
-                workingOptions.album =
-                  relations.mainReleaseTrackLink.slots({
-                    content:
-                      data.mainReleaseAlbumName,
+                  workingOptions.album =
+                    relations.mainReleaseTrackLink.slots({
+                      content:
+                        data.mainReleaseAlbumName,
 
-                    color:
-                      data.mainReleaseAlbumColor,
-                  });
+                      color:
+                        data.mainReleaseAlbumColor,
+                    });
 
-                if (data.name !== data.mainReleaseName) {
-                  workingCapsule += '.namedDifferently';
-                  workingOptions.name =
-                    html.tag('i', data.mainReleaseName);
-                }
+                  if (data.name !== data.mainReleaseName) {
+                    workingCapsule += '.namedDifferently';
+                    workingOptions.name =
+                      html.tag('i', data.mainReleaseName);
+                  }
 
-                return language.$(workingCapsule, workingOptions);
-              })),
+                  return language.$(workingCapsule, workingOptions);
+                })),
 
-            relations.mainReleaseArtistCommentaryEntries,
-          ]),
+              relations.mainReleaseArtistCommentaryEntries,
+            ]),
 
         html.tag('p', {class: ['drop', 'commentary-drop']},
           {[html.onlyIfContent]: true},
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 8d59f85f..071ccd45 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -2,15 +2,18 @@ export default {
   contentDependencies: [
     'generateAdditionalFilesList',
     'generateAdditionalNamesBox',
+    'generateAlbumArtworkColumn',
     'generateAlbumNavAccent',
     'generateAlbumSecondaryNav',
     'generateAlbumSidebar',
     'generateAlbumStyleTags',
     'generateCommentaryEntry',
+    'generateContentContentHeading',
     'generateContentHeading',
     'generateContributionList',
     'generateLyricsSection',
     'generatePageLayout',
+    'generateReadCommentaryLine',
     'generateTrackArtistCommentarySection',
     'generateTrackArtworkColumn',
     'generateTrackInfoPageFeaturedByFlashesList',
@@ -32,6 +35,14 @@ export default {
       (track.isMainRelease
         ? track
         : track.mainReleaseTrack),
+
+    singleTrackSingle:
+      track.album.style === 'single' &&
+      track.album.tracks.length === 1,
+
+    firstTrackInSingle:
+      track.album.style === 'single' &&
+      track === track.album.tracks[0],
   }),
 
   relations: (relation, query, track) => ({
@@ -47,6 +58,9 @@ export default {
     navLinks:
       relation('generateTrackNavLinks', track),
 
+    albumNavLink:
+      relation('linkAlbum', track.album),
+
     albumNavAccent:
       relation('generateAlbumNavAccent', track.album, track),
 
@@ -60,14 +74,22 @@ export default {
       relation('generateAdditionalNamesBox', track.additionalNames),
 
     artworkColumn:
-      relation('generateTrackArtworkColumn', track),
+      (query.firstTrackInSingle
+        ? relation('generateAlbumArtworkColumn', track.album)
+        : relation('generateTrackArtworkColumn', track)),
 
     contentHeading:
       relation('generateContentHeading'),
 
+    contentContentHeading:
+      relation('generateContentContentHeading', track),
+
     releaseInfo:
       relation('generateTrackReleaseInfo', track),
 
+    readCommentaryLine:
+      relation('generateReadCommentaryLine', track),
+
     otherReleasesList:
       relation('generateTrackInfoPageOtherReleasesList', track),
 
@@ -75,18 +97,20 @@ export default {
       relation('generateContributionList', track.contributorContribs),
 
     referencedTracksList:
-      relation('generateTrackList', track.referencedTracks),
+      relation('generateTrackList', track.referencedTracks, track),
 
     sampledTracksList:
-      relation('generateTrackList', track.sampledTracks),
+      relation('generateTrackList', track.sampledTracks, track),
 
     referencedByTracksList:
       relation('generateTrackListDividedByGroups',
-        query.mainReleaseTrack.referencedByTracks),
+        query.mainReleaseTrack.referencedByTracks,
+        track),
 
     sampledByTracksList:
       relation('generateTrackListDividedByGroups',
-        query.mainReleaseTrack.sampledByTracks),
+        query.mainReleaseTrack.sampledByTracks,
+        track),
 
     flashesThatFeatureList:
       relation('generateTrackInfoPageFeaturedByFlashesList', track),
@@ -115,12 +139,21 @@ export default {
         .map(entry => relation('generateCommentaryEntry', entry)),
   }),
 
-  data: (_query, track) => ({
+  data: (query, track) => ({
     name:
       track.name,
 
     color:
       track.color,
+
+    dateAlbumAddedToWiki:
+      track.album.dateAddedToWiki,
+
+    singleTrackSingle:
+      query.singleTrackSingle,
+
+    firstTrackInSingle:
+      query.firstTrackInSingle,
   }),
 
   generate: (data, relations, {html, language}) =>
@@ -176,14 +209,19 @@ export default {
                         language.$(capsule, 'link')),
                   })),
 
-              !html.isBlank(relations.artistCommentarySection) &&
-                language.encapsulate(capsule, 'readCommentary', capsule =>
-                  language.$(capsule, {
-                    link:
-                      html.tag('a',
-                        {href: '#artist-commentary'},
-                        language.$(capsule, 'link')),
-                  })),
+              (!html.isBlank(relations.additionalFilesList) ||
+               !html.isBlank(relations.contributorContributionList) ||
+               !html.isBlank(relations.creditingSourceEntries) ||
+               !html.isBlank(relations.flashesThatFeatureList) ||
+               !html.isBlank(relations.lyricsSection) ||
+               !html.isBlank(relations.midiProjectFilesList) ||
+               !html.isBlank(relations.referencedByTracksList) ||
+               !html.isBlank(relations.referencedTracksList) ||
+               !html.isBlank(relations.referencingSourceEntries) ||
+               !html.isBlank(relations.sampledByTracksList) ||
+               !html.isBlank(relations.sampledTracksList) ||
+               !html.isBlank(relations.sheetMusicFilesList)) &&
+                relations.readCommentaryLine,
 
               !html.isBlank(relations.creditingSourceEntries) &&
                 language.encapsulate(capsule, 'readCreditingSources', capsule =>
@@ -316,6 +354,22 @@ export default {
             relations.flashesThatFeatureList,
           ]),
 
+          data.firstTrackInSingle &&
+            html.tag('p',
+              {[html.onlyIfContent]: true},
+
+              language.$('releaseInfo.addedToWiki', {
+                [language.onlyIfOptions]: ['date'],
+                date: language.formatDate(data.dateAlbumAddedToWiki),
+              })),
+
+          data.firstTrackInSingle &&
+          (!html.isBlank(relations.lyricsSection) ||
+           !html.isBlank(relations.artistCommentaryEntries) ||
+           !html.isBlank(relations.creditingSourceEntries) ||
+           !html.isBlank(relations.referencingSourceEntries)) &&
+            html.tag('hr', {class: 'main-separator'}),
+
           relations.lyricsSection,
 
           html.tags([
@@ -351,20 +405,20 @@ export default {
           relations.artistCommentarySection,
 
           html.tags([
-            relations.contentHeading.clone()
+            relations.contentContentHeading.clone()
               .slots({
                 attributes: {id: 'crediting-sources'},
-                title: language.$('misc.creditingSources'),
+                string: 'misc.creditingSources',
               }),
 
             relations.creditingSourceEntries,
           ]),
 
           html.tags([
-            relations.contentHeading.clone()
+            relations.contentContentHeading.clone()
               .slots({
                 attributes: {id: 'referencing-sources'},
-                title: language.$('misc.referencingSources'),
+                string: 'misc.referencingSources',
               }),
 
             relations.referencingSourceEntries,
@@ -372,17 +426,28 @@ export default {
         ],
 
         navLinkStyle: 'hierarchical',
-        navLinks: html.resolve(relations.navLinks),
+        navLinks:
+          (data.singleTrackSingle
+            ? [
+                {auto: 'home'},
+                {
+                  html: relations.albumNavLink,
+                  accent: language.$(pageCapsule, 'nav.singleAccent'),
+                },
+              ]
+            : html.resolve(relations.navLinks)),
 
         navBottomRowContent:
-          relations.albumNavAccent.slots({
-            showTrackNavigation: true,
-            showExtraLinks: false,
-          }),
+          (data.singleTrackSingle
+            ? null
+            : relations.albumNavAccent.slots({
+                showTrackNavigation: true,
+                showExtraLinks: false,
+              })),
 
         secondaryNav:
           relations.secondaryNav
-            .slot('mode', 'track'),
+            .slot('mode', data.firstTrackInSingle ? 'album' : 'track'),
 
         leftSidebar: relations.sidebar,
 
diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js
index 53a32536..ff7659b5 100644
--- a/src/content/dependencies/generateTrackList.js
+++ b/src/content/dependencies/generateTrackList.js
@@ -2,9 +2,18 @@ export default {
   contentDependencies: ['generateTrackListItem'],
   extraDependencies: ['html'],
 
-  relations: (relation, tracks) => ({
+  query: (tracks, contextTrack) => ({
+    presentedTracks:
+      (contextTrack
+        ? tracks.map(track =>
+            track.otherReleases.find(({album}) => album === contextTrack.album) ??
+            track)
+        : tracks),
+  }),
+
+  relations: (relation, query, _tracks, _contextTrack) => ({
     items:
-      tracks
+      query.presentedTracks
         .map(track => relation('generateTrackListItem', track, [])),
   }),
 
diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js
index 230868d6..9deccc0c 100644
--- a/src/content/dependencies/generateTrackListDividedByGroups.js
+++ b/src/content/dependencies/generateTrackListDividedByGroups.js
@@ -14,7 +14,7 @@ export default {
       wikiInfo.divideTrackListsByGroups,
   }),
 
-  query(sprawl, tracks) {
+  query(sprawl, tracks, _contextTrack) {
     const dividingGroups = sprawl.divideTrackListsByGroups;
 
     const groupings = new Map();
@@ -50,10 +50,10 @@ export default {
     return {groups, groupedTracks, ungroupedTracks};
   },
 
-  relations: (relation, query, sprawl, tracks) => ({
+  relations: (relation, query, sprawl, tracks, contextTrack) => ({
     flatList:
       (empty(sprawl.divideTrackListsByGroups)
-        ? relation('generateTrackList', tracks)
+        ? relation('generateTrackList', tracks, contextTrack)
         : null),
 
     contentHeading:
@@ -65,12 +65,12 @@ export default {
 
     groupedTrackLists:
       query.groupedTracks
-        .map(tracks => relation('generateTrackList', tracks)),
+        .map(tracks => relation('generateTrackList', tracks, contextTrack)),
 
     ungroupedTrackList:
       (empty(query.ungroupedTracks)
         ? null
-        : relation('generateTrackList', query.ungroupedTracks)),
+        : relation('generateTrackList', query.ungroupedTracks, contextTrack)),
   }),
 
   data: (query, _sprawl, _tracks) => ({
diff --git a/src/content/dependencies/generateTrackListItem.js b/src/content/dependencies/generateTrackListItem.js
index 3c850a18..5678e240 100644
--- a/src/content/dependencies/generateTrackListItem.js
+++ b/src/content/dependencies/generateTrackListItem.js
@@ -97,9 +97,7 @@ export default {
             workingCapsule += '.withArtists';
             workingOptions.by =
               html.tag('span', {class: 'by'},
-                // TODO: This is obviously evil.
-                html.metatag('chunkwrap', {split: /,| (?=and)/},
-                  html.resolve(relations.credit)));
+                relations.credit);
           }
 
           return language.$(workingCapsule, workingOptions);
diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js
index 54e462c7..3298dcc4 100644
--- a/src/content/dependencies/generateTrackReleaseInfo.js
+++ b/src/content/dependencies/generateTrackReleaseInfo.js
@@ -1,9 +1,7 @@
-import {empty} from '#sugar';
-
 export default {
   contentDependencies: [
     'generateReleaseInfoContributionsLine',
-    'linkExternal',
+    'generateReleaseInfoListenLine',
   ],
 
   extraDependencies: ['html', 'language'],
@@ -11,14 +9,11 @@ export default {
   relations(relation, track) {
     const relations = {};
 
-    relations.artistContributionLinks =
+    relations.artistContributionsLine =
       relation('generateReleaseInfoContributionsLine', track.artistContribs);
 
-    if (!empty(track.urls)) {
-      relations.externalLinks =
-        track.urls.map(url =>
-          relation('linkExternal', url));
-    }
+    relations.listenLine =
+      relation('generateReleaseInfoListenLine', track);
 
     return relations;
   },
@@ -48,7 +43,7 @@ export default {
           {[html.joinChildren]: html.tag('br')},
 
           [
-            relations.artistContributionLinks.slots({
+            relations.artistContributionsLine.slots({
               stringKey: capsule + '.by',
               featuringStringKey: capsule + '.by.featuring',
               chronologyKind: 'track',
@@ -66,17 +61,9 @@ export default {
           ]),
 
         html.tag('p',
-          language.encapsulate(capsule, 'listenOn', capsule =>
-            (relations.externalLinks
-              ? language.$(capsule, {
-                  links:
-                    language.formatDisjunctionList(
-                      relations.externalLinks
-                        .map(link => link.slot('context', 'track'))),
-                })
-              : language.$(capsule, 'noLinks', {
-                  name:
-                    html.tag('i', data.name),
-                })))),
+          relations.listenLine.slots({
+            visibleWithoutLinks: true,
+            context: ['track'],
+          })),
       ])),
 };
diff --git a/src/content/dependencies/linkAlbum.js b/src/content/dependencies/linkAlbum.js
index 36b0d13a..cdfe65d3 100644
--- a/src/content/dependencies/linkAlbum.js
+++ b/src/content/dependencies/linkAlbum.js
@@ -1,8 +1,12 @@
 export default {
-  contentDependencies: ['linkThing'],
+  contentDependencies: ['linkThing', 'linkTrack'],
 
-  relations: (relation, album) =>
-    ({link: relation('linkThing', 'localized.album', album)}),
+  relations: (relation, album) => ({
+    link:
+      (album.style === 'single'
+        ? relation('linkTrack', album.tracks[0])
+        : relation('linkThing', 'localized.album', album)),
+  }),
 
   generate: (relations) => relations.link,
 };
diff --git a/src/content/dependencies/linkAnythingMan.js b/src/content/dependencies/linkAnythingMan.js
index e408c1b2..10ce7762 100644
--- a/src/content/dependencies/linkAnythingMan.js
+++ b/src/content/dependencies/linkAnythingMan.js
@@ -6,19 +6,15 @@ export default {
     'linkTrack',
   ],
 
-  query: (thing) => ({
-    referenceType: thing.constructor[Symbol.for('Thing.referenceType')],
-  }),
-
-  relations: (relation, query, thing) => ({
+  relations: (relation, thing) => ({
     link:
-      (query.referenceType === 'album'
+      (thing.isAlbum
         ? relation('linkAlbum', thing)
-     : query.referenceType === 'artwork'
+     : thing.isArtwork
         ? relation('linkArtwork', thing)
-     : query.referenceType === 'flash'
+     : thing.isFlash
         ? relation('linkFlash', thing)
-     : query.referenceType === 'track'
+     : thing.isTrack
         ? relation('linkTrack', thing)
         : null),
   }),
diff --git a/src/content/dependencies/linkArtistRollingWindow.js b/src/content/dependencies/linkArtistRollingWindow.js
new file mode 100644
index 00000000..e94b8ec5
--- /dev/null
+++ b/src/content/dependencies/linkArtistRollingWindow.js
@@ -0,0 +1,8 @@
+export default {
+  contentDependencies: ['linkThing'],
+
+  relations: (relation, artist) =>
+    ({link: relation('linkThing', 'localized.artistRollingWindow', artist)}),
+
+  generate: (relations) => relations.link,
+};
diff --git a/src/content/dependencies/linkArtwork.js b/src/content/dependencies/linkArtwork.js
index 8cd6f359..c10150d1 100644
--- a/src/content/dependencies/linkArtwork.js
+++ b/src/content/dependencies/linkArtwork.js
@@ -1,16 +1,11 @@
 export default {
   contentDependencies: ['linkAlbum', 'linkTrack'],
 
-  query: (artwork) => ({
-    referenceType:
-      artwork.thing.constructor[Symbol.for('Thing.referenceType')],
-  }),
-
-  relations: (relation, query, artwork) => ({
+  relations: (relation, artwork) => ({
     link:
-      (query.referenceType === 'album'
+      (artwork.thing.isAlbum
         ? relation('linkAlbum', artwork.thing)
-     : query.referenceType === 'track'
+     : artwork.thing.isTrack
         ? relation('linkTrack', artwork.thing)
         : null),
   }),
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
index c658d461..1db0373b 100644
--- a/src/content/dependencies/linkContribution.js
+++ b/src/content/dependencies/linkContribution.js
@@ -30,7 +30,7 @@ export default {
 
     trimAnnotation: {type: 'boolean', default: false},
 
-    preventWrapping: {type: 'boolean', default: true},
+    preventWrapping: {type: 'boolean', default: false},
     preventTooltip: {type: 'boolean', default: false},
 
     chronologyKind: {type: 'string'},
diff --git a/src/content/dependencies/linkReferencedArtworks.js b/src/content/dependencies/linkReferencedArtworks.js
index c456b808..f73a2ad3 100644
--- a/src/content/dependencies/linkReferencedArtworks.js
+++ b/src/content/dependencies/linkReferencedArtworks.js
@@ -1,21 +1,14 @@
-import Thing from '#thing';
-
 export default {
   contentDependencies: [
     'linkAlbumReferencedArtworks',
     'linkTrackReferencedArtworks',
   ],
 
-  query: (artwork) => ({
-    referenceType:
-      artwork.thing.constructor[Thing.referenceType],
-  }),
-
-  relations: (relation, query, artwork) => ({
+  relations: (relation, artwork) => ({
     link:
-      (query.referenceType === 'album'
+      (artwork.thing.isAlbum
         ? relation('linkAlbumReferencedArtworks', artwork.thing)
-     : query.referenceType === 'track'
+     : artwork.thing.isTrack
         ? relation('linkTrackReferencedArtworks', artwork.thing)
         : null),
   }),
diff --git a/src/content/dependencies/linkReferencingArtworks.js b/src/content/dependencies/linkReferencingArtworks.js
index 0cfca4db..6927f230 100644
--- a/src/content/dependencies/linkReferencingArtworks.js
+++ b/src/content/dependencies/linkReferencingArtworks.js
@@ -1,21 +1,14 @@
-import Thing from '#thing';
-
 export default {
   contentDependencies: [
     'linkAlbumReferencingArtworks',
     'linkTrackReferencingArtworks',
   ],
 
-  query: (artwork) => ({
-    referenceType:
-      artwork.thing.constructor[Thing.referenceType],
-  }),
-
-  relations: (relation, query, artwork) => ({
+  relations: (relation, artwork) => ({
     link:
-      (query.referenceType === 'album'
+      (artwork.thing.isAlbum
         ? relation('linkAlbumReferencingArtworks', artwork.thing)
-     : query.referenceType === 'track'
+     : artwork.thing.isTrack
         ? relation('linkTrackReferencingArtworks', artwork.thing)
         : null),
   }),
diff --git a/src/content/dependencies/listAlbumsByDuration.js b/src/content/dependencies/listAlbumsByDuration.js
index c60685ab..c28fd800 100644
--- a/src/content/dependencies/listAlbumsByDuration.js
+++ b/src/content/dependencies/listAlbumsByDuration.js
@@ -11,8 +11,12 @@ export default {
   },
 
   query({albumData}, spec) {
-    const albums = sortAlphabetically(albumData.slice());
-    const durations = albums.map(album => getTotalDuration(album.tracks));
+    const albums =
+      sortAlphabetically(
+        albumData.filter(album => !album.hideDuration));
+
+    const durations =
+      albums.map(album => getTotalDuration(album.tracks));
 
     filterByCount(albums, durations);
     sortByCount(albums, durations, {greatestFirst: true});
diff --git a/src/content/dependencies/listAlbumsByTracks.js b/src/content/dependencies/listAlbumsByTracks.js
index 798e6c2e..1f20401c 100644
--- a/src/content/dependencies/listAlbumsByTracks.js
+++ b/src/content/dependencies/listAlbumsByTracks.js
@@ -10,13 +10,20 @@ export default {
   },
 
   query({albumData}, spec) {
-    const albums = sortAlphabetically(albumData.slice());
-    const counts = albums.map(album => album.tracks.length);
+    const albums =
+      sortAlphabetically(
+        albumData.filter(album => !album.hideDuration));
+
+    const counts =
+      albums.map(album => album.tracks.length);
 
     filterByCount(albums, counts);
     sortByCount(albums, counts, {greatestFirst: true});
 
-    return {spec, albums, counts};
+    const styles =
+      albums.map(album => album.style);
+
+    return {spec, albums, counts, styles};
   },
 
   relations(relation, query) {
@@ -32,6 +39,7 @@ export default {
   data(query) {
     return {
       counts: query.counts,
+      styles: query.styles,
     };
   },
 
@@ -42,10 +50,19 @@ export default {
         stitchArrays({
           link: relations.albumLinks,
           count: data.counts,
-        }).map(({link, count}) => ({
-            album: link,
-            tracks: language.countTracks(count, {unit: true}),
-          })),
+          style: data.styles,
+        }).map(({link, count, style}) => {
+            const row = {
+              album: link,
+              tracks: language.countTracks(count, {unit: true}),
+            };
+
+            if (style === 'single') {
+              row.stringsKey = 'single';
+            }
+
+            return row;
+          }),
     });
   },
 };
diff --git a/src/content/dependencies/listArtistsByContributions.js b/src/content/dependencies/listArtistsByContributions.js
index 41944959..99f19764 100644
--- a/src/content/dependencies/listArtistsByContributions.js
+++ b/src/content/dependencies/listArtistsByContributions.js
@@ -1,13 +1,6 @@
 import {sortAlphabetically, sortByCount} from '#sort';
-
-import {
-  accumulateSum,
-  empty,
-  filterByCount,
-  filterMultipleArrays,
-  stitchArrays,
-  unique,
-} from '#sugar';
+import {empty, filterByCount, filterMultipleArrays, stitchArrays}
+  from '#sugar';
 
 export default {
   contentDependencies: ['generateListingPage', 'linkArtist'],
@@ -41,37 +34,46 @@ export default {
       query[countsKey] = counts;
     };
 
+    const countContributions = (artist, keys) => {
+      const contribs =
+        keys
+          .flatMap(key => artist[key])
+          .filter(contrib => contrib.countInContributionTotals);
+
+      const things =
+        new Set(contribs.map(contrib => contrib.thing));
+
+      return things.size;
+    };
+
     queryContributionInfo(
       'artistsByTrackContributions',
       'countsByTrackContributions',
       artist =>
-        (unique(
-          ([
-            artist.trackArtistContributions,
-            artist.trackContributorContributions,
-          ]).flat()
-            .map(({thing}) => thing)
-        )).length);
+        countContributions(artist, [
+          'trackArtistContributions',
+          'trackContributorContributions',
+        ]));
 
     queryContributionInfo(
       'artistsByArtworkContributions',
       'countsByArtworkContributions',
       artist =>
-        accumulateSum(
-          [
-            artist.albumCoverArtistContributions,
-            artist.albumWallpaperArtistContributions,
-            artist.albumBannerArtistContributions,
-            artist.trackCoverArtistContributions,
-          ],
-          contribs => contribs.length));
+        countContributions(artist, [
+          'albumCoverArtistContributions',
+          'albumWallpaperArtistContributions',
+          'albumBannerArtistContributions',
+          'trackCoverArtistContributions',
+        ]));
 
     if (sprawl.enableFlashesAndGames) {
       queryContributionInfo(
         'artistsByFlashContributions',
         'countsByFlashContributions',
         artist =>
-          artist.flashContributorContributions.length);
+          countContributions(artist, [
+            'flashContributorContributions',
+          ]));
     }
 
     return query;
diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js
index 69ecf5a4..e9a75744 100644
--- a/src/content/dependencies/transformContent.js
+++ b/src/content/dependencies/transformContent.js
@@ -1,5 +1,6 @@
 import {basename} from 'node:path';
 
+import {logWarn} from '#cli';
 import {bindFind} from '#find';
 import {replacerSpec, parseContentNodes} from '#replacer';
 
@@ -62,20 +63,30 @@ export default {
       Object.values(replacerSpec)
         .map(description => description.link)
         .filter(Boolean)),
+
     'image',
     'generateTextWithTooltip',
     'generateTooltip',
     'linkExternal',
   ],
 
-  extraDependencies: ['html', 'language', 'to', 'wikiData'],
+  extraDependencies: [
+    'html',
+    'language',
+    'niceShowAggregate',
+    'to',
+    'wikiData',
+  ],
 
   sprawl(wikiData, content) {
-    const find = bindFind(wikiData);
+    const find = bindFind(wikiData, {mode: 'quiet'});
 
-    const parsedNodes = parseContentNodes(content ?? '');
+    const {result: parsedNodes, error} =
+      parseContentNodes(content ?? '', {errorMode: 'return'});
 
     return {
+      error,
+
       nodes: parsedNodes
         .map(node => {
           if (node.type !== 'tag') {
@@ -189,6 +200,9 @@ export default {
     return {
       content,
 
+      error:
+        sprawl.error,
+
       nodes:
         sprawl.nodes
           .map(node => {
@@ -301,7 +315,12 @@ export default {
     },
   },
 
-  generate(data, relations, slots, {html, language, to}) {
+  generate(data, relations, slots, {html, language, niceShowAggregate, to}) {
+    if (data.error) {
+      logWarn`Error in content text.`;
+      niceShowAggregate(data.error);
+    }
+
     let imageIndex = 0;
     let internalLinkIndex = 0;
     let externalLinkIndex = 0;
@@ -360,9 +379,8 @@ export default {
                   height && {height},
                   style && {style},
 
-                  align === 'center' &&
-                  !link &&
-                    {class: 'align-center'},
+                  align && !link &&
+                    {class: 'align-' + align},
 
                   pixelate &&
                     {class: 'pixelate'});
@@ -373,8 +391,8 @@ export default {
                     {href: link},
                     {target: '_blank'},
 
-                    align === 'center' &&
-                      {class: 'align-center'},
+                    align &&
+                      {class: 'align-' + align},
 
                     {title:
                       language.encapsulate('misc.external.opensInNewTab', capsule =>
@@ -424,8 +442,8 @@ export default {
               inline: false,
               data:
                 html.tag('div', {class: 'content-image-container'},
-                  align === 'center' &&
-                    {class: 'align-center'},
+                  align &&
+                    {class: 'align-' + align},
 
                   image),
             };
@@ -437,22 +455,31 @@ export default {
                 ? to('media.path', node.src.slice('media/'.length))
                 : node.src);
 
-            const {width, height, align, pixelate} = node;
+            const {width, height, align, inline, pixelate} = node;
 
-            const content =
-              html.tag('div', {class: 'content-video-container'},
-                align === 'center' &&
-                  {class: 'align-center'},
+            const video =
+              html.tag('video',
+                src && {src},
+                width && {width},
+                height && {height},
 
-                html.tag('video',
-                  src && {src},
-                  width && {width},
-                  height && {height},
+                {controls: true},
 
-                  {controls: true},
+                align && inline &&
+                  {class: 'align-' + align},
+
+                pixelate &&
+                  {class: 'pixelate'});
+
+            const content =
+              (inline
+                ? video
+                : html.tag('div', {class: 'content-video-container'},
+                    align &&
+                      {class: 'align-' + align},
+
+                    video));
 
-                  pixelate &&
-                    {class: 'pixelate'}));
 
             return {
               type: 'processed-video',
@@ -466,15 +493,14 @@ export default {
                 ? to('media.path', node.src.slice('media/'.length))
                 : node.src);
 
-            const {align, inline} = node;
+            const {align, inline, nameless} = node;
 
             const audio =
               html.tag('audio',
                 src && {src},
 
-                align === 'center' &&
-                inline &&
-                  {class: 'align-center'},
+                align && inline &&
+                  {class: 'align-' + align},
 
                 {controls: true});
 
@@ -482,13 +508,14 @@ export default {
               (inline
                 ? audio
                 : html.tag('div', {class: 'content-audio-container'},
-                    align === 'center' &&
-                      {class: 'align-center'},
+                    align &&
+                      {class: 'align-' + align},
 
                     [
-                      html.tag('a', {class: 'filename'},
-                        src && {href: src},
-                        language.sanitize(basename(node.src))),
+                      !nameless &&
+                        html.tag('a', {class: 'filename'},
+                          src && {href: src},
+                          language.sanitize(basename(node.src))),
 
                       audio,
                     ]));
diff --git a/src/data/checks.js b/src/data/checks.js
index afd2a04c..3fcb6d3b 100644
--- a/src/data/checks.js
+++ b/src/data/checks.js
@@ -192,6 +192,10 @@ export function filterReferenceErrors(wikiData, {
       directDescendantArtTags: 'artTag',
     }],
 
+    ['artworkData', {
+      referencedArtworks: '_artwork',
+    }],
+
     ['flashData', {
       commentary: '_content',
       creditingSources: '_content',
diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js
index dfc6864f..de1d37c3 100644
--- a/src/data/composite/things/album/index.js
+++ b/src/data/composite/things/album/index.js
@@ -1,2 +1,2 @@
-export {default as withHasCoverArt} from './withHasCoverArt.js';
+export {default as withCoverArtDate} from './withCoverArtDate.js';
 export {default as withTracks} from './withTracks.js';
diff --git a/src/data/composite/wiki-data/withCoverArtDate.js b/src/data/composite/things/album/withCoverArtDate.js
index a114d5ff..978f566a 100644
--- a/src/data/composite/wiki-data/withCoverArtDate.js
+++ b/src/data/composite/things/album/withCoverArtDate.js
@@ -2,8 +2,7 @@ import {input, templateCompositeFrom} from '#composite';
 import {isDate} from '#validators';
 
 import {raiseOutputWithoutDependency} from '#composite/control-flow';
-
-import withResolvedContribs from './withResolvedContribs.js';
+import {withHasArtwork} from '#composite/wiki-data';
 
 export default templateCompositeFrom({
   annotation: `withCoverArtDate`,
@@ -19,14 +18,14 @@ export default templateCompositeFrom({
   outputs: ['#coverArtDate'],
 
   steps: () => [
-    withResolvedContribs({
-      from: 'coverArtistContribs',
-      date: input.value(null),
+    withHasArtwork({
+      contribs: 'coverArtistContribs',
+      artworks: 'coverArtworks',
     }),
 
     raiseOutputWithoutDependency({
-      dependency: '#resolvedContribs',
-      mode: input.value('empty'),
+      dependency: '#hasArtwork',
+      mode: input.value('falsy'),
       output: input.value({'#coverArtDate': null}),
     }),
 
diff --git a/src/data/composite/things/artwork/index.js b/src/data/composite/things/artwork/index.js
index 3693c10f..b5e5e167 100644
--- a/src/data/composite/things/artwork/index.js
+++ b/src/data/composite/things/artwork/index.js
@@ -1,5 +1,7 @@
+export {default as withArtTags} from './withArtTags.js';
 export {default as withAttachedArtwork} from './withAttachedArtwork.js';
 export {default as withContainingArtworkList} from './withContainingArtworkList.js';
+export {default as withContentWarningArtTags} from './withContentWarningArtTags.js';
 export {default as withContribsFromAttachedArtwork} from './withContribsFromAttachedArtwork.js';
 export {default as withDate} from './withDate.js';
 export {default as withPropertyFromAttachedArtwork} from './withPropertyFromAttachedArtwork.js';
diff --git a/src/data/composite/things/artwork/withArtTags.js b/src/data/composite/things/artwork/withArtTags.js
new file mode 100644
index 00000000..1fed3c31
--- /dev/null
+++ b/src/data/composite/things/artwork/withArtTags.js
@@ -0,0 +1,99 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
+  from '#composite/control-flow';
+import {withPropertyFromObject} from '#composite/data';
+import {withResolvedReferenceList} from '#composite/wiki-data';
+import {soupyFind} from '#composite/wiki-properties';
+
+import withPropertyFromAttachedArtwork
+  from './withPropertyFromAttachedArtwork.js';
+
+export default templateCompositeFrom({
+  annotation: `withArtTags`,
+
+  inputs: {
+    from: input({
+      type: 'array',
+      acceptsNull: true,
+      defaultDependency: 'artTags',
+    }),
+  },
+
+  outputs: ['#artTags'],
+
+  steps: () => [
+    withResolvedReferenceList({
+      list: input('from'),
+      find: soupyFind.input('artTag'),
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#resolvedReferenceList',
+      mode: input.value('empty'),
+    }),
+
+    {
+      dependencies: ['#availability', '#resolvedReferenceList'],
+      compute: (continuation, {
+        ['#availability']: availability,
+        ['#resolvedReferenceList']: resolvedReferenceList,
+      }) =>
+        (availability
+          ? continuation.raiseOutput({
+              '#artTags': resolvedReferenceList,
+            })
+          : continuation()),
+    },
+
+    withPropertyFromAttachedArtwork({
+      property: input.value('artTags'),
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#attachedArtwork.artTags',
+      mode: input.value('empty'),
+    }),
+
+    {
+      dependencies: ['#availability', '#attachedArtwork.artTags'],
+      compute: (continuation, {
+        ['#availability']: availability,
+        ['#attachedArtwork.artTags']: attachedArtworkArtTags,
+      }) =>
+        (availability
+          ? continuation.raiseOutput({
+              '#artTags': attachedArtworkArtTags,
+            })
+          : continuation()),
+    },
+
+    raiseOutputWithoutDependency({
+      dependency: 'artTagsFromThingProperty',
+      output: input.value({'#artTags': []}),
+    }),
+
+    withPropertyFromObject({
+      object: 'thing',
+      property: 'artTagsFromThingProperty',
+    }).outputs({
+      ['#value']: '#thing.artTags',
+    }),
+
+    withResultOfAvailabilityCheck({
+      from: '#thing.artTags',
+      mode: input.value('empty'),
+    }),
+
+    {
+      dependencies: ['#availability', '#thing.artTags'],
+      compute: (continuation, {
+        ['#availability']: availability,
+        ['#thing.artTags']: thingArtTags,
+      }) =>
+        (availability
+          ? continuation({'#artTags': thingArtTags})
+          : continuation({'#artTags': []})),
+    },
+  ],
+});
diff --git a/src/data/composite/things/artwork/withContentWarningArtTags.js b/src/data/composite/things/artwork/withContentWarningArtTags.js
new file mode 100644
index 00000000..4c07e837
--- /dev/null
+++ b/src/data/composite/things/artwork/withContentWarningArtTags.js
@@ -0,0 +1,27 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {withFilteredList, withPropertyFromList} from '#composite/data';
+
+import withArtTags from './withArtTags.js';
+
+export default templateCompositeFrom({
+  annotation: `withContentWarningArtTags`,
+
+  outputs: ['#contentWarningArtTags'],
+
+  steps: () => [
+    withArtTags(),
+
+    withPropertyFromList({
+      list: '#artTags',
+      property: input.value('isContentWarning'),
+    }),
+
+    withFilteredList({
+      list: '#artTags',
+      filter: '#artTags.isContentWarning',
+    }).outputs({
+      '#filteredList': '#contentWarningArtTags',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/contribution/index.js b/src/data/composite/things/contribution/index.js
index 9b22be2e..31d86b8b 100644
--- a/src/data/composite/things/contribution/index.js
+++ b/src/data/composite/things/contribution/index.js
@@ -1,6 +1,4 @@
 export {default as inheritFromContributionPresets} from './inheritFromContributionPresets.js';
-export {default as thingPropertyMatches} from './thingPropertyMatches.js';
-export {default as thingReferenceTypeMatches} from './thingReferenceTypeMatches.js';
 export {default as withContainingReverseContributionList} from './withContainingReverseContributionList.js';
 export {default as withContributionArtist} from './withContributionArtist.js';
 export {default as withContributionContext} from './withContributionContext.js';
diff --git a/src/data/composite/things/contribution/thingPropertyMatches.js b/src/data/composite/things/contribution/thingPropertyMatches.js
deleted file mode 100644
index a678c3f5..00000000
--- a/src/data/composite/things/contribution/thingPropertyMatches.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import {exitWithoutDependency} from '#composite/control-flow';
-
-export default templateCompositeFrom({
-  annotation: `thingPropertyMatches`,
-
-  compose: false,
-
-  inputs: {
-    value: input({type: 'string'}),
-  },
-
-  steps: () => [
-    {
-      dependencies: ['thing', 'thingProperty'],
-
-      compute: (continuation, {thing, thingProperty}) =>
-        continuation({
-          ['#thingProperty']:
-            (thing.constructor[Symbol.for('Thing.referenceType')] === 'artwork'
-              ? thing.artistContribsFromThingProperty
-              : thingProperty),
-        }),
-    },
-
-    exitWithoutDependency({
-      dependency: '#thingProperty',
-      value: input.value(false),
-    }),
-
-    {
-      dependencies: [
-        '#thingProperty',
-        input('value'),
-      ],
-
-      compute: ({
-        ['#thingProperty']: thingProperty,
-        [input('value')]: value,
-      }) =>
-        thingProperty === value,
-    },
-  ],
-});
diff --git a/src/data/composite/things/contribution/thingReferenceTypeMatches.js b/src/data/composite/things/contribution/thingReferenceTypeMatches.js
deleted file mode 100644
index 4042e78f..00000000
--- a/src/data/composite/things/contribution/thingReferenceTypeMatches.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import {exitWithoutDependency} from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
-
-export default templateCompositeFrom({
-  annotation: `thingReferenceTypeMatches`,
-
-  compose: false,
-
-  inputs: {
-    value: input({type: 'string'}),
-  },
-
-  steps: () => [
-    exitWithoutDependency({
-      dependency: 'thing',
-      value: input.value(false),
-    }),
-
-    withPropertyFromObject({
-      object: 'thing',
-      property: input.value('constructor'),
-    }),
-
-    {
-      dependencies: [
-        '#thing.constructor',
-        input('value'),
-      ],
-
-      compute: (continuation, {
-        ['#thing.constructor']: constructor,
-        [input('value')]: value,
-      }) =>
-        (constructor[Symbol.for('Thing.referenceType')] === value
-          ? continuation.exit(true)
-       : constructor[Symbol.for('Thing.referenceType')] === 'artwork'
-          ? continuation()
-          : continuation.exit(false)),
-    },
-
-    withPropertyFromObject({
-      object: 'thing',
-      property: input.value('thing'),
-    }),
-
-    withPropertyFromObject({
-      object: '#thing.thing',
-      property: input.value('constructor'),
-    }),
-
-    {
-      dependencies: [
-        '#thing.thing.constructor',
-        input('value'),
-      ],
-
-      compute: ({
-        ['#thing.thing.constructor']: constructor,
-        [input('value')]: value,
-      }) =>
-        constructor[Symbol.for('Thing.referenceType')] === value,
-    },
-  ],
-});
diff --git a/src/data/composite/wiki-data/exitWithoutArtwork.js b/src/data/composite/wiki-data/exitWithoutArtwork.js
new file mode 100644
index 00000000..8e799fda
--- /dev/null
+++ b/src/data/composite/wiki-data/exitWithoutArtwork.js
@@ -0,0 +1,45 @@
+import {input, templateCompositeFrom} from '#composite';
+import {isContributionList, isThing, strictArrayOf} from '#validators';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+
+import withHasArtwork from './withHasArtwork.js';
+
+export default templateCompositeFrom({
+  annotation: `exitWithoutArtwork`,
+
+  inputs: {
+    contribs: input({
+      validate: isContributionList,
+      defaultValue: null,
+    }),
+
+    artwork: input({
+      validate: isThing,
+      defaultValue: null,
+    }),
+
+    artworks: input({
+      validate: strictArrayOf(isThing),
+      defaultValue: null,
+    }),
+
+    value: input({
+      defaultValue: null,
+    }),
+  },
+
+  steps: () => [
+    withHasArtwork({
+      contribs: input('contribs'),
+      artwork: input('artwork'),
+      artworks: input('artworks'),
+    }),
+
+    exitWithoutDependency({
+      dependency: '#hasArtwork',
+      mode: input.value('falsy'),
+      value: input('value'),
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js
index 38afc2ac..3206575b 100644
--- a/src/data/composite/wiki-data/index.js
+++ b/src/data/composite/wiki-data/index.js
@@ -5,6 +5,7 @@
 //
 
 export {default as exitWithoutContribs} from './exitWithoutContribs.js';
+export {default as exitWithoutArtwork} from './exitWithoutArtwork.js';
 export {default as gobbleSoupyFind} from './gobbleSoupyFind.js';
 export {default as gobbleSoupyReverse} from './gobbleSoupyReverse.js';
 export {default as inputNotFoundMode} from './inputNotFoundMode.js';
@@ -16,8 +17,8 @@ export {default as withClonedThings} from './withClonedThings.js';
 export {default as withConstitutedArtwork} from './withConstitutedArtwork.js';
 export {default as withContentNodes} from './withContentNodes.js';
 export {default as withContributionListSums} from './withContributionListSums.js';
-export {default as withCoverArtDate} from './withCoverArtDate.js';
 export {default as withDirectory} from './withDirectory.js';
+export {default as withHasArtwork} from './withHasArtwork.js';
 export {default as withRecontextualizedContributionList} from './withRecontextualizedContributionList.js';
 export {default as withRedatedContributionList} from './withRedatedContributionList.js';
 export {default as withResolvedAnnotatedReferenceList} from './withResolvedAnnotatedReferenceList.js';
diff --git a/src/data/composite/things/album/withHasCoverArt.js b/src/data/composite/wiki-data/withHasArtwork.js
index fd3f2894..9c22f439 100644
--- a/src/data/composite/things/album/withHasCoverArt.js
+++ b/src/data/composite/wiki-data/withHasArtwork.js
@@ -1,7 +1,5 @@
-// TODO: This shouldn't be coded as an Album-specific thing,
-// or even really to do with cover artworks in particular, either.
-
 import {input, templateCompositeFrom} from '#composite';
+import {isContributionList, isThing, strictArrayOf} from '#validators';
 
 import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
   from '#composite/control-flow';
@@ -9,13 +7,30 @@ import {fillMissingListItems, withFlattenedList, withPropertyFromList}
   from '#composite/data';
 
 export default templateCompositeFrom({
-  annotation: 'withHasCoverArt',
+  annotation: 'withHasArtwork',
+
+  inputs: {
+    contribs: input({
+      validate: isContributionList,
+      defaultValue: null,
+    }),
+
+    artwork: input({
+      validate: isThing,
+      defaultValue: null,
+    }),
+
+    artworks: input({
+      validate: strictArrayOf(isThing),
+      defaultValue: null,
+    }),
+  },
 
-  outputs: ['#hasCoverArt'],
+  outputs: ['#hasArtwork'],
 
   steps: () => [
     withResultOfAvailabilityCheck({
-      from: 'coverArtistContribs',
+      from: input('contribs'),
       mode: input.value('empty'),
     }),
 
@@ -26,19 +41,37 @@ export default templateCompositeFrom({
       }) =>
         (availability
           ? continuation.raiseOutput({
-              ['#hasCoverArt']: true,
+              ['#hasArtwork']: true,
             })
           : continuation()),
     },
 
+    {
+      dependencies: [input('artwork'), input('artworks')],
+      compute: (continuation, {
+        [input('artwork')]: artwork,
+        [input('artworks')]: artworks,
+      }) =>
+        continuation({
+          ['#artworks']:
+            (artwork && artworks
+              ? [artwork, ...artworks]
+           : artwork
+              ? [artwork]
+           : artworks
+              ? artworks
+              : []),
+        }),
+    },
+
     raiseOutputWithoutDependency({
-      dependency: 'coverArtworks',
+      dependency: '#artworks',
       mode: input.value('empty'),
-      output: input.value({'#hasCoverArt': false}),
+      output: input.value({'#hasArtwork': false}),
     }),
 
     withPropertyFromList({
-      list: 'coverArtworks',
+      list: '#artworks',
       property: input.value('artistContribs'),
       internal: input.value(true),
     }),
@@ -46,19 +79,19 @@ export default templateCompositeFrom({
     // Since we're getting the update value for each artwork's artistContribs,
     // it may not be set at all, and in that case won't be exposing as [].
     fillMissingListItems({
-      list: '#coverArtworks.artistContribs',
+      list: '#artworks.artistContribs',
       fill: input.value([]),
     }),
 
     withFlattenedList({
-      list: '#coverArtworks.artistContribs',
+      list: '#artworks.artistContribs',
     }),
 
     withResultOfAvailabilityCheck({
       from: '#flattenedList',
       mode: input.value('empty'),
     }).outputs({
-      '#availability': '#hasCoverArt',
+      '#availability': '#hasArtwork',
     }),
   ],
 });
diff --git a/src/data/things/additional-file.js b/src/data/things/additional-file.js
index 398d0af5..b15f62e0 100644
--- a/src/data/things/additional-file.js
+++ b/src/data/things/additional-file.js
@@ -2,10 +2,9 @@ import {input} from '#composite';
 import Thing from '#thing';
 import {isString, validateArrayItems} from '#validators';
 
-import {contentString, simpleString, thing} from '#composite/wiki-properties';
-
 import {exposeConstant, exposeUpdateValueOrContinue}
   from '#composite/control-flow';
+import {contentString, simpleString, thing} from '#composite/wiki-properties';
 
 export class AdditionalFile extends Thing {
   static [Thing.getPropertyDescriptors] = () => ({
@@ -26,6 +25,14 @@ export class AdditionalFile extends Thing {
         value: input.value([]),
       }),
     ],
+
+    // Expose only
+
+    isAdditionalFile: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
diff --git a/src/data/things/additional-name.js b/src/data/things/additional-name.js
index 4c23f291..99f3ee46 100644
--- a/src/data/things/additional-name.js
+++ b/src/data/things/additional-name.js
@@ -1,5 +1,7 @@
+import {input} from '#composite';
 import Thing from '#thing';
 
+import {exposeConstant} from '#composite/control-flow';
 import {contentString, thing} from '#composite/wiki-properties';
 
 export class AdditionalName extends Thing {
@@ -10,6 +12,14 @@ export class AdditionalName extends Thing {
 
     name: contentString(),
     annotation: contentString(),
+
+    // Expose only
+
+    isAdditionalName: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
diff --git a/src/data/things/album.js b/src/data/things/album.js
index af42c6fa..427c5d7f 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -10,7 +10,8 @@ import {traverse} from '#node-utils';
 import {sortAlbumsTracksChronologically, sortChronologically} from '#sort';
 import {empty} from '#sugar';
 import Thing from '#thing';
-import {isColor, isDate, isDirectory, isNumber} from '#validators';
+import {is, isColor, isContributionList, isDate, isDirectory, isNumber}
+  from '#validators';
 
 import {
   parseAdditionalFiles,
@@ -25,12 +26,22 @@ import {
   parseWallpaperParts,
 } from '#yaml';
 
-import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
-  from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
 
-import {exitWithoutContribs, withDirectory, withCoverArtDate}
-  from '#composite/wiki-data';
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
+  exitWithoutArtwork,
+  withDirectory,
+  withHasArtwork,
+  withResolvedContribs,
+} from '#composite/wiki-data';
 
 import {
   color,
@@ -58,7 +69,7 @@ import {
   wikiData,
 } from '#composite/wiki-properties';
 
-import {withHasCoverArt, withTracks} from '#composite/things/album';
+import {withCoverArtDate, withTracks} from '#composite/things/album';
 import {withAlbum, withContinueCountingFrom, withStartCountingFrom}
   from '#composite/things/track-section';
 
@@ -76,7 +87,13 @@ export class Album extends Thing {
     TrackSection,
     WikiInfo,
   }) => ({
-    // Update & expose
+    // > Update & expose - Internal relationships
+
+    trackSections: thingList({
+      class: input.value(TrackSection),
+    }),
+
+    // > Update & expose - Identifying metadata
 
     name: name('Unnamed Album'),
     directory: directory(),
@@ -97,20 +114,105 @@ export class Album extends Thing {
     alwaysReferenceTracksByDirectory: flag(false),
     suffixTrackDirectories: flag(false),
 
-    color: color(),
-    urls: urls(),
+    style: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(is(...[
+          'album',
+          'single',
+        ])),
+      }),
 
-    additionalNames: thingList({
-      class: input.value(AdditionalName),
-    }),
+      exposeConstant({
+        value: input.value('album'),
+      }),
+    ],
 
     bandcampAlbumIdentifier: simpleString(),
     bandcampArtworkIdentifier: simpleString(),
 
+    additionalNames: thingList({
+      class: input.value(AdditionalName),
+    }),
+
     date: simpleDate(),
-    trackArtDate: simpleDate(),
     dateAddedToWiki: simpleDate(),
 
+    // > Update & expose - Credits and contributors
+
+    artistContribs: contributionList({
+      date: 'date',
+      artistProperty: input.value('albumArtistContributions'),
+    }),
+
+    trackArtistContribs: [
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('albumTrackArtistContributions'),
+        date: 'date',
+      }).outputs({
+        '#resolvedContribs': '#trackArtistContribs',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#trackArtistContribs',
+        mode: input.value('empty'),
+      }),
+
+      withResolvedContribs({
+        from: 'artistContribs',
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('albumTrackArtistContributions'),
+        date: 'date',
+      }).outputs({
+        '#resolvedContribs': '#trackArtistContribs',
+      }),
+
+      exposeDependency({dependency: '#trackArtistContribs'}),
+    ],
+
+    // > Update & expose - General configuration
+
+    countTracksInArtistTotals: flag(true),
+
+    hasTrackNumbers: flag(true),
+    isListedOnHomepage: flag(true),
+    isListedInGalleries: flag(true),
+
+    hideDuration: flag(false),
+
+    // > Update & expose - General metadata
+
+    color: color(),
+
+    urls: urls(),
+
+    // > Update & expose - Artworks
+
+    coverArtworks: [
+      // This works, lol, because this array describes `expose.transform` for
+      // the coverArtworks property, and compositions generally access the
+      // update value, not what's exposed by property access out in the open.
+      // There's no recursion going on here.
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+        value: input.value([]),
+      }),
+
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Cover Artwork'),
+    ],
+
+    coverArtistContribs: [
+      withCoverArtDate(),
+
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumCoverArtistContributions'),
+      }),
+    ],
+
     coverArtDate: [
       withCoverArtDate({
         from: input.updateValue({
@@ -122,52 +224,61 @@ export class Album extends Thing {
     ],
 
     coverArtFileExtension: [
-      exitWithoutContribs({contribs: 'coverArtistContribs'}),
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+      }),
+
       fileExtension('jpg'),
     ],
 
-    trackCoverArtFileExtension: fileExtension('jpg'),
+    coverArtDimensions: [
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+      }),
 
-    wallpaperFileExtension: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
-      fileExtension('jpg'),
+      dimensions(),
     ],
 
-    bannerFileExtension: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      fileExtension('jpg'),
-    ],
+    artTags: [
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+        value: input.value([]),
+      }),
 
-    wallpaperStyle: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
-      simpleString(),
+      referenceList({
+        class: input.value(ArtTag),
+        find: soupyFind.input('artTag'),
+      }),
     ],
 
-    wallpaperParts: [
-      exitWithoutContribs({
-        contribs: 'wallpaperArtistContribs',
+    referencedArtworks: [
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
         value: input.value([]),
       }),
 
-      wallpaperParts(),
+      referencedArtworkList(),
     ],
 
-    bannerStyle: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      simpleString(),
-    ],
+    trackCoverArtistContribs: contributionList({
+      // May be null, indicating cover art was added for tracks on the date
+      // each track specifies, or else the track's own release date.
+      date: 'trackArtDate',
 
-    coverArtDimensions: [
-      exitWithoutContribs({contribs: 'coverArtistContribs'}),
-      dimensions(),
-    ],
+      // This is the "correct" value, but it gets overwritten - with the same
+      // value - regardless.
+      artistProperty: input.value('trackCoverArtistContributions'),
+    }),
 
-    trackDimensions: dimensions(),
+    trackArtDate: simpleDate(),
 
-    bannerDimensions: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      dimensions(),
-    ],
+    trackCoverArtFileExtension: fileExtension('jpg'),
+
+    trackDimensions: dimensions(),
 
     wallpaperArtwork: [
       exitWithoutDependency({
@@ -180,119 +291,115 @@ export class Album extends Thing {
         .call(this, 'Wallpaper Artwork'),
     ],
 
-    bannerArtwork: [
-      exitWithoutDependency({
-        dependency: 'bannerArtistContribs',
-        mode: input.value('empty'),
-        value: input.value(null),
-      }),
+    wallpaperArtistContribs: [
+      withCoverArtDate(),
 
-      constitutibleArtwork.fromYAMLFieldSpec
-        .call(this, 'Banner Artwork'),
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumWallpaperArtistContributions'),
+      }),
     ],
 
-    coverArtworks: [
-      withHasCoverArt(),
-
-      exitWithoutDependency({
-        dependency: '#hasCoverArt',
-        mode: input.value('falsy'),
-        value: input.value([]),
+    wallpaperFileExtension: [
+      exitWithoutArtwork({
+        contribs: 'wallpaperArtistContribs',
+        artwork: 'wallpaperArtwork',
       }),
 
-      constitutibleArtworkList.fromYAMLFieldSpec
-        .call(this, 'Cover Artwork'),
+      fileExtension('jpg'),
     ],
 
-    hasTrackNumbers: flag(true),
-    isListedOnHomepage: flag(true),
-    isListedInGalleries: flag(true),
+    wallpaperStyle: [
+      exitWithoutArtwork({
+        contribs: 'wallpaperArtistContribs',
+        artwork: 'wallpaperArtwork',
+      }),
 
-    commentary: thingList({
-      class: input.value(CommentaryEntry),
-    }),
+      simpleString(),
+    ],
 
-    creditingSources: thingList({
-      class: input.value(CreditingSourcesEntry),
-    }),
+    wallpaperParts: [
+      // kinda nonsensical or at least unlikely lol, but y'know
+      exitWithoutArtwork({
+        contribs: 'wallpaperArtistContribs',
+        artwork: 'wallpaperArtwork',
+        value: input.value([]),
+      }),
 
-    additionalFiles: thingList({
-      class: input.value(AdditionalFile),
-    }),
+      wallpaperParts(),
+    ],
 
-    trackSections: thingList({
-      class: input.value(TrackSection),
-    }),
+    bannerArtwork: [
+      exitWithoutDependency({
+        dependency: 'bannerArtistContribs',
+        mode: input.value('empty'),
+        value: input.value(null),
+      }),
 
-    artistContribs: contributionList({
-      date: 'date',
-      artistProperty: input.value('albumArtistContributions'),
-    }),
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Banner Artwork'),
+    ],
 
-    coverArtistContribs: [
+    bannerArtistContribs: [
       withCoverArtDate(),
 
       contributionList({
         date: '#coverArtDate',
-        artistProperty: input.value('albumCoverArtistContributions'),
+        artistProperty: input.value('albumBannerArtistContributions'),
       }),
     ],
 
-    trackCoverArtistContribs: contributionList({
-      // May be null, indicating cover art was added for tracks on the date
-      // each track specifies, or else the track's own release date.
-      date: 'trackArtDate',
-
-      // This is the "correct" value, but it gets overwritten - with the same
-      // value - regardless.
-      artistProperty: input.value('trackCoverArtistContributions'),
-    }),
+    bannerFileExtension: [
+      exitWithoutArtwork({
+        contribs: 'bannerArtistContribs',
+        artwork: 'bannerArtwork',
+      }),
 
-    wallpaperArtistContribs: [
-      withCoverArtDate(),
+      fileExtension('jpg'),
+    ],
 
-      contributionList({
-        date: '#coverArtDate',
-        artistProperty: input.value('albumWallpaperArtistContributions'),
+    bannerDimensions: [
+      exitWithoutArtwork({
+        contribs: 'bannerArtistContribs',
+        artwork: 'bannerArtwork',
       }),
-    ],
 
-    bannerArtistContribs: [
-      withCoverArtDate(),
+      dimensions(),
+    ],
 
-      contributionList({
-        date: '#coverArtDate',
-        artistProperty: input.value('albumBannerArtistContributions'),
+    bannerStyle: [
+      exitWithoutArtwork({
+        contribs: 'bannerArtistContribs',
+        artwork: 'bannerArtwork',
       }),
+
+      simpleString(),
     ],
 
+    // > Update & expose - Groups
+
     groups: referenceList({
       class: input.value(Group),
       find: soupyFind.input('group'),
     }),
 
-    artTags: [
-      exitWithoutContribs({
-        contribs: 'coverArtistContribs',
-        value: input.value([]),
-      }),
+    // > Update & expose - Content entries
 
-      referenceList({
-        class: input.value(ArtTag),
-        find: soupyFind.input('artTag'),
-      }),
-    ],
+    commentary: thingList({
+      class: input.value(CommentaryEntry),
+    }),
 
-    referencedArtworks: [
-      exitWithoutContribs({
-        contribs: 'coverArtistContribs',
-        value: input.value([]),
-      }),
+    creditingSources: thingList({
+      class: input.value(CreditingSourcesEntry),
+    }),
 
-      referencedArtworkList(),
-    ],
+    // > Update & expose - Additional files
 
-    // Update only
+    additionalFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
+
+    // > Update only
 
     find: soupyFind(),
     reverse: soupyReverse(),
@@ -307,13 +414,23 @@ export class Album extends Thing {
       class: input.value(WikiInfo),
     }),
 
-    // Expose only
+    // > Expose only
+
+    isAlbum: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
 
     commentatorArtists: commentatorArtists(),
 
     hasCoverArt: [
-      withHasCoverArt(),
-      exposeDependency({dependency: '#hasCoverArt'}),
+      withHasArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+      }),
+
+      exposeDependency({dependency: '#hasArtwork'}),
     ],
 
     hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}),
@@ -457,6 +574,9 @@ export class Album extends Thing {
     albumArtistContributionsBy:
       soupyReverse.contributionsBy('albumData', 'artistContribs'),
 
+    albumTrackArtistContributionsBy:
+      soupyReverse.contributionsBy('albumData', 'trackArtistContribs'),
+
     albumCoverArtistContributionsBy:
       soupyReverse.artworkContributionsBy('albumData', 'coverArtworks'),
 
@@ -476,21 +596,15 @@ export class Album extends Thing {
 
   static [Thing.yamlDocumentSpec] = {
     fields: {
-      'Album': {property: 'name'},
+      // Identifying metadata
 
+      'Album': {property: 'name'},
       'Directory': {property: 'directory'},
       'Directory Suffix': {property: 'directorySuffix'},
       'Suffix Track Directories': {property: 'suffixTrackDirectories'},
-
       'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
-      'Always Reference Tracks By Directory': {
-        property: 'alwaysReferenceTracksByDirectory',
-      },
-
-      'Additional Names': {
-        property: 'additionalNames',
-        transform: parseAdditionalNames,
-      },
+      'Always Reference Tracks By Directory': {property: 'alwaysReferenceTracksByDirectory'},
+      'Style': {property: 'style'},
 
       'Bandcamp Album ID': {
         property: 'bandcampAlbumIdentifier',
@@ -502,18 +616,53 @@ export class Album extends Thing {
         transform: String,
       },
 
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
+
       'Date': {
         property: 'date',
         transform: parseDate,
       },
 
-      'Color': {property: 'color'},
-      'URLs': {property: 'urls'},
+      'Date Added': {
+        property: 'dateAddedToWiki',
+        transform: parseDate,
+      },
+
+      // Credits and contributors
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      'Track Artists': {
+        property: 'trackArtistContribs',
+        transform: parseContributors,
+      },
+
+      // General configuration
+
+      'Count Tracks In Artist Totals': {property: 'countTracksInArtistTotals'},
 
       'Has Track Numbers': {property: 'hasTrackNumbers'},
       'Listed on Homepage': {property: 'isListedOnHomepage'},
       'Listed in Galleries': {property: 'isListedInGalleries'},
 
+      'Hide Duration': {property: 'hideDuration'},
+
+      // General metadata
+
+      'Color': {property: 'color'},
+
+      'URLs': {property: 'urls'},
+
+      // Artworks
+      //  (Note - this YAML section is deliberately ordered differently
+      //   than the corresponding property descriptors.)
+
       'Cover Artwork': {
         property: 'coverArtworks',
         transform:
@@ -557,27 +706,29 @@ export class Album extends Thing {
           }),
       },
 
+      'Cover Artists': {
+        property: 'coverArtistContribs',
+        transform: parseContributors,
+      },
+
       'Cover Art Date': {
         property: 'coverArtDate',
         transform: parseDate,
       },
 
-      'Default Track Cover Art Date': {
-        property: 'trackArtDate',
-        transform: parseDate,
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
       },
 
-      'Date Added': {
-        property: 'dateAddedToWiki',
-        transform: parseDate,
+      'Default Track Cover Artists': {
+        property: 'trackCoverArtistContribs',
+        transform: parseContributors,
       },
 
-      'Cover Art File Extension': {property: 'coverArtFileExtension'},
-      'Track Art File Extension': {property: 'trackCoverArtFileExtension'},
-
-      'Cover Art Dimensions': {
-        property: 'coverArtDimensions',
-        transform: parseDimensions,
+      'Default Track Cover Art Date': {
+        property: 'trackArtDate',
+        transform: parseDate,
       },
 
       'Default Track Dimensions': {
@@ -590,8 +741,6 @@ export class Album extends Thing {
         transform: parseContributors,
       },
 
-      'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
-
       'Wallpaper Style': {property: 'wallpaperStyle'},
 
       'Wallpaper Parts': {
@@ -604,14 +753,31 @@ export class Album extends Thing {
         transform: parseContributors,
       },
 
-      'Banner Style': {property: 'bannerStyle'},
-      'Banner File Extension': {property: 'bannerFileExtension'},
-
       'Banner Dimensions': {
         property: 'bannerDimensions',
         transform: parseDimensions,
       },
 
+      'Banner Style': {property: 'bannerStyle'},
+
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
+      'Track Art File Extension': {property: 'trackCoverArtFileExtension'},
+      'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
+      'Banner File Extension': {property: 'bannerFileExtension'},
+
+      'Art Tags': {property: 'artTags'},
+
+      'Referenced Artworks': {
+        property: 'referencedArtworks',
+        transform: parseAnnotatedReferences,
+      },
+
+      // Groups
+
+      'Groups': {property: 'groups'},
+
+      // Content entries
+
       'Commentary': {
         property: 'commentary',
         transform: parseCommentary,
@@ -622,40 +788,40 @@ export class Album extends Thing {
         transform: parseCreditingSources,
       },
 
+      // Additional files
+
       'Additional Files': {
         property: 'additionalFiles',
         transform: parseAdditionalFiles,
       },
 
-      'Referenced Artworks': {
-        property: 'referencedArtworks',
-        transform: parseAnnotatedReferences,
-      },
+      // Shenanigans
 
       'Franchises': {ignore: true},
+      'Review Points': {ignore: true},
+    },
 
-      'Artists': {
-        property: 'artistContribs',
-        transform: parseContributors,
-      },
-
-      'Cover Artists': {
-        property: 'coverArtistContribs',
-        transform: parseContributors,
-      },
+    invalidFieldCombinations: [
+      {message: `Move commentary on singles to the track`, fields: [
+        ['Style', 'single'],
+        'Commentary',
+      ]},
 
-      'Default Track Cover Artists': {
-        property: 'trackCoverArtistContribs',
-        transform: parseContributors,
-      },
+      {message: `Move crediting sources on singles to the track`, fields: [
+        ['Style', 'single'],
+        'Crediting Sources',
+      ]},
 
-      'Groups': {property: 'groups'},
-      'Art Tags': {property: 'artTags'},
+      {message: `Move referencing sources on singles to the track`, fields: [
+        ['Style', 'single'],
+        'Referencing Sources',
+      ]},
 
-      'Review Points': {ignore: true},
-    },
+      {message: `Move additional names on singles to the track`, fields: [
+        ['Style', 'single'],
+        'Additional Names',
+      ]},
 
-    invalidFieldCombinations: [
       {message: `Specify one wallpaper style or multiple wallpaper parts, not both`, fields: [
         'Wallpaper Parts',
         'Wallpaper Style',
@@ -835,6 +1001,12 @@ export class Album extends Thing {
       artwork.fileExtension,
     ];
   }
+
+  // As of writing, albums don't even have a `duration` property...
+  // so this function will never be called... but the message stands...
+  countOwnContributionInDurationTotals(_contrib) {
+    return false;
+  }
 }
 
 export class TrackSection extends Thing {
@@ -892,6 +1064,12 @@ export class TrackSection extends Thing {
 
     // Expose only
 
+    isTrackSection: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     directory: [
       withAlbum(),
 
diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js
index 518f616b..fff724cb 100644
--- a/src/data/things/art-tag.js
+++ b/src/data/things/art-tag.js
@@ -1,14 +1,23 @@
+export const DATA_ART_TAGS_DIRECTORY = 'art-tags';
 export const ART_TAG_DATA_FILE = 'tags.yaml';
 
+import {readFile} from 'node:fs/promises';
+import * as path from 'node:path';
+
 import {input} from '#composite';
+import {traverse} from '#node-utils';
 import {sortAlphabetically} from '#sort';
 import Thing from '#thing';
 import {unique} from '#sugar';
 import {isName} from '#validators';
 import {parseAdditionalNames, parseAnnotatedReferences} from '#yaml';
 
-import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
-  from '#composite/control-flow';
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
 
 import {
   annotatedReferenceList,
@@ -79,6 +88,12 @@ export class ArtTag extends Thing {
 
     // Expose only
 
+    isArtTag: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     descriptionShort: [
       exitWithoutDependency({
         dependency: 'description',
@@ -174,13 +189,25 @@ export class ArtTag extends Thing {
   };
 
   static [Thing.getYamlLoadingSpec] = ({
-    documentModes: {allInOne},
+    documentModes: {allTogether},
     thingConstructors: {ArtTag},
   }) => ({
     title: `Process art tags file`,
-    file: ART_TAG_DATA_FILE,
 
-    documentMode: allInOne,
+    files: dataPath =>
+      Promise.allSettled([
+        readFile(path.join(dataPath, ART_TAG_DATA_FILE))
+          .then(() => [ART_TAG_DATA_FILE]),
+
+        traverse(path.join(dataPath, DATA_ART_TAGS_DIRECTORY), {
+          filterFile: name => path.extname(name) === '.yaml',
+          prefixPath: DATA_ART_TAGS_DIRECTORY,
+        }),
+      ]).then(results => results
+          .filter(({status}) => status === 'fulfilled')
+          .flatMap(({value}) => value)),
+
+    documentMode: allTogether,
     documentThing: ArtTag,
 
     save: (results) => ({artTagData: results}),
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 5b67051c..2905d893 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -5,14 +5,21 @@ import {inspect} from 'node:util';
 import CacheableObject from '#cacheable-object';
 import {colors} from '#cli';
 import {input} from '#composite';
-import {sortAlphabetically} from '#sort';
 import {stitchArrays} from '#sugar';
 import Thing from '#thing';
 import {isName, validateArrayItems} from '#validators';
 import {getKebabCase} from '#wiki-data';
 import {parseArtwork} from '#yaml';
 
-import {exitWithoutDependency} from '#composite/control-flow';
+import {
+  sortAlbumsTracksChronologically,
+  sortArtworksChronologically,
+  sortAlphabetically,
+  sortContributionsChronologically,
+} from '#sort';
+
+import {exitWithoutDependency, exposeConstant} from '#composite/control-flow';
+import {withReverseReferenceList} from '#composite/wiki-data';
 
 import {
   constitutibleArtwork,
@@ -76,6 +83,12 @@ export class Artist extends Thing {
 
     // Expose only
 
+    isArtist: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     trackArtistContributions: reverseReferenceList({
       reverse: soupyReverse.input('trackArtistContributionsBy'),
     }),
@@ -96,6 +109,10 @@ export class Artist extends Thing {
       reverse: soupyReverse.input('albumArtistContributionsBy'),
     }),
 
+    albumTrackArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('albumTrackArtistContributionsBy'),
+    }),
+
     albumCoverArtistContributions: reverseReferenceList({
       reverse: soupyReverse.input('albumCoverArtistContributionsBy'),
     }),
@@ -124,6 +141,102 @@ export class Artist extends Thing {
       reverse: soupyReverse.input('groupsCloselyLinkedTo'),
     }),
 
+    musicContributions: [
+      withReverseReferenceList({
+        reverse: soupyReverse.input('trackArtistContributionsBy'),
+      }).outputs({
+        '#reverseReferenceList': '#trackArtistContribs',
+      }),
+
+      withReverseReferenceList({
+        reverse: soupyReverse.input('trackContributorContributionsBy'),
+      }).outputs({
+        '#reverseReferenceList': '#trackContributorContribs',
+      }),
+
+      {
+        dependencies: [
+          '#trackArtistContribs',
+          '#trackContributorContribs',
+        ],
+
+        compute: (continuation, {
+          ['#trackArtistContribs']: trackArtistContribs,
+          ['#trackContributorContribs']: trackContributorContribs,
+        }) => continuation({
+          ['#contributions']: [
+            ...trackArtistContribs,
+            ...trackContributorContribs,
+          ],
+        }),
+      },
+
+      {
+        dependencies: ['#contributions'],
+        compute: ({'#contributions': contributions}) =>
+          sortContributionsChronologically(
+            contributions,
+            sortAlbumsTracksChronologically),
+      },
+    ],
+
+    artworkContributions: [
+      withReverseReferenceList({
+        reverse: soupyReverse.input('trackCoverArtistContributionsBy'),
+      }).outputs({
+        '#reverseReferenceList': '#trackCoverArtistContribs',
+      }),
+
+      withReverseReferenceList({
+        reverse: soupyReverse.input('albumCoverArtistContributionsBy'),
+      }).outputs({
+        '#reverseReferenceList': '#albumCoverArtistContribs',
+      }),
+
+      withReverseReferenceList({
+        reverse: soupyReverse.input('albumWallpaperArtistContributionsBy'),
+      }).outputs({
+        '#reverseReferenceList': '#albumWallpaperArtistContribs',
+      }),
+
+      withReverseReferenceList({
+        reverse: soupyReverse.input('albumBannerArtistContributionsBy'),
+      }).outputs({
+        '#reverseReferenceList': '#albumBannerArtistContribs',
+      }),
+
+      {
+        dependencies: [
+          '#trackCoverArtistContribs',
+          '#albumCoverArtistContribs',
+          '#albumWallpaperArtistContribs',
+          '#albumBannerArtistContribs',
+        ],
+
+        compute: (continuation, {
+          ['#trackCoverArtistContribs']: trackCoverArtistContribs,
+          ['#albumCoverArtistContribs']: albumCoverArtistContribs,
+          ['#albumWallpaperArtistContribs']: albumWallpaperArtistContribs,
+          ['#albumBannerArtistContribs']: albumBannerArtistContribs,
+        }) => continuation({
+          ['#contributions']: [
+            ...trackCoverArtistContribs,
+            ...albumCoverArtistContribs,
+            ...albumWallpaperArtistContribs,
+            ...albumBannerArtistContribs,
+          ],
+        }),
+      },
+
+      {
+        dependencies: ['#contributions'],
+        compute: ({'#contributions': contributions}) =>
+          sortContributionsChronologically(
+            contributions,
+            sortArtworksChronologically),
+      },
+    ],
+
     totalDuration: artistTotalDuration(),
   });
 
diff --git a/src/data/things/artwork.js b/src/data/things/artwork.js
index 3cf380a0..c54bcced 100644
--- a/src/data/things/artwork.js
+++ b/src/data/things/artwork.js
@@ -25,7 +25,7 @@ import {
   parseDimensions,
 } from '#yaml';
 
-import {withPropertyFromObject} from '#composite/data';
+import {withPropertyFromList, withPropertyFromObject} from '#composite/data';
 
 import {
   exitWithoutDependency,
@@ -55,8 +55,10 @@ import {
 } from '#composite/wiki-properties';
 
 import {
+  withArtTags,
   withAttachedArtwork,
   withContainingArtworkList,
+  withContentWarningArtTags,
   withContribsFromAttachedArtwork,
   withPropertyFromAttachedArtwork,
   withDate,
@@ -170,6 +172,7 @@ export class Artwork extends Thing {
       withResolvedContribs({
         from: input.updateValue({validate: isContributionList}),
         date: '#date',
+        thingProperty: input.thisProperty(),
         artistProperty: 'artistContribsArtistProperty',
       }),
 
@@ -208,47 +211,16 @@ export class Artwork extends Thing {
     artTagsFromThingProperty: simpleString(),
 
     artTags: [
-      withResolvedReferenceList({
-        list: input.updateValue({
+      withArtTags({
+        from: input.updateValue({
           validate:
             validateReferenceList(ArtTag[Thing.referenceType]),
         }),
-
-        find: soupyFind.input('artTag'),
-      }),
-
-      exposeDependencyOrContinue({
-        dependency: '#resolvedReferenceList',
-        mode: input.value('empty'),
-      }),
-
-      withPropertyFromAttachedArtwork({
-        property: input.value('artTags'),
-      }),
-
-      exposeDependencyOrContinue({
-        dependency: '#attachedArtwork.artTags',
-      }),
-
-      exitWithoutDependency({
-        dependency: 'artTagsFromThingProperty',
-        value: input.value([]),
       }),
 
-      withPropertyFromObject({
-        object: 'thing',
-        property: 'artTagsFromThingProperty',
-      }).outputs({
-        ['#value']: '#artTags',
-      }),
-
-      exposeDependencyOrContinue({
+      exposeDependency({
         dependency: '#artTags',
       }),
-
-      exposeConstant({
-        value: input.value([]),
-      }),
     ],
 
     referencedArtworksFromThingProperty: simpleString(),
@@ -322,6 +294,12 @@ export class Artwork extends Thing {
 
     // Expose only
 
+    isArtwork: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     referencedByArtworks: reverseReferenceList({
       reverse: soupyReverse.input('artworksWhichReference'),
     }),
@@ -370,6 +348,42 @@ export class Artwork extends Thing {
     attachingArtworks: reverseReferenceList({
       reverse: soupyReverse.input('artworksWhichAttach'),
     }),
+
+    groups: [
+      withPropertyFromObject({
+        object: 'thing',
+        property: input.value('groups'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#thing.groups',
+      }),
+
+      exposeConstant({
+        value: input.value([]),
+      }),
+    ],
+
+    contentWarningArtTags: [
+      withContentWarningArtTags(),
+
+      exposeDependency({
+        dependency: '#contentWarningArtTags',
+      }),
+    ],
+
+    contentWarnings: [
+      withContentWarningArtTags(),
+
+      withPropertyFromList({
+        list: '#contentWarningArtTags',
+        property: input.value('name'),
+      }),
+
+      exposeDependency({
+        dependency: '#contentWarningArtTags.name',
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -456,6 +470,18 @@ export class Artwork extends Thing {
     return this.thing.getOwnArtworkPath(this);
   }
 
+  countOwnContributionInContributionTotals(contrib) {
+    if (this.attachAbove) {
+      return false;
+    }
+
+    if (contrib.annotation?.startsWith('edits for wiki')) {
+      return false;
+    }
+
+    return true;
+  }
+
   [inspect.custom](depth, options, inspect) {
     const parts = [];
 
diff --git a/src/data/things/content.js b/src/data/things/content.js
index e380780c..d2cf32dc 100644
--- a/src/data/things/content.js
+++ b/src/data/things/content.js
@@ -50,6 +50,10 @@ export class ContentEntry extends Thing {
     },
 
     accessKind: [
+      exitWithoutDependency({
+        dependency: 'accessDate',
+      }),
+
       exposeUpdateValueOrContinue({
         validate: input.value(
           is(...[
@@ -73,7 +77,7 @@ export class ContentEntry extends Thing {
       },
 
       exposeConstant({
-        value: input.value(null),
+        value: input.value('accessed'),
       }),
     ],
 
@@ -105,6 +109,12 @@ export class ContentEntry extends Thing {
 
     // Expose only
 
+    isContentEntry: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     annotationParts: [
       withAnnotationParts({
         mode: input.value('strings'),
@@ -147,6 +157,12 @@ export class CommentaryEntry extends ContentEntry {
   static [Thing.getPropertyDescriptors] = () => ({
     // Expose only
 
+    isCommentaryEntry: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     isWikiEditorCommentary: hasAnnotationPart({
       part: input.value('wiki editor'),
     }),
@@ -161,6 +177,12 @@ export class LyricsEntry extends ContentEntry {
 
     // Expose only
 
+    isLyricsEntry: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     isWikiLyrics: hasAnnotationPart({
       part: input.value('wiki lyrics'),
     }),
@@ -196,6 +218,26 @@ export class LyricsEntry extends ContentEntry {
   });
 }
 
-export class CreditingSourcesEntry extends ContentEntry {}
+export class CreditingSourcesEntry extends ContentEntry {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Expose only
+
+    isCreditingSourcesEntry: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+  });
+}
 
-export class ReferencingSourcesEntry extends ContentEntry {}
+export class ReferencingSourcesEntry extends ContentEntry {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Expose only
+
+    isReferencingSourceEntry: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+  });
+}
diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js
index b3655eb8..006aeec0 100644
--- a/src/data/things/contribution.js
+++ b/src/data/things/contribution.js
@@ -5,12 +5,20 @@ import {colors} from '#cli';
 import {input} from '#composite';
 import {empty} from '#sugar';
 import Thing from '#thing';
-import {isStringNonEmpty, isThing, validateReference} from '#validators';
+import {isBoolean, isStringNonEmpty, isThing, validateReference}
+  from '#validators';
 
-import {exitWithoutDependency, exposeDependency} from '#composite/control-flow';
 import {flag, simpleDate, soupyFind} from '#composite/wiki-properties';
 
 import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
   withFilteredList,
   withNearbyItemFromList,
   withPropertyFromList,
@@ -19,8 +27,6 @@ import {
 
 import {
   inheritFromContributionPresets,
-  thingPropertyMatches,
-  thingReferenceTypeMatches,
   withContainingReverseContributionList,
   withContributionArtist,
   withContributionContext,
@@ -70,7 +76,26 @@ export class Contribution extends Thing {
         property: input.thisProperty(),
       }),
 
-      flag(true),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      {
+        dependencies: ['thing', input.myself()],
+        compute: (continuation, {
+          ['thing']: thing,
+          [input.myself()]: contribution,
+        }) =>
+          (thing.countOwnContributionInContributionTotals?.(contribution)
+            ? true
+         : thing.countOwnContributionInContributionTotals
+            ? false
+            : continuation()),
+      },
+
+      exposeConstant({
+        value: input.value(true),
+      }),
     ],
 
     countInDurationTotals: [
@@ -78,7 +103,37 @@ export class Contribution extends Thing {
         property: input.thisProperty(),
       }),
 
-      flag(true),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: input.value('duration'),
+      }),
+
+      exitWithoutDependency({
+        dependency: '#thing.duration',
+        mode: input.value('falsy'),
+        value: input.value(false),
+      }),
+
+      {
+        dependencies: ['thing', input.myself()],
+        compute: (continuation, {
+          ['thing']: thing,
+          [input.myself()]: contribution,
+        }) =>
+          (thing.countOwnContributionInDurationTotals?.(contribution)
+            ? true
+         : thing.countOwnContributionInDurationTotals
+            ? false
+            : continuation()),
+      },
+
+      exposeConstant({
+        value: input.value(true),
+      }),
     ],
 
     // Update only
@@ -87,6 +142,12 @@ export class Contribution extends Thing {
 
     // Expose only
 
+    isContribution: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     context: [
       withContributionContext(),
 
@@ -167,38 +228,6 @@ export class Contribution extends Thing {
       }),
     ],
 
-    isArtistContribution: thingPropertyMatches({
-      value: input.value('artistContribs'),
-    }),
-
-    isContributorContribution: thingPropertyMatches({
-      value: input.value('contributorContribs'),
-    }),
-
-    isCoverArtistContribution: thingPropertyMatches({
-      value: input.value('coverArtistContribs'),
-    }),
-
-    isBannerArtistContribution: thingPropertyMatches({
-      value: input.value('bannerArtistContribs'),
-    }),
-
-    isWallpaperArtistContribution: thingPropertyMatches({
-      value: input.value('wallpaperArtistContribs'),
-    }),
-
-    isForTrack: thingReferenceTypeMatches({
-      value: input.value('track'),
-    }),
-
-    isForAlbum: thingReferenceTypeMatches({
-      value: input.value('album'),
-    }),
-
-    isForFlash: thingReferenceTypeMatches({
-      value: input.value('flash'),
-    }),
-
     previousBySameArtist: [
       withContainingReverseContributionList().outputs({
         '#containingReverseContributionList': '#list',
@@ -238,6 +267,21 @@ export class Contribution extends Thing {
         dependency: '#nearbyItem',
       }),
     ],
+
+    groups: [
+      withPropertyFromObject({
+        object: 'thing',
+        property: input.value('groups'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#thing.groups',
+      }),
+
+      exposeConstant({
+        value: input.value([]),
+      }),
+    ],
   });
 
   [inspect.custom](depth, options, inspect) {
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
index 160221f0..73b22746 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -149,6 +149,12 @@ export class Flash extends Thing {
 
     // Expose only
 
+    isFlash: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     commentatorArtists: commentatorArtists(),
 
     act: [
@@ -317,6 +323,12 @@ export class FlashAct extends Thing {
 
     // Expose only
 
+    isFlashAct: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     side: [
       withFlashSide(),
       exposeDependency({dependency: '#flashSide'}),
@@ -372,6 +384,14 @@ export class FlashSide extends Thing {
     // Update only
 
     find: soupyFind(),
+
+    // Expose only
+
+    isFlashSide: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
diff --git a/src/data/things/group.js b/src/data/things/group.js
index 0262a3a5..0935dc93 100644
--- a/src/data/things/group.js
+++ b/src/data/things/group.js
@@ -5,17 +5,28 @@ import {inspect} from 'node:util';
 import {colors} from '#cli';
 import {input} from '#composite';
 import Thing from '#thing';
-import {is} from '#validators';
+import {is, isBoolean} from '#validators';
 import {parseAnnotatedReferences, parseSerieses} from '#yaml';
 
+import {withPropertyFromObject} from '#composite/data';
+import {withUniqueReferencingThing} from '#composite/wiki-data';
+
+import {
+  exposeConstant,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
 import {
   annotatedReferenceList,
   color,
   contentString,
   directory,
+  flag,
   name,
   referenceList,
   soupyFind,
+  soupyReverse,
   thing,
   thingList,
   urls,
@@ -30,6 +41,33 @@ export class Group extends Thing {
     name: name('Unnamed Group'),
     directory: directory(),
 
+    excludeFromGalleryTabs: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withUniqueReferencingThing({
+        reverse: soupyReverse.input('groupCategoriesWhichInclude'),
+      }).outputs({
+        '#uniqueReferencingThing': '#category',
+      }),
+
+      withPropertyFromObject({
+        object: '#category',
+        property: input.value('excludeGroupsFromGalleryTabs'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#category.excludeGroupsFromGalleryTabs',
+      }),
+
+      exposeConstant({
+        value: input.value(false),
+      }),
+    ],
+
+    divideAlbumsByStyle: flag(false),
+
     description: contentString(),
 
     urls: urls(),
@@ -54,10 +92,16 @@ export class Group extends Thing {
     // Update only
 
     find: soupyFind(),
-    reverse: soupyFind(),
+    reverse: soupyReverse(),
 
     // Expose only
 
+    isGroup: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     descriptionShort: {
       flags: {expose: true},
 
@@ -133,6 +177,10 @@ export class Group extends Thing {
     fields: {
       'Group': {property: 'name'},
       'Directory': {property: 'directory'},
+
+      'Exclude From Gallery Tabs': {property: 'excludeFromGalleryTabs'},
+      'Divide Albums By Style': {property: 'divideAlbumsByStyle'},
+
       'Description': {property: 'description'},
       'URLs': {property: 'urls'},
 
@@ -217,6 +265,8 @@ export class GroupCategory extends Thing {
     name: name('Unnamed Group Category'),
     directory: directory(),
 
+    excludeGroupsFromGalleryTabs: flag(false),
+
     color: color(),
 
     groups: referenceList({
@@ -227,6 +277,14 @@ export class GroupCategory extends Thing {
     // Update only
 
     find: soupyFind(),
+
+    // Expose only
+
+    isGroupCategory: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.reverseSpecs] = {
@@ -241,7 +299,12 @@ export class GroupCategory extends Thing {
   static [Thing.yamlDocumentSpec] = {
     fields: {
       'Category': {property: 'name'},
+
       'Color': {property: 'color'},
+
+      'Exclude Groups From Gallery Tabs': {
+        property: 'excludeGroupsFromGalleryTabs',
+      },
     },
   };
 }
diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js
index 3a11c287..2456ca95 100644
--- a/src/data/things/homepage-layout.js
+++ b/src/data/things/homepage-layout.js
@@ -17,7 +17,7 @@ import {
   validateReference,
 } from '#validators';
 
-import {exposeDependency} from '#composite/control-flow';
+import {exposeConstant, exposeDependency} from '#composite/control-flow';
 import {withResolvedReference} from '#composite/wiki-data';
 
 import {
@@ -47,6 +47,14 @@ export class HomepageLayout extends Thing {
     sections: thingList({
       class: input.value(HomepageLayoutSection),
     }),
+
+    // Expose only
+
+    isHomepageLayout: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -156,6 +164,14 @@ export class HomepageLayoutSection extends Thing {
     rows: thingList({
       class: input.value(HomepageLayoutRow),
     }),
+
+    // Expose only
+
+    isHomepageLayoutSection: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -182,6 +198,12 @@ export class HomepageLayoutRow extends Thing {
 
     // Expose only
 
+    isHomepageLayoutRow: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     type: {
       flags: {expose: true},
 
@@ -233,6 +255,12 @@ export class HomepageLayoutActionsRow extends HomepageLayoutRow {
 
     // Expose only
 
+    isHomepageLayoutActionsRow: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     type: {
       flags: {expose: true},
       expose: {compute: () => 'actions'},
@@ -261,6 +289,12 @@ export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow {
 
     // Expose only
 
+    isHomepageLayoutAlbumCarouselRow: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     type: {
       flags: {expose: true},
       expose: {compute: () => 'album carousel'},
@@ -321,6 +355,12 @@ export class HomepageLayoutAlbumGridRow extends HomepageLayoutRow {
 
     // Expose only
 
+    isHomepageLayoutAlbumGridRow: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     type: {
       flags: {expose: true},
       expose: {compute: () => 'album grid'},
diff --git a/src/data/things/language.js b/src/data/things/language.js
index b0124c10..88e8d996 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -1,8 +1,9 @@
-import { Temporal, toTemporalInstant } from '@js-temporal/polyfill';
+import {Temporal, toTemporalInstant} from '@js-temporal/polyfill';
 
 import {withAggregate} from '#aggregate';
 import CacheableObject from '#cacheable-object';
 import {logWarn} from '#cli';
+import {input} from '#composite';
 import * as html from '#html';
 import {empty} from '#sugar';
 import {isLanguageCode} from '#validators';
@@ -16,6 +17,7 @@ import {
   isExternalLinkStyle,
 } from '#external-links';
 
+import {exposeConstant} from '#composite/control-flow';
 import {externalFunction, flag, name} from '#composite/wiki-properties';
 
 export const languageOptionRegex = /{(?<name>[A-Z0-9_]+)}/g;
@@ -127,6 +129,12 @@ export class Language extends Thing {
 
     // Expose only
 
+    isLanguage: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     onlyIfOptions: {
       flags: {expose: true},
       expose: {
@@ -204,6 +212,10 @@ export class Language extends Thing {
   }
 
   formatString(...args) {
+    if (typeof args.at(-1) === 'function') {
+      throw new Error(`Passed function - did you mean language.encapsulate() instead?`);
+    }
+
     const hasOptions =
       typeof args.at(-1) === 'object' &&
       args.at(-1) !== null;
@@ -310,7 +322,7 @@ export class Language extends Thing {
           return undefined;
         }
 
-        return optionValue;
+        return this.sanitize(optionValue);
       },
     });
 
@@ -375,26 +387,16 @@ export class Language extends Thing {
 
       partInProgress += template.slice(lastIndex, match.index);
 
-      // Sanitize string arguments in particular. These are taken to come from
-      // (raw) data and may include special characters that aren't meant to be
-      // rendered as HTML markup.
-      const sanitizedInsertion =
-        this.#sanitizeValueForInsertion(insertion);
-
-      if (typeof sanitizedInsertion === 'string') {
-        // Join consecutive strings together.
-        partInProgress += sanitizedInsertion;
-      } else if (
-        sanitizedInsertion instanceof html.Tag &&
-        sanitizedInsertion.contentOnly
-      ) {
-        // Collapse string-only tag contents onto the current string part.
-        partInProgress += sanitizedInsertion.toString();
-      } else {
-        // Push the string part in progress, then the insertion as-is.
-        outputParts.push(partInProgress);
-        outputParts.push(sanitizedInsertion);
-        partInProgress = '';
+      for (const insertionItem of html.smush(insertion).content) {
+        if (typeof insertionItem === 'string') {
+          // Join consecutive strings together.
+          partInProgress += insertionItem;
+        } else {
+          // Push the string part in progress, then the insertion as-is.
+          outputParts.push(partInProgress);
+          outputParts.push(insertionItem);
+          partInProgress = '';
+        }
       }
 
       lastIndex = match.index + match[0].length;
@@ -867,14 +869,14 @@ export class Language extends Thing {
 
   typicallyLowerCase(string) {
     // Utter nonsense implementation, so this only works on strings,
-    // not actual HTML content, and will loudly disrespect *intentful*
+    // not actual HTML content, and may rudely disrespect *intentful*
     // capitalization of whatever goes into it.
 
-    if (typeof string === 'string') {
-      return string[0].toLowerCase() + string.slice(1).toLowerCase();
-    } else {
-      return string;
-    }
+    if (typeof string !== 'string') return string;
+    if (string.length <= 1) return string;
+    if (/^\S+?[A-Z]/.test(string)) return string;
+
+    return string[0].toLowerCase() + string.slice(1);
   }
 
   // Utility function to quickly provide a useful string key
diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js
index 43d1638e..28289f53 100644
--- a/src/data/things/news-entry.js
+++ b/src/data/things/news-entry.js
@@ -1,9 +1,11 @@
 export const NEWS_DATA_FILE = 'news.yaml';
 
+import {input} from '#composite';
 import {sortChronologically} from '#sort';
 import Thing from '#thing';
 import {parseDate} from '#yaml';
 
+import {exposeConstant} from '#composite/control-flow';
 import {contentString, directory, name, simpleDate}
   from '#composite/wiki-properties';
 
@@ -22,6 +24,12 @@ export class NewsEntry extends Thing {
 
     // Expose only
 
+    isNewsEntry: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     contentShort: {
       flags: {expose: true},
 
diff --git a/src/data/things/sorting-rule.js b/src/data/things/sorting-rule.js
index ccc4ad89..808a0085 100644
--- a/src/data/things/sorting-rule.js
+++ b/src/data/things/sorting-rule.js
@@ -3,6 +3,7 @@ export const SORTING_RULE_DATA_FILE = 'sorting-rules.yaml';
 import {readFile, writeFile} from 'node:fs/promises';
 import * as path from 'node:path';
 
+import {input} from '#composite';
 import {chunkByProperties, compareArrays, unique} from '#sugar';
 import Thing from '#thing';
 import {isObject, isStringNonEmpty, anyOf, strictArrayOf} from '#validators';
@@ -21,6 +22,7 @@ import {
   reorderDocumentsInYAMLSourceText,
 } from '#yaml';
 
+import {exposeConstant} from '#composite/control-flow';
 import {flag} from '#composite/wiki-properties';
 
 function isSelectFollowingEntry(value) {
@@ -46,6 +48,14 @@ export class SortingRule extends Thing {
       flags: {update: true, expose: true},
       update: {validate: isStringNonEmpty},
     },
+
+    // Expose only
+
+    isSortingRule: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -118,6 +128,14 @@ export class ThingSortingRule extends SortingRule {
         validate: strictArrayOf(isStringNonEmpty),
       },
     },
+
+    // Expose only
+
+    isThingSortingRule: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(SortingRule, {
@@ -217,6 +235,14 @@ export class DocumentSortingRule extends ThingSortingRule {
       flags: {update: true, expose: true},
       update: {validate: isStringNonEmpty},
     },
+
+    // Expose only
+
+    isDocumentSortingRule: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ThingSortingRule, {
diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js
index 52a09c31..28167df2 100644
--- a/src/data/things/static-page.js
+++ b/src/data/things/static-page.js
@@ -2,11 +2,13 @@ export const DATA_STATIC_PAGE_DIRECTORY = 'static-page';
 
 import * as path from 'node:path';
 
+import {input} from '#composite';
 import {traverse} from '#node-utils';
 import {sortAlphabetically} from '#sort';
 import Thing from '#thing';
 import {isName} from '#validators';
 
+import {exposeConstant} from '#composite/control-flow';
 import {contentString, directory, flag, name, simpleString}
   from '#composite/wiki-properties';
 
@@ -36,6 +38,14 @@ export class StaticPage extends Thing {
     content: contentString(),
 
     absoluteLinks: flag(),
+
+    // Expose only
+
+    isStaticPage: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.findSpecs] = {
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 8419f8ba..18faebc3 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -25,6 +25,7 @@ import {
 import {withPropertyFromObject} from '#composite/data';
 
 import {
+  exitWithoutDependency,
   exposeConstant,
   exposeDependency,
   exposeDependencyOrContinue,
@@ -95,7 +96,13 @@ export class Track extends Thing {
     ReferencingSourcesEntry,
     WikiInfo,
   }) => ({
-    // Update & expose
+    // > Update & expose - Internal relationships
+
+    album: thing({
+      class: input.value(Album),
+    }),
+
+    // > Update & expose - Identifying metadata
 
     name: name('Unnamed Track'),
 
@@ -129,20 +136,93 @@ export class Track extends Thing {
       })
     ],
 
-    album: thing({
-      class: input.value(Album),
+    alwaysReferenceByDirectory: [
+      withAlwaysReferenceByDirectory(),
+      exposeDependency({dependency: '#alwaysReferenceByDirectory'}),
+    ],
+
+    mainReleaseTrack: singleReference({
+      class: input.value(Track),
+      find: soupyFind.input('track'),
     }),
 
+    bandcampTrackIdentifier: simpleString(),
+    bandcampArtworkIdentifier: simpleString(),
+
     additionalNames: thingList({
       class: input.value(AdditionalName),
     }),
 
-    bandcampTrackIdentifier: simpleString(),
-    bandcampArtworkIdentifier: simpleString(),
+    dateFirstReleased: simpleDate(),
+
+    // > Update & expose - Credits and contributors
+
+    artistContribs: [
+      inheritContributionListFromMainRelease(),
+
+      withDate(),
+
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('trackArtistContributions'),
+        date: '#date',
+      }).outputs({
+        '#resolvedContribs': '#artistContribs',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#artistContribs',
+        mode: input.value('empty'),
+      }),
+
+      withPropertyFromAlbum({
+        property: input.value('trackArtistContribs'),
+      }),
+
+      withRecontextualizedContributionList({
+        list: '#album.trackArtistContribs',
+        artistProperty: input.value('trackArtistContributions'),
+      }),
+
+      withRedatedContributionList({
+        list: '#album.trackArtistContribs',
+        date: '#date',
+      }),
+
+      exposeDependency({dependency: '#album.trackArtistContribs'}),
+    ],
+
+    contributorContribs: [
+      inheritContributionListFromMainRelease(),
+
+      withDate(),
+
+      contributionList({
+        date: '#date',
+        artistProperty: input.value('trackContributorContributions'),
+      }),
+    ],
+
+    // > Update & expose - General configuration
+
+    countInArtistTotals: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromAlbum({
+        property: input.value('countTracksInArtistTotals'),
+      }),
+
+      exposeDependency({dependency: '#album.countTracksInArtistTotals'}),
+    ],
+
+    disableUniqueCoverArt: flag(),
+
+    // > Update & expose - General metadata
 
     duration: duration(),
-    urls: urls(),
-    dateFirstReleased: simpleDate(),
 
     color: [
       exposeUpdateValueOrContinue({
@@ -165,37 +245,27 @@ export class Track extends Thing {
       exposeDependency({dependency: '#album.color'}),
     ],
 
-    alwaysReferenceByDirectory: [
-      withAlwaysReferenceByDirectory(),
-      exposeDependency({dependency: '#alwaysReferenceByDirectory'}),
-    ],
-
-    // Disables presenting the track as though it has its own unique artwork.
-    // This flag should only be used in select circumstances, i.e. to override
-    // an album's trackCoverArtists. This flag supercedes that property, as well
-    // as the track's own coverArtists.
-    disableUniqueCoverArt: flag(),
-
-    // File extension for track's corresponding media file. This represents the
-    // track's unique cover artwork, if any, and does not inherit the extension
-    // of the album's main artwork. It does inherit trackCoverArtFileExtension,
-    // if present on the album.
-    coverArtFileExtension: [
-      exitWithoutUniqueCoverArt(),
+    urls: urls(),
 
-      exposeUpdateValueOrContinue({
-        validate: input.value(isFileExtension),
-      }),
+    // > Update & expose - Artworks
 
-      withPropertyFromAlbum({
-        property: input.value('trackCoverArtFileExtension'),
+    trackArtworks: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
       }),
 
-      exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}),
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Track Artwork'),
+    ],
 
-      exposeConstant({
-        value: input.value('jpg'),
+    coverArtistContribs: [
+      withCoverArtistContribs({
+        from: input.updateValue({
+          validate: isContributionList,
+        }),
       }),
+
+      exposeDependency({dependency: '#coverArtistContribs'}),
     ],
 
     coverArtDate: [
@@ -208,117 +278,59 @@ export class Track extends Thing {
       exposeDependency({dependency: '#trackArtDate'}),
     ],
 
-    coverArtDimensions: [
+    coverArtFileExtension: [
       exitWithoutUniqueCoverArt(),
 
-      exposeUpdateValueOrContinue(),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isFileExtension),
+      }),
 
       withPropertyFromAlbum({
-        property: input.value('trackDimensions'),
+        property: input.value('trackCoverArtFileExtension'),
       }),
 
-      exposeDependencyOrContinue({dependency: '#album.trackDimensions'}),
-
-      dimensions(),
-    ],
-
-    commentary: thingList({
-      class: input.value(CommentaryEntry),
-    }),
-
-    creditingSources: thingList({
-      class: input.value(CreditingSourcesEntry),
-    }),
-
-    referencingSources: thingList({
-      class: input.value(ReferencingSourcesEntry),
-    }),
-
-    lyrics: [
-      // TODO: Inherited lyrics are literally the same objects, so of course
-      // their .thing properties aren't going to point back to this one, and
-      // certainly couldn't be recontextualized...
-      inheritFromMainRelease(),
+      exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}),
 
-      thingList({
-        class: input.value(LyricsEntry),
+      exposeConstant({
+        value: input.value('jpg'),
       }),
     ],
 
-    additionalFiles: thingList({
-      class: input.value(AdditionalFile),
-    }),
-
-    sheetMusicFiles: thingList({
-      class: input.value(AdditionalFile),
-    }),
-
-    midiProjectFiles: thingList({
-      class: input.value(AdditionalFile),
-    }),
-
-    mainReleaseTrack: singleReference({
-      class: input.value(Track),
-      find: soupyFind.input('track'),
-    }),
-
-    artistContribs: [
-      inheritContributionListFromMainRelease(),
-
-      withDate(),
-
-      withResolvedContribs({
-        from: input.updateValue({validate: isContributionList}),
-        thingProperty: input.thisProperty(),
-        artistProperty: input.value('trackArtistContributions'),
-        date: '#date',
-      }).outputs({
-        '#resolvedContribs': '#artistContribs',
-      }),
+    coverArtDimensions: [
+      exitWithoutUniqueCoverArt(),
 
-      exposeDependencyOrContinue({
-        dependency: '#artistContribs',
-        mode: input.value('empty'),
-      }),
+      exposeUpdateValueOrContinue(),
 
       withPropertyFromAlbum({
-        property: input.value('artistContribs'),
-      }),
-
-      withRecontextualizedContributionList({
-        list: '#album.artistContribs',
-        artistProperty: input.value('trackArtistContributions'),
+        property: input.value('trackDimensions'),
       }),
 
-      withRedatedContributionList({
-        list: '#album.artistContribs',
-        date: '#date',
-      }),
+      exposeDependencyOrContinue({dependency: '#album.trackDimensions'}),
 
-      exposeDependency({dependency: '#album.artistContribs'}),
+      dimensions(),
     ],
 
-    contributorContribs: [
-      inheritContributionListFromMainRelease(),
-
-      withDate(),
+    artTags: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
+      }),
 
-      contributionList({
-        date: '#date',
-        artistProperty: input.value('trackContributorContributions'),
+      referenceList({
+        class: input.value(ArtTag),
+        find: soupyFind.input('artTag'),
       }),
     ],
 
-    coverArtistContribs: [
-      withCoverArtistContribs({
-        from: input.updateValue({
-          validate: isContributionList,
-        }),
+    referencedArtworks: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
       }),
 
-      exposeDependency({dependency: '#coverArtistContribs'}),
+      referencedArtworkList(),
     ],
 
+    // > Update & expose - Referenced tracks
+
     referencedTracks: [
       inheritFromMainRelease({
         notFoundValue: input.value([]),
@@ -341,35 +353,46 @@ export class Track extends Thing {
       }),
     ],
 
-    trackArtworks: [
-      exitWithoutUniqueCoverArt({
-        value: input.value([]),
-      }),
+    // > Update & expose - Additional files
 
-      constitutibleArtworkList.fromYAMLFieldSpec
-        .call(this, 'Track Artwork'),
-    ],
+    additionalFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
 
-    artTags: [
-      exitWithoutUniqueCoverArt({
-        value: input.value([]),
-      }),
+    sheetMusicFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
 
-      referenceList({
-        class: input.value(ArtTag),
-        find: soupyFind.input('artTag'),
+    midiProjectFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
+
+    // > Update & expose - Content entries
+
+    lyrics: [
+      // TODO: Inherited lyrics are literally the same objects, so of course
+      // their .thing properties aren't going to point back to this one, and
+      // certainly couldn't be recontextualized...
+      inheritFromMainRelease(),
+
+      thingList({
+        class: input.value(LyricsEntry),
       }),
     ],
 
-    referencedArtworks: [
-      exitWithoutUniqueCoverArt({
-        value: input.value([]),
-      }),
+    commentary: thingList({
+      class: input.value(CommentaryEntry),
+    }),
 
-      referencedArtworkList(),
-    ],
+    creditingSources: thingList({
+      class: input.value(CreditingSourcesEntry),
+    }),
+
+    referencingSources: thingList({
+      class: input.value(ReferencingSourcesEntry),
+    }),
 
-    // Update only
+    // > Update only
 
     find: soupyFind(),
     reverse: soupyReverse(),
@@ -389,7 +412,13 @@ export class Track extends Thing {
       class: input.value(WikiInfo),
     }),
 
-    // Expose only
+    // > Expose only
+
+    isTrack: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
 
     commentatorArtists: commentatorArtists(),
 
@@ -441,6 +470,34 @@ export class Track extends Thing {
       exposeDependency({dependency: '#otherReleases'}),
     ],
 
+    commentaryFromMainRelease: [
+      withMainRelease(),
+
+      exitWithoutDependency({
+        dependency: '#mainRelease',
+        value: input.value([]),
+      }),
+
+      withPropertyFromObject({
+        object: '#mainRelease',
+        property: input.value('commentary'),
+      }),
+
+      exposeDependency({
+        dependency: '#mainRelease.commentary',
+      }),
+    ],
+
+    groups: [
+      withPropertyFromAlbum({
+        property: input.value('groups'),
+      }),
+
+      exposeDependency({
+        dependency: '#album.groups',
+      }),
+    ],
+
     referencedByTracks: reverseReferenceList({
       reverse: soupyReverse.input('tracksWhichReference'),
     }),
@@ -456,14 +513,13 @@ export class Track extends Thing {
 
   static [Thing.yamlDocumentSpec] = {
     fields: {
+      // Identifying metadata
+
       'Track': {property: 'name'},
       'Directory': {property: 'directory'},
       'Suffix Directory': {property: 'suffixDirectoryFromAlbum'},
-
-      'Additional Names': {
-        property: 'additionalNames',
-        transform: parseAdditionalNames,
-      },
+      'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
+      'Main Release': {property: 'mainReleaseTrack'},
 
       'Bandcamp Track ID': {
         property: 'bandcampTrackIdentifier',
@@ -475,31 +531,32 @@ export class Track extends Thing {
         transform: String,
       },
 
-      'Duration': {
-        property: 'duration',
-        transform: parseDuration,
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
       },
 
-      'Color': {property: 'color'},
-      'URLs': {property: 'urls'},
-
       'Date First Released': {
         property: 'dateFirstReleased',
         transform: parseDate,
       },
 
-      'Cover Art Date': {
-        property: 'coverArtDate',
-        transform: parseDate,
-      },
+      // Credits and contributors
 
-      'Cover Art File Extension': {property: 'coverArtFileExtension'},
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
 
-      'Cover Art Dimensions': {
-        property: 'coverArtDimensions',
-        transform: parseDimensions,
+      'Contributors': {
+        property: 'contributorContribs',
+        transform: parseContributors,
       },
 
+      // General configuration
+
+      'Count In Artist Totals': {property: 'countInArtistTotals'},
+
       'Has Cover Art': {
         property: 'disableUniqueCoverArt',
         transform: value =>
@@ -508,28 +565,65 @@ export class Track extends Thing {
             : value),
       },
 
-      'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
+      // General metadata
 
-      'Lyrics': {
-        property: 'lyrics',
-        transform: parseLyrics,
+      'Duration': {
+        property: 'duration',
+        transform: parseDuration,
       },
 
-      'Commentary': {
-        property: 'commentary',
-        transform: parseCommentary,
+      'Color': {property: 'color'},
+
+      'URLs': {property: 'urls'},
+
+      // Artworks
+
+      'Track Artwork': {
+        property: 'trackArtworks',
+        transform:
+          parseArtwork({
+            thingProperty: 'trackArtworks',
+            dimensionsFromThingProperty: 'coverArtDimensions',
+            fileExtensionFromThingProperty: 'coverArtFileExtension',
+            dateFromThingProperty: 'coverArtDate',
+            artTagsFromThingProperty: 'artTags',
+            referencedArtworksFromThingProperty: 'referencedArtworks',
+            artistContribsFromThingProperty: 'coverArtistContribs',
+            artistContribsArtistProperty: 'trackCoverArtistContributions',
+          }),
       },
 
-      'Crediting Sources': {
-        property: 'creditingSources',
-        transform: parseCreditingSources,
+      'Cover Artists': {
+        property: 'coverArtistContribs',
+        transform: parseContributors,
       },
 
-      'Referencing Sources': {
-        property: 'referencingSources',
-        transform: parseReferencingSources,
+      'Cover Art Date': {
+        property: 'coverArtDate',
+        transform: parseDate,
+      },
+
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
+
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
+      },
+
+      'Art Tags': {property: 'artTags'},
+
+      'Referenced Artworks': {
+        property: 'referencedArtworks',
+        transform: parseAnnotatedReferences,
       },
 
+      // Referenced tracks
+
+      'Referenced Tracks': {property: 'referencedTracks'},
+      'Sampled Tracks': {property: 'sampledTracks'},
+
+      // Additional files
+
       'Additional Files': {
         property: 'additionalFiles',
         transform: parseAdditionalFiles,
@@ -545,54 +639,41 @@ export class Track extends Thing {
         transform: parseAdditionalFiles,
       },
 
-      'Main Release': {property: 'mainReleaseTrack'},
-      'Referenced Tracks': {property: 'referencedTracks'},
-      'Sampled Tracks': {property: 'sampledTracks'},
-
-      'Referenced Artworks': {
-        property: 'referencedArtworks',
-        transform: parseAnnotatedReferences,
-      },
+      // Content entries
 
-      'Franchises': {ignore: true},
-      'Inherit Franchises': {ignore: true},
-
-      'Artists': {
-        property: 'artistContribs',
-        transform: parseContributors,
+      'Lyrics': {
+        property: 'lyrics',
+        transform: parseLyrics,
       },
 
-      'Contributors': {
-        property: 'contributorContribs',
-        transform: parseContributors,
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
       },
 
-      'Cover Artists': {
-        property: 'coverArtistContribs',
-        transform: parseContributors,
+      'Crediting Sources': {
+        property: 'creditingSources',
+        transform: parseCreditingSources,
       },
 
-      'Track Artwork': {
-        property: 'trackArtworks',
-        transform:
-          parseArtwork({
-            thingProperty: 'trackArtworks',
-            dimensionsFromThingProperty: 'coverArtDimensions',
-            fileExtensionFromThingProperty: 'coverArtFileExtension',
-            dateFromThingProperty: 'coverArtDate',
-            artTagsFromThingProperty: 'artTags',
-            referencedArtworksFromThingProperty: 'referencedArtworks',
-            artistContribsFromThingProperty: 'coverArtistContribs',
-            artistContribsArtistProperty: 'trackCoverArtistContributions',
-          }),
+      'Referencing Sources': {
+        property: 'referencingSources',
+        transform: parseReferencingSources,
       },
 
-      'Art Tags': {property: 'artTags'},
+      // Shenanigans
 
+      'Franchises': {ignore: true},
+      'Inherit Franchises': {ignore: true},
       'Review Points': {ignore: true},
     },
 
     invalidFieldCombinations: [
+      {message: `Secondary releases never count in artist totals`, fields: [
+        'Main Release',
+        'Count In Artist Totals',
+      ]},
+
       {message: `Secondary releases inherit references from the main one`, fields: [
         'Main Release',
         'Referenced Tracks',
@@ -779,6 +860,30 @@ export class Track extends Thing {
     ];
   }
 
+  countOwnContributionInContributionTotals(_contrib) {
+    if (!this.countInArtistTotals) {
+      return false;
+    }
+
+    if (this.isSecondaryRelease) {
+      return false;
+    }
+
+    return true;
+  }
+
+  countOwnContributionInDurationTotals(_contrib) {
+    if (!this.countInArtistTotals) {
+      return false;
+    }
+
+    if (this.isSecondaryRelease) {
+      return false;
+    }
+
+    return true;
+  }
+
   [inspect.custom](depth) {
     const parts = [];
 
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index f97f9027..b6057735 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/wiki-info.js
@@ -13,7 +13,7 @@ import {
   isURL,
 } from '#validators';
 
-import {exitWithoutDependency} from '#composite/control-flow';
+import {exitWithoutDependency, exposeConstant} from '#composite/control-flow';
 
 import {
   contentString,
@@ -119,6 +119,14 @@ export class WikiInfo extends Thing {
         default: false,
       },
     },
+
+    // Expose only
+
+    isWikiInfo: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 9a0295b8..71887fc1 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -87,7 +87,7 @@ function makeProcessDocument(thingConstructor, {
   //   ]
   //
   // ...means A can't coexist with B or C, B can't coexist with A or C, and
-  // C can't coexist iwth A, B, or D - but it's okay for D to coexist with
+  // C can't coexist with A, B, or D - but it's okay for D to coexist with
   // A or B.
   //
   invalidFieldCombinations = [],
@@ -182,9 +182,22 @@ function makeProcessDocument(thingConstructor, {
 
     const fieldCombinationErrors = [];
 
-    for (const {message, fields} of invalidFieldCombinations) {
+    for (const {message, fields: fieldsSpec} of invalidFieldCombinations) {
       const fieldsPresent =
-        presentFields.filter(field => fields.includes(field));
+        fieldsSpec.flatMap(fieldSpec => {
+          if (Array.isArray(fieldSpec)) {
+            const [field, match] = fieldSpec;
+            if (!presentFields.includes(field)) return [];
+            if (typeof match === 'function') {
+              return match(document[field]) ? [field] : [];
+            } else {
+              return document[field] === match ? [field] : [];
+            }
+          }
+
+          const field = fieldSpec;
+          return presentFields.includes(field) ? [field] : [];
+        });
 
       if (fieldsPresent.length >= 2) {
         const filteredDocument =
@@ -194,7 +207,10 @@ function makeProcessDocument(thingConstructor, {
             {preserveOriginalOrder: true});
 
         fieldCombinationErrors.push(
-          new FieldCombinationError(filteredDocument, message));
+          new FieldCombinationError(
+            filteredDocument,
+            fieldsSpec,
+            message));
 
         for (const field of Object.keys(filteredDocument)) {
           skippedFields.add(field);
@@ -416,19 +432,36 @@ export class FieldCombinationAggregateError extends AggregateError {
 }
 
 export class FieldCombinationError extends Error {
-  constructor(fields, message) {
-    const fieldNames = Object.keys(fields);
+  constructor(filteredDocument, fieldsSpec, message) {
+    const fieldNames = Object.keys(filteredDocument);
 
     const fieldNamesText =
       fieldNames
-        .map(field => colors.red(field))
+        .map(field => {
+          if (fieldsSpec.includes(field)) {
+            return colors.red(field);
+          }
+
+          const match =
+            fieldsSpec
+              .find(fieldSpec =>
+                Array.isArray(fieldSpec) &&
+                fieldSpec[0] === field)
+              .at(1);
+
+          if (typeof match === 'function') {
+            return colors.red(`${field}: ${filteredDocument[field]}`);
+          } else {
+            return colors.red(`${field}: ${match}`);
+          }
+        })
         .join(', ');
 
     const mainMessage = `Don't combine ${fieldNamesText}`;
 
     const causeMessage =
       (typeof message === 'function'
-        ? message(fields)
+        ? message(filteredFields)
      : typeof message === 'string'
         ? message
         : null);
@@ -440,7 +473,7 @@ export class FieldCombinationError extends Error {
           : null),
     });
 
-    this.fields = fields;
+    this.fields = fieldNames;
   }
 }
 
@@ -976,6 +1009,12 @@ export const documentModes = {
   // array of processed documents (wiki objects).
   allInOne: Symbol('Document mode: allInOne'),
 
+  // allTogether: One or more documens, spread across any number of files.
+  // Expects files array (or function) and processDocument function.
+  // Calls save with an array of processed documents (wiki objects) - this is
+  // a flat array, *not* an array of the documents processed from *each* file.
+  allTogether: Symbol('Document mode: allTogether'),
+
   // oneDocumentTotal: Just a single document, represented in one file.
   // Expects file string (or function) and processDocument function. Calls
   // save with the single processed wiki document (data object).
@@ -1086,6 +1125,7 @@ export async function getFilesFromDataStep(dataStep, {dataPath}) {
       }
     }
 
+    case documentModes.allTogether:
     case documentModes.headerAndEntries:
     case documentModes.onePerFile: {
       if (!dataStep.files) {
@@ -1241,7 +1281,8 @@ export function processThingsFromDataStep(documents, dataStep) {
   const {documentMode} = dataStep;
 
   switch (documentMode) {
-    case documentModes.allInOne: {
+    case documentModes.allInOne:
+    case documentModes.allTogether: {
       const result = [];
       const aggregate = openAggregate({message: `Errors processing documents`});
 
@@ -1516,6 +1557,10 @@ export function saveThingsFromDataStep(thingLists, dataStep) {
       return dataStep.save(thing);
     }
 
+    case documentModes.allTogether: {
+      return dataStep.save(thingLists.flat());
+    }
+
     case documentModes.headerAndEntries:
     case documentModes.onePerFile: {
       return dataStep.save(thingLists);
diff --git a/src/external-links.js b/src/external-links.js
index d0583b73..ab1555bd 100644
--- a/src/external-links.js
+++ b/src/external-links.js
@@ -30,6 +30,9 @@ export const externalLinkContexts = [
   'generic',
   'group',
   'track',
+
+  'composerRelease',
+  'officialRelease',
 ];
 
 export const isExternalLinkContext =
@@ -255,6 +258,30 @@ export const externalLinkSpec = [
   },
 
   {
+    match: {
+      domain: '.bandcamp.com',
+      context: 'composerRelease',
+    },
+
+    platform: 'bandcamp.composerRelease',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'bandcamp',
+  },
+
+  {
+    match: {
+      domain: '.bandcamp.com',
+      context: 'officialRelease',
+    },
+
+    platform: 'bandcamp.officialRelease',
+    handle: {domain: /^[^.]+/},
+
+    icon: 'bandcamp',
+  },
+
+  {
     match: {domain: '.bandcamp.com'},
 
     platform: 'bandcamp',
diff --git a/src/find.js b/src/find.js
index e7f5cda1..8f2170d4 100644
--- a/src/find.js
+++ b/src/find.js
@@ -56,11 +56,14 @@ export function processAvailableMatchesByName(data, {
         if (normalizedName in multipleNameMatches) {
           multipleNameMatches[normalizedName].push(thing);
         } else {
-          multipleNameMatches[normalizedName] = [results[normalizedName], thing];
+          multipleNameMatches[normalizedName] = [
+            results[normalizedName].thing,
+            thing,
+          ];
           results[normalizedName] = null;
         }
       } else {
-        results[normalizedName] = thing;
+        results[normalizedName] = {thing, name};
       }
     }
   }
@@ -87,7 +90,7 @@ export function processAvailableMatchesByDirectory(data, {
         continue;
       }
 
-      results[directory] = thing;
+      results[directory] = {thing, directory};
     }
   }
 
@@ -117,13 +120,50 @@ function oopsMultipleNameMatches(mode, {
     `Returning null for this reference.`);
 }
 
+function oopsNameCapitalizationMismatch(mode, {
+  matchingName,
+  matchedName,
+}) {
+  if (matchingName.length === matchedName.length) {
+    let a = '', b = '';
+    for (let i = 0; i < matchingName.length; i++) {
+      if (
+        matchingName[i] === matchedName[i] ||
+        matchingName[i].toLowerCase() !== matchingName[i].toLowerCase()
+      ) {
+        a += matchingName[i];
+        b += matchedName[i];
+      } else {
+        a += colors.bright(colors.red(matchingName[i]));
+        b += colors.bright(colors.green(matchedName[i]));
+      }
+    }
+
+    matchingName = a;
+    matchedName = b;
+  }
+
+  return warnOrThrow(mode,
+    `Provided capitalization differs from the matched name. Please resolve:\n` +
+    `- provided: ${matchingName}\n` +
+    `- should be: ${matchedName}\n` +
+    `Returning null for this reference.`);
+}
+
 export function prepareMatchByName(mode, {byName, multipleNameMatches}) {
   return (name) => {
     const normalizedName = name.toLowerCase();
     const match = byName[normalizedName];
 
     if (match) {
-      return match;
+      if (name === match.name) {
+        return match.thing;
+      } else {
+        return oopsNameCapitalizationMismatch(mode, {
+          matchingName: name,
+          matchedName: match.name,
+        });
+      }
     } else if (multipleNameMatches[normalizedName]) {
       return oopsMultipleNameMatches(mode, {
         name,
@@ -154,7 +194,13 @@ export function prepareMatchByDirectory(mode, {referenceTypes, byDirectory}) {
       });
     }
 
-    return byDirectory[directory];
+    const match = byDirectory[directory];
+
+    if (match) {
+      return match.thing;
+    } else {
+      return null;
+    }
   };
 }
 
@@ -345,7 +391,13 @@ function findMixedHelper(config) {
           });
         }
 
-        return byDirectory[referenceType][directory];
+        const match = byDirectory[referenceType][directory];
+
+        if (match) {
+          return match.thing;
+        } else {
+          return null;
+        }
       },
 
       matchByName:
diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js
index 7d4bf059..40505189 100644
--- a/src/gen-thumbs.js
+++ b/src/gen-thumbs.js
@@ -555,42 +555,56 @@ async function determineThumbtacksNeededForFile({
   return mismatchedWithinRightSize;
 }
 
-async function generateImageThumbnail(imagePath, thumbtack, {
+// Write all requested thumbtacks for a source image in one pass
+// This saves a lot of disk reads which are probably the main bottleneck
+function prepareConvertArgs(filePathInMedia, dirnameInCache, thumbtacks) {
+  const args = [filePathInMedia, '-strip'];
+
+  const basename =
+    path.basename(filePathInMedia, path.extname(filePathInMedia));
+
+  // do larger sizes first
+  thumbtacks.sort((a, b) => thumbnailSpec[b].size - thumbnailSpec[a].size);
+
+  for (const tack of thumbtacks) {
+    const {size, quality} = thumbnailSpec[tack];
+    const filename = `${basename}.${tack}.jpg`;
+    const filePathInCache = path.join(dirnameInCache, filename);
+    args.push(
+      '(', '+clone',
+      '-resize', `${size}x${size}>`,
+      '-interlace', 'Plane',
+      '-quality', `${quality}%`,
+      '-write', filePathInCache,
+      '+delete', ')',
+    );
+  }
+
+  // throw away the (already written) image stream
+  args.push('null:');
+
+  return args;
+}
+
+async function generateImageThumbnails(imagePath, thumbtacks, {
   mediaPath,
   mediaCachePath,
   spawnConvert,
 }) {
+  if (empty(thumbtacks)) return;
+
   const filePathInMedia =
     path.join(mediaPath, imagePath);
 
   const dirnameInCache =
     path.join(mediaCachePath, path.dirname(imagePath));
 
-  const filename =
-    path.basename(imagePath, path.extname(imagePath)) +
-    `.${thumbtack}.jpg`;
-
-  const filePathInCache =
-    path.join(dirnameInCache, filename);
-
   await mkdir(dirnameInCache, {recursive: true});
 
-  const specEntry = thumbnailSpec[thumbtack];
-  const {size, quality} = specEntry;
-
-  const convertProcess = spawnConvert([
-    filePathInMedia,
-    '-strip',
-    '-resize',
-    `${size}x${size}>`,
-    '-interlace',
-    'Plane',
-    '-quality',
-    `${quality}%`,
-    filePathInCache,
-  ]);
-
-  await promisifyProcess(convertProcess, false);
+  const convertArgs =
+    prepareConvertArgs(filePathInMedia, dirnameInCache, thumbtacks);
+
+  await promisifyProcess(spawnConvert(convertArgs), false);
 }
 
 export async function determineMediaCachePath({
@@ -1099,33 +1113,23 @@ export default async function genThumbs({
   const writeMessageFn = () =>
     `Writing image thumbnails. [failed: ${numFailed}]`;
 
-  const generateCallImageIndices =
-    imageThumbtacksNeeded
-      .flatMap(({length}, index) =>
-        Array.from({length}, () => index));
-
-  const generateCallImagePaths =
-    generateCallImageIndices
-      .map(index => imagePaths[index]);
-
-  const generateCallThumbtacks =
-    imageThumbtacksNeeded.flat();
-
   const generateCallFns =
     stitchArrays({
-      imagePath: generateCallImagePaths,
-      thumbtack: generateCallThumbtacks,
-    }).map(({imagePath, thumbtack}) => () =>
-        generateImageThumbnail(imagePath, thumbtack, {
+      imagePath: imagePaths,
+      thumbtacks: imageThumbtacksNeeded,
+    }).map(({imagePath, thumbtacks}) => () =>
+        generateImageThumbnails(imagePath, thumbtacks, {
           mediaPath,
           mediaCachePath,
           spawnConvert,
         }).catch(error => {
             numFailed++;
-            return ({error});
+            return {error};
           }));
 
-  logInfo`Generating ${generateCallFns.length} thumbnails for ${imagePaths.length} media files.`;
+  const totalThumbs = imageThumbtacksNeeded.reduce((sum, tacks) => sum + tacks.length, 0);
+
+  logInfo`Generating ${totalThumbs} thumbnails for ${imagePaths.length} media files.`;
   if (generateCallFns.length > 500) {
     logInfo`Go get a latte - this could take a while!`;
   }
@@ -1134,37 +1138,30 @@ export default async function genThumbs({
     await progressPromiseAll(writeMessageFn,
       queue(generateCallFns, magickThreads));
 
-  let successfulIndices;
+  let successfulPaths;
 
   {
-    const erroredIndices = generateCallImageIndices.slice();
-    const erroredPaths = generateCallImagePaths.slice();
-    const erroredThumbtacks = generateCallThumbtacks.slice();
+    const erroredPaths = imagePaths.slice();
     const errors = generateCallResults.map(result => result?.error);
 
     const {removed} =
       filterMultipleArrays(
-        erroredIndices,
         erroredPaths,
-        erroredThumbtacks,
         errors,
-        (_index, _imagePath, _thumbtack, error) => error);
+        (_imagePath, error) => error);
 
-    successfulIndices = new Set(removed[0]);
-
-    const chunks =
-      chunkMultipleArrays(erroredPaths, erroredThumbtacks, errors,
-        (imagePath, lastImagePath) => imagePath !== lastImagePath);
+    ([successfulPaths] = removed);
 
     // TODO: This should obviously be an aggregate error.
     // ...Just like every other error report here, and those dang aggregates
     // should be constructable from within the queue, rather than after.
-    for (const [[imagePath], thumbtacks, errors] of chunks) {
-      logError`Failed to generate thumbnails for ${imagePath}:`;
-      for (const {thumbtack, error} of stitchArrays({thumbtack: thumbtacks, error: errors})) {
-        logError`- ${thumbtack}: ${error}`;
-      }
-    }
+    stitchArrays({
+      imagePath: erroredPaths,
+      error: errors,
+    }).forEach(({imagePath, error}) => {
+        logError`Failed to generate thumbnails for ${imagePath}:`;
+        logError`- ${error}`;
+      });
 
     if (empty(errors)) {
       logInfo`All needed thumbnails generated successfully - nice!`;
@@ -1178,8 +1175,8 @@ export default async function genThumbs({
     imagePaths,
     imageThumbtacksNeeded,
     imageDimensions,
-    (_imagePath, _thumbtacksNeeded, _dimensions, index) =>
-      successfulIndices.has(index));
+    (imagePath, _thumbtacksNeeded, _dimensions) =>
+      successfulPaths.includes(imagePath));
 
   for (const {
     imagePath,
diff --git a/src/html.js b/src/html.js
index 49700775..42083845 100644
--- a/src/html.js
+++ b/src/html.js
@@ -53,6 +53,17 @@ export const attributeSpec = {
   },
 };
 
+let disabledSlotValidation = false;
+let disabledTagTracing = false;
+
+export function disableSlotValidation() {
+  disabledSlotValidation = true;
+}
+
+export function disableTagTracing() {
+  disabledTagTracing = true;
+}
+
 // Pass to tag() as an attributes key to make tag() return a 8lank tag if the
 // provided content is empty. Useful for when you'll only 8e showing an element
 // according to the presence of content that would 8elong there.
@@ -223,7 +234,11 @@ export function isBlank(content) {
     // could include content. These need to be checked too.
     // Check each of the templates one at a time.
     for (const template of result) {
-      const content = template.content;
+      // Resolve the content all the way down to a tag -
+      // if it's a template that returns another template,
+      // that won't do, because we need to detect if its
+      // final content is a tag marked onlyIfSiblings.
+      const content = normalize(template);
 
       if (content instanceof Tag && content.onlyIfSiblings) {
         continue;
@@ -271,7 +286,11 @@ export const validators = {
   },
 };
 
-export function blank() {
+export function blank(...args) {
+  if (args.length) {
+    throw new Error(`Passed arguments - did you mean isBlank() instead?`)
+  }
+
   return [];
 }
 
@@ -352,8 +371,10 @@ export class Tag {
     this.attributes = attributes;
     this.content = content;
 
-    this.#traceError = new Error();
-  }
+    if (!disabledTagTracing) {
+      this.#traceError = new Error();
+    }
+}
 
   clone() {
     return Reflect.construct(this.constructor, [
@@ -706,17 +727,19 @@ export class Tag {
             `of ${inspect(this, {compact: true})}`,
             {cause: caughtError});
 
-        error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true;
-        error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError;
+        if (this.#traceError && !disabledTagTracing) {
+          error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true;
+          error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError;
 
-        error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [
-          /content-function\.js/,
-          /util\/html\.js/,
-        ];
+          error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [
+            /content-function\.js/,
+            /util\/html\.js/,
+          ];
 
-        error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [
-          /content\/dependencies\/(.*\.js:.*(?=\)))/,
-        ];
+          error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [
+            /content\/dependencies\/(.*\.js:.*(?=\)))/,
+          ];
+        }
 
         throw error;
       }
@@ -1131,6 +1154,34 @@ export class Attributes {
   }
 
   add(...args) {
+    // Very common case: add({class: 'foo', id: 'bar'})¡
+    // The argument is a plain object (no Template, no Attributes,
+    // no blessAttributes symbol). We can skip the expensive
+    // isAttributesAdditionSinglet() validation and flatten/array handling.
+    if (
+      args.length === 1 &&
+      args[0] &&
+      typeof args[0] === 'object' &&
+      !Array.isArray(args[0]) &&
+      !(args[0] instanceof Attributes) &&
+      !(args[0] instanceof Template) &&
+      !Object.hasOwn(args[0], blessAttributes)
+    ) {
+      const obj = args[0];
+
+      // Preserve existing merge semantics by funnelling each key through
+      // the internal #addOneAttribute helper (handles class/style union,
+      // unique merging, etc.) but avoid *per-object* validation overhead.
+      for (const key of Reflect.ownKeys(obj)) {
+        this.#addOneAttribute(key, obj[key]);
+      }
+
+      // Match the original return style (list of results) so callers that
+      // inspect the return continue to work.
+      return obj;
+    }
+
+    // Fall back to the original slow-but-thorough implementation
     switch (args.length) {
       case 1:
         isAttributesAdditionSinglet(args[0]);
@@ -1142,10 +1193,11 @@ export class Attributes {
 
       default:
         throw new Error(
-          `Expected array or object, or attribute and value`);
+          'Expected array or object, or attribute and value');
     }
   }
 
+
   with(...args) {
     const clone = this.clone();
     clone.add(...args);
@@ -1739,6 +1791,10 @@ export class Template {
   }
 
   static validateSlotValueAgainstDescription(value, description) {
+    if (disabledSlotValidation) {
+      return true;
+    }
+
     if (value === undefined) {
       throw new TypeError(`Specify value as null or don't specify at all`);
     }
@@ -1916,17 +1972,11 @@ export class Template {
     return this.content.toString();
   }
 
-  static resolve(tagOrTemplate) {
+  static resolve(content) {
     // Flattens contents of a template, recursively "resolving" until a
     // non-template is ready (or just returns a provided non-template
     // argument as-is).
 
-    if (!(tagOrTemplate instanceof Template)) {
-      return tagOrTemplate;
-    }
-
-    let {content} = tagOrTemplate;
-
     while (content instanceof Template) {
       content = content.content;
     }
@@ -1934,7 +1984,7 @@ export class Template {
     return content;
   }
 
-  static resolveForSlots(tagOrTemplate, slots) {
+  static resolveForSlots(content, slots) {
     if (!slots || typeof slots !== 'object') {
       throw new Error(
         `Expected slots to be an object or array, ` +
@@ -1942,18 +1992,18 @@ export class Template {
     }
 
     if (!Array.isArray(slots)) {
-      return Template.resolveForSlots(tagOrTemplate, Object.keys(slots)).slots(slots);
+      return Template.resolveForSlots(content, Object.keys(slots)).slots(slots);
     }
 
-    while (tagOrTemplate && tagOrTemplate instanceof Template) {
+    while (content instanceof Template) {
       try {
         for (const slot of slots) {
-          tagOrTemplate.getSlotDescription(slot);
+          content.getSlotDescription(slot);
         }
 
-        return tagOrTemplate;
+        return content;
       } catch {
-        tagOrTemplate = tagOrTemplate.content;
+        content = content.content;
       }
     }
 
diff --git a/src/page/album.js b/src/page/album.js
index 696e2854..e585618c 100644
--- a/src/page/album.js
+++ b/src/page/album.js
@@ -8,15 +8,22 @@ export function targets({wikiData}) {
 
 export function pathsForTarget(album) {
   return [
-    {
-      type: 'page',
-      path: ['album', album.directory],
-
-      contentFunction: {
-        name: 'generateAlbumInfoPage',
-        args: [album],
-      },
-    },
+    (album.style === 'single'
+      ? {
+          type: 'redirect',
+          fromPath: ['album', album.directory],
+          toPath: ['track', album.tracks[0].directory],
+          title: album.name,
+        }
+      : {
+          type: 'page',
+          path: ['album', album.directory],
+
+          contentFunction: {
+            name: 'generateAlbumInfoPage',
+            args: [album],
+          },
+        }),
 
     {
       type: 'page',
diff --git a/src/page/artist.js b/src/page/artist.js
index 257e060d..bd8b8988 100644
--- a/src/page/artist.js
+++ b/src/page/artist.js
@@ -32,6 +32,16 @@ export function pathsForTarget(artist) {
         args: [artist],
       },
     },
+
+    {
+      type: 'page',
+      path: ['artistRollingWindow', artist.directory],
+
+      contentFunction: {
+        name: 'generateArtistRollingWindowPage',
+        args: [artist],
+      },
+    },
   ];
 }
 
diff --git a/src/replacer.js b/src/replacer.js
index 0698eced..779ee78d 100644
--- a/src/replacer.js
+++ b/src/replacer.js
@@ -8,7 +8,7 @@
 import * as marked from 'marked';
 
 import * as html from '#html';
-import {escapeRegex, typeAppearance} from '#sugar';
+import {empty, escapeRegex, typeAppearance} from '#sugar';
 import {matchMarkdownLinks} from '#wiki-data';
 
 export const replacerSpec = {
@@ -464,7 +464,7 @@ export function squashBackslashes(text) {
   // a set of characters where the backslash carries meaning
   // into later formatting (i.e. markdown). Note that we do
   // NOT compress double backslashes into single backslashes.
-  return text.replace(/([^\\](?:\\{2})*)\\(?![\\*_~>-])/g, '$1');
+  return text.replace(/([^\\](?:\\{2})*)\\(?![\\*_~>.-])/g, '$1');
 }
 
 export function restoreRawHTMLTags(text) {
@@ -526,6 +526,7 @@ export function postprocessComments(inputNodes) {
 
 function postprocessHTMLTags(inputNodes, tagName, callback) {
   const outputNodes = [];
+  const errors = [];
 
   const lastNode = inputNodes.at(-1);
 
@@ -593,10 +594,16 @@ function postprocessHTMLTags(inputNodes, tagName, callback) {
           return false;
         })();
 
-        outputNodes.push(
-          callback(attributes, {
-            inline,
-          }));
+        try {
+          outputNodes.push(
+            callback(attributes, {
+              inline,
+            }));
+        } catch (caughtError) {
+          errors.push(new Error(
+            `Failed to process ${match[0]}`,
+            {cause: caughtError}));
+        }
 
         // No longer at the start of a line after the tag - there will at
         // least be text with only '\n' before the next of this tag that's
@@ -619,15 +626,33 @@ function postprocessHTMLTags(inputNodes, tagName, callback) {
     outputNodes.push(node);
   }
 
+  if (!empty(errors)) {
+    throw new AggregateError(
+      errors,
+    `Errors postprocessing <${tagName}> tags`);
+  }
+
   return outputNodes;
 }
 
+function complainAboutMediaSrc(src) {
+  if (!src) {
+    throw new Error(`Missing "src" attribute`);
+  }
+
+  if (src.startsWith('/media/')) {
+    throw new Error(`Start "src" with "media/", not "/media/"`);
+  }
+}
+
 export function postprocessImages(inputNodes) {
   return postprocessHTMLTags(inputNodes, 'img',
     (attributes, {inline}) => {
       const node = {type: 'image'};
 
       node.src = attributes.get('src');
+      complainAboutMediaSrc(node.src);
+
       node.inline = attributes.get('inline') ?? inline;
 
       if (attributes.get('link')) node.link = attributes.get('link');
@@ -648,10 +673,13 @@ export function postprocessImages(inputNodes) {
 
 export function postprocessVideos(inputNodes) {
   return postprocessHTMLTags(inputNodes, 'video',
-    attributes => {
+    (attributes, {inline}) => {
       const node = {type: 'video'};
 
       node.src = attributes.get('src');
+      complainAboutMediaSrc(node.src);
+
+      node.inline = attributes.get('inline') ?? inline;
 
       if (attributes.get('width')) node.width = parseInt(attributes.get('width'));
       if (attributes.get('height')) node.height = parseInt(attributes.get('height'));
@@ -668,8 +696,12 @@ export function postprocessAudios(inputNodes) {
       const node = {type: 'audio'};
 
       node.src = attributes.get('src');
+      complainAboutMediaSrc(node.src);
+
       node.inline = attributes.get('inline') ?? inline;
+
       if (attributes.get('align')) node.align = attributes.get('align');
+      if (attributes.get('nameless')) node.nameless = true;
 
       return node;
     });
@@ -821,54 +853,108 @@ export function postprocessExternalLinks(inputNodes) {
   return outputNodes;
 }
 
-export function parseContentNodes(input) {
+export function parseContentNodes(input, {
+  errorMode = 'throw',
+} = {}) {
   if (typeof input !== 'string') {
     throw new TypeError(`Expected input to be string, got ${typeAppearance(input)}`);
   }
 
-  try {
-    let output = parseNodes(input, 0);
-    output = postprocessComments(output);
-    output = postprocessImages(output);
-    output = postprocessVideos(output);
-    output = postprocessAudios(output);
-    output = postprocessHeadings(output);
-    output = postprocessSummaries(output);
-    output = postprocessExternalLinks(output);
-    return output;
-  } catch (errorNode) {
-    if (errorNode.type !== 'error') {
-      throw errorNode;
-    }
+  let result = null, error = null;
 
-    const {
-      i,
-      data: {message},
-    } = errorNode;
+  process: {
+    try {
+      result = parseNodes(input, 0);
+    } catch (caughtError) {
+      if (caughtError.type === 'error') {
+        const {i, data: {message}} = caughtError;
 
-    let lineStart = input.slice(0, i).lastIndexOf('\n');
-    if (lineStart >= 0) {
-      lineStart += 1;
-    } else {
-      lineStart = 0;
+        let lineStart = input.slice(0, i).lastIndexOf('\n');
+        if (lineStart >= 0) {
+          lineStart += 1;
+        } else {
+          lineStart = 0;
+        }
+
+        let lineEnd = input.slice(i).indexOf('\n');
+        if (lineEnd >= 0) {
+          lineEnd += i;
+        } else {
+          lineEnd = input.length;
+        }
+
+        const line = input.slice(lineStart, lineEnd);
+
+        const cursor = i - lineStart;
+
+        error =
+          new SyntaxError(
+            `Parse error (at pos ${i}): ${message}\n` +
+            line + `\n` +
+            '-'.repeat(cursor) + '^');
+      } else {
+        error = caughtError;
+      }
+
+      // A parse error means there's no output to continue with at all,
+      // so stop here.
+      break process;
     }
 
-    let lineEnd = input.slice(i).indexOf('\n');
-    if (lineEnd >= 0) {
-      lineEnd += i;
-    } else {
-      lineEnd = input.length;
+    const postprocessErrors = [];
+
+    for (const postprocess of [
+      postprocessComments,
+      postprocessImages,
+      postprocessVideos,
+      postprocessAudios,
+      postprocessHeadings,
+      postprocessSummaries,
+      postprocessExternalLinks,
+    ]) {
+      try {
+        result = postprocess(result);
+      } catch (caughtError) {
+        const error =
+          new Error(
+            `Error in step ${`"${postprocess.name}"`}`,
+            {cause: caughtError});
+
+        error[Symbol.for('hsmusic.aggregate.translucent')] = true;
+
+        postprocessErrors.push(error);
+      }
     }
 
-    const line = input.slice(lineStart, lineEnd);
+    if (!empty(postprocessErrors)) {
+      error =
+        new AggregateError(
+          postprocessErrors,
+        `Errors postprocessing content text`);
 
-    const cursor = i - lineStart;
+      error[Symbol.for('hsmusic.aggregate.translucent')] = 'single';
+    }
+  }
+
+  if (errorMode === 'throw') {
+    if (error) {
+      throw error;
+    } else {
+      return result;
+    }
+  } else if (errorMode === 'return') {
+    if (!result) {
+      result = [{
+        i: 0,
+        iEnd: input.length,
+        type: 'text',
+        data: input,
+      }];
+    }
 
-    throw new SyntaxError([
-      `Parse error (at pos ${i}): ${message}`,
-      line,
-      '-'.repeat(cursor) + '^',
-    ].join('\n'));
+    return {error, result};
+  } else {
+    throw new Error(`Unknown errorMode ${errorMode}`);
   }
 }
 
diff --git a/src/static/css/site.css b/src/static/css/site.css
index 6c0d3926..29c1396a 100644
--- a/src/static/css/site.css
+++ b/src/static/css/site.css
@@ -61,7 +61,7 @@ body::before, .wallpaper-part {
 
 #page-container {
   max-width: 1100px;
-  margin: 0 auto 40px;
+  margin: 0 auto 38px;
   padding: 15px 0;
 }
 
@@ -76,10 +76,25 @@ body::before, .wallpaper-part {
   height: unset;
 }
 
+@property --banner-shine {
+  syntax: '<percentage>';
+  initial-value: 0%;
+  inherits: false;
+}
+
 #banner {
   margin: 10px 0;
   width: 100%;
   position: relative;
+
+  --banner-shine: 4%;
+  -webkit-box-reflect: below -12px linear-gradient(transparent, color-mix(in srgb, transparent, var(--banner-shine) white));
+  transition: --banner-shine 0.8s;
+}
+
+#banner:hover {
+  --banner-shine: 35%;
+  transition-delay: 0.3s;
 }
 
 #banner::after {
@@ -261,7 +276,11 @@ body::before, .wallpaper-part {
 #page-container {
   background-color: var(--bg-color, rgba(35, 35, 35, 0.8));
   color: #ffffff;
-  box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
+  border-bottom: 2px solid #fff1;
+  box-shadow:
+    0 0 40px #0008,
+    0 2px 15px -3px #2221,
+    0 2px 6px 2px #1113;
 }
 
 #skippers > * {
@@ -1013,10 +1032,13 @@ a .normal-content {
 }
 
 .image-media-link::after {
-  content: '';
-  display: inline-block;
-  width: 22px;
-  height: 1em;
+  /* Thanks to Jay Freestone for being awesome:
+   * https://www.jayfreestone.com/writing/wrapping-and-inline-pseudo-elements/
+   */
+
+  pointer-events: none;
+  content: '\200b';
+  padding-left: 22px;
 
   background-color: var(--primary-color);
 
@@ -1027,7 +1049,6 @@ a .normal-content {
 
   mask-repeat: no-repeat;
   mask-position: calc(100% - 2px);
-  vertical-align: text-bottom;
 }
 
 .image-media-link:hover::after {
@@ -1095,7 +1116,16 @@ a .normal-content {
   font-weight: 800;
 }
 
+.dot-switcher > span {
+  color: #ffffffcc;
+}
+
 .dot-switcher > span.current {
+  font-weight: normal;
+  color: white;
+}
+
+.dot-switcher > span.current a {
   font-weight: 800;
 }
 
@@ -1109,6 +1139,15 @@ a .normal-content {
   text-decoration: none !important;
 }
 
+label:hover span {
+  text-decoration: underline;
+  text-decoration-style: solid;
+}
+
+label > input[type=checkbox]:not(:checked) + span {
+  opacity: 0.8;
+}
+
 #secondary-nav {
   text-align: center;
 
@@ -1296,6 +1335,15 @@ li:not(:first-child:last-child) .tooltip:where(:not(.cover-artwork .tooltip)),
   height: 1.4em;
 }
 
+.contribution-tooltip .chronology-heading {
+  grid-column-start: handle-start;
+  grid-column-end: platform-end;
+
+  font-size: 0.85em;
+  font-style: oblique;
+  margin-bottom: 2px;
+}
+
 .contribution-tooltip .chronology-link {
   display: grid;
   grid-column-start: icon-start;
@@ -1503,10 +1551,26 @@ s.spoiler::-moz-selection {
   background: white;
 }
 
-span.path {
-  font-size: 0.9em;
+span.path, code.filename {
+  font-size: 0.95em;
   font-family: "courier new", monospace;
   font-weight: 800;
+  background: #ccc3;
+
+  padding: 0.05em 0.5ch;
+  border: 1px solid #ccce;
+  border-radius: 2px;
+  line-height: 1.4;
+}
+
+.image-details code.filename {
+  margin-left: -0.4ch;
+  opacity: 0.8;
+}
+
+.image-details code.filename:hover {
+  opacity: 1;
+  cursor: text;
 }
 
 span.path i {
@@ -1755,6 +1819,15 @@ p.image-details.origin-details .origin-details {
   color: var(--primary-color);
 }
 
+.inherited-commentary-section {
+  clear: right;
+  margin-top: 1em;
+  margin-right: min(4vw, 60px);
+  border: 2px solid var(--deep-color);
+  border-radius: 4px;
+  background: #ffffff07;
+}
+
 .commentary-art {
   float: right;
   width: 30%;
@@ -1796,6 +1869,35 @@ p.image-details.origin-details .origin-details {
   margin-top: 0.25em;
 }
 
+.lyrics-entry {
+  clip-path: inset(-15px -20px);
+}
+
+.lyrics-entry::after {
+  content: "";
+  pointer-events: none;
+  display: block;
+
+  /* Slight stretching past the bottom of the screen seems
+   * to make resizing the window (and "revealing" that area)
+   * a bit smoother.
+   */
+  position: fixed;
+  bottom: -20px;
+  left: 0;
+  right: 0;
+
+  height: calc(20px + min(90px, 13.5vh));
+  background: linear-gradient(to bottom, transparent, black 70%, black);
+  opacity: 0.6;
+}
+
+.lyrics-entry sup {
+  vertical-align: text-top;
+  opacity: 0.8;
+  cursor: default;
+}
+
 .js-hide,
 .js-show-once-data,
 .js-hide-once-data {
@@ -1815,12 +1917,20 @@ p.image-details.origin-details .origin-details {
   margin-bottom: 1.5em;
 }
 
-a.align-center, img.align-center, audio.align-center {
+.content-image-container.align-full {
+  width: 100%;
+}
+
+a.align-center, img.align-center, audio.align-center, video.align-center {
   display: block;
   margin-left: auto;
   margin-right: auto;
 }
 
+a.align-full, img.align-full, video.align-full {
+  width: 100%;
+}
+
 center {
   margin-top: 1em;
   margin-bottom: 1em;
@@ -1935,13 +2045,32 @@ ul.quick-info li:not(:last-child)::after {
   text-align: center;
 }
 
-.gallery-view-switcher {
+.gallery-view-switcher,
+.gallery-style-selector {
   margin-left: auto;
   margin-right: auto;
   text-align: center;
   line-height: 1.4;
 }
 
+.gallery-style-selector .styles {
+  display: inline-flex;
+  justify-content: center;
+}
+
+.gallery-style-selector .styles label:not(:last-child) {
+  margin-right: 1.25ch;
+}
+
+.gallery-style-selector .count {
+  font-size: 0.85em;
+
+  position: relative;
+  bottom: -0.25em;
+
+  opacity: 0.9;
+}
+
 #content.top-index section {
   margin-bottom: 1.5em;
 }
@@ -2205,31 +2334,54 @@ li .origin-details {
 
 #content hr {
   border: 1px inset #808080;
-  width: 100%;
+}
+
+#content hr.split {
+  color: #808080;
 }
 
 #content hr.split::before {
   content: "(split)";
-  color: #808080;
 }
 
-#content hr.split {
+#content hr.main-separator {
+  color: var(--dim-color);
+  clear: none;
+  margin-top: -0.25em;
+  margin-bottom: 1.75em;
+}
+
+#content hr.main-separator::before {
+  content: "♦";
+  font-size: 1.2em;
+}
+
+#content hr.split,
+#content hr.main-separator {
   position: relative;
   overflow: hidden;
   border: none;
 }
 
-#content hr.split::after {
+#content hr.split::after,
+#content hr.main-separator::after {
   display: inline-block;
   content: "";
-  border: 1px inset #808080;
-  width: 100%;
+  width: calc(100% - min(calc(8vw - 35px), 45px));
   position: absolute;
   top: 50%;
-  margin-top: -2px;
   margin-left: 10px;
 }
 
+#content hr.split::after {
+  border: 1px inset currentColor;
+  margin-top: -2px;
+}
+
+#content hr.main-separator::after {
+  border-bottom: 1px solid currentColor;
+}
+
 li > ul {
   margin-top: 5px;
 }
@@ -2309,6 +2461,65 @@ html[data-url-key="localized.albumCommentary"] p.track-info {
   margin-left: 20px;
 }
 
+html[data-url-key="localized.artistRollingWindow"] #content p {
+  text-align: center;
+}
+
+html[data-url-key="localized.artistRollingWindow"] #content input[type=number] {
+  width: 3em;
+  margin: 0 0.25em;
+  background: black;
+  color: white;
+  border: 1px dotted var(--primary-color);
+  padding: 4px;
+  border-radius: 3px;
+}
+
+html[data-url-key="localized.artistRollingWindow"] #timeframe-selection-control a {
+  display: inline-block;
+  padding: 5px;
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+html[data-url-key="localized.artistRollingWindow"] #timeframe-selection-control a:not([href]) {
+  text-decoration: none;
+  opacity: 0.7;
+}
+
+html[data-url-key="localized.artistRollingWindow"] #timeframe-source-area {
+  border: 1px dashed #ffffff42;
+  border-top-style: solid;
+  border-bottom-style: solid;
+
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  min-height: calc(100vh - 260px);
+}
+
+html[data-url-key="localized.artistRollingWindow"] #timeframe-source-area .grid-listing {
+  width: 100%;
+}
+
+html[data-url-key="localized.artistRollingWindow"] .grid-item.peeking {
+  opacity: 0.8;
+  background: #ffffff24;
+}
+
+html[data-url-key="localized.artistRollingWindow"] .grid-item > span:not(:first-of-type) {
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  flex-wrap: wrap;
+}
+
+html[data-url-key="localized.artistRollingWindow"] .grid-item > span:not(:first-of-type) > *:not([style*="display: none"]) ~ *::before {
+  content: '\00b7';
+  margin-left: 0.5ch;
+  margin-right: 0.5ch;
+}
+
 html[data-url-key="localized.groupInfo"] .by a {
   color: var(--page-primary-color);
 }
@@ -2602,6 +2813,7 @@ html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:las
 .content-video-container,
 .content-audio-container {
   width: fit-content;
+  max-width: 100%;
   background-color: var(--dark-color);
   border: 2px solid var(--primary-color);
   border-radius: 2.5px 2.5px 3px 3px;
@@ -2611,6 +2823,7 @@ html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:las
 .content-video-container video,
 .content-audio-container audio {
   display: block;
+  max-width: 100%;
 }
 
 .content-video-container.align-center,
@@ -2619,6 +2832,11 @@ html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:las
   margin-right: auto;
 }
 
+.content-video-container.align-full,
+.content-audio-container.align-full {
+  width: 100%;
+}
+
 .content-audio-container .filename {
   color: white;
   font-family: monospace;
@@ -2685,6 +2903,23 @@ img {
   object-fit: cover;
 }
 
+.image {
+  --reveal-filter: ;
+  --shadow-filter: ;
+
+  backdrop-filter: blur(0);
+  filter:
+    var(--reveal-filter)
+    var(--shadow-filter);
+}
+
+p > img, li > img {
+  max-width: 100%;
+  object-fit: contain;
+  height: auto;
+  vertical-align: text-bottom;
+}
+
 .image-inner-area::after {
   content: "";
   display: block;
@@ -2744,9 +2979,9 @@ video.pixelate, .pixelate video {
   text-decoration-style: dotted;
 }
 
-.reveal .image {
+.reveal:not(.revealed) .image {
   opacity: 0.7;
-  filter: blur(20px) brightness(0.7);
+  --reveal-filter: blur(20px) brightness(0.7);
 }
 
 .reveal .image.reveal-thumbnail {
@@ -2770,7 +3005,6 @@ video.pixelate, .pixelate video {
 }
 
 .reveal.revealed .image {
-  filter: none;
   opacity: 1;
 }
 
@@ -2781,7 +3015,6 @@ video.pixelate, .pixelate video {
 .reveal:not(.revealed) .image-outer-area > * {
   --reveal-border-radius: 6px;
   position: relative;
-  overflow: hidden;
   border-radius: var(--reveal-border-radius);
 }
 
@@ -2817,7 +3050,7 @@ video.pixelate, .pixelate video {
 }
 
 .reveal:not(.revealed) .image-outer-area > *:hover .image {
-  filter: blur(20px) brightness(0.6);
+  --reveal-filter: blur(20px) brightness(0.6);
   opacity: 0.6;
 }
 
@@ -2843,20 +3076,88 @@ video.pixelate, .pixelate video {
   justify-content: center;
   align-items: flex-start;
   padding: 5px 15px;
+  box-sizing: border-box;
+}
+
+.grid-listing:not(:has(.grid-item:not([class*="hidden-by-"]))) {
+  padding-bottom: 140px;
+  background: #cccccc07;
+  border-radius: 10px;
+  border: 1px dashed #fff3;
+}
+
+.grid-listing .reveal-all-container {
+  flex-basis: 100%;
+}
+
+.grid-listing:not(:has(.grid-item:not([class*="hidden-by-"]))) .reveal-all-container {
+  display: none;
+}
+
+.grid-listing .reveal-all-container.has-nearby-tab {
+  margin-bottom: 0.6em;
+}
+
+.grid-listing .reveal-all {
+  max-width: 400px;
+  margin: 0.20em auto 0;
+  text-align: center;
+}
+
+.grid-listing .reveal-all .warnings:not(.reveal-all:hover *) {
+  opacity: 0.4;
+}
+
+.grid-listing .reveal-all a {
+  display: inline-block;
+  margin-bottom: 0.15em;
+
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+.grid-listing .reveal-all b {
+  white-space: nowrap;
 }
 
 .grid-item {
+  line-height: 1.2;
   font-size: 0.9em;
 }
 
 .grid-item {
+  --tab-pull: 0px;
+  --tabnt-offset: 0px;
+
   display: inline-block;
   text-align: center;
   background-color: #111111;
   border: 1px dotted var(--primary-color);
   border-radius: 2px;
   padding: 5px;
+
   margin: 10px;
+  margin-top:
+    calc(
+       10px
+     - var(--tab-pull)
+     + var(--tabnt-offset));
+}
+
+.grid-item.has-tab {
+  border-radius: 8px 8px 3px 3px;
+}
+
+.grid-item.has-tab:hover {
+  --tab-pull: 3px;
+}
+
+.grid-item:not(.has-tab) {
+  --tabnt-offset: calc(1.2em - 4px);
+}
+
+.grid-item[class*="hidden-by-"] {
+  display: none;
 }
 
 .grid-item .image-container {
@@ -2873,10 +3174,16 @@ video.pixelate, .pixelate video {
 }
 
 .grid-item .image {
+  --shadow-filter:
+    drop-shadow(0 3px 2px #0004)
+    drop-shadow(0 1px 5px #0001)
+    drop-shadow(0 3px 4px #0001);
+
   width: 100%;
   height: 100% !important;
   margin-top: auto;
   margin-bottom: auto;
+  object-fit: contain;
 }
 
 .grid-item:hover {
@@ -2893,20 +3200,27 @@ video.pixelate, .pixelate video {
   hyphens: auto;
 }
 
-.grid-item > span:not(:first-child) {
-  margin-top: 2px;
-}
+/* tab */
+.grid-item > span:first-child {
+  margin-bottom: calc(3px + var(--tab-pull));
 
-.grid-item > span:first-of-type {
-  margin-top: 6px;
+  font-style: oblique;
 }
 
-.grid-item > span:not(:first-of-type) {
+/* info */
+.grid-item > .image-container + span ~ span {
+  margin-top: 2px;
+
   font-size: 0.9em;
   opacity: 0.8;
 }
 
-.grid-item:hover > span:first-of-type {
+/* title */
+.grid-item > .image-container + span {
+  margin-top: 6px;
+}
+
+.grid-item:hover > .image-container + span {
   text-decoration: underline;
 }
 
@@ -2956,7 +3270,6 @@ video.pixelate, .pixelate video {
   left: 0;
   right: 0;
   bottom: 0;
-  z-index: -20;
   background-color: var(--dim-color);
   filter: brightness(0.6);
 }
@@ -3346,15 +3659,11 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   grid-template-columns: 1fr min(40%, 90px);
 }
 
-.content-sticky-heading-root.has-cover {
-  padding-right: min(40%, 400px);
-}
-
 .content-sticky-heading-row h1 {
   position: relative;
   margin: 0;
   padding-right: 20px;
-  line-height: 1.4;
+  overflow-x: hidden;
 }
 
 .content-sticky-heading-row h1 .reference-collapsed-heading {
@@ -3494,7 +3803,9 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
 }
 
 #content, .sidebar {
-  contain: paint;
+  /* In the year of our pizza 2025, we try commenting this out.
+   */
+  /*contain: paint;*/
 }
 
 /* Sticky sidebar */
diff --git a/src/static/js/client-util.js b/src/static/js/client-util.js
index 71112313..5a35bcf2 100644
--- a/src/static/js/client-util.js
+++ b/src/static/js/client-util.js
@@ -127,3 +127,10 @@ export function dispatchInternalEvent(event, eventName, ...args) {
 
   return results;
 }
+
+const languageCode = document.documentElement.getAttribute('lang');
+
+export function formatDate(inputDate) {
+  const date = new Date(inputDate);
+  return date.toLocaleDateString(languageCode);
+}
diff --git a/src/static/js/client/artist-rolling-window.js b/src/static/js/client/artist-rolling-window.js
new file mode 100644
index 00000000..b201e7df
--- /dev/null
+++ b/src/static/js/client/artist-rolling-window.js
@@ -0,0 +1,573 @@
+/* eslint-env browser */
+
+import {cssProp, formatDate} from '../client-util.js';
+
+import {sortByDate} from '../../shared-util/sort.js';
+import {chunkByConditions, chunkByProperties, empty, stitchArrays}
+  from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'artistRollingWindowInfo',
+
+  timeframeMonthsBefore: null,
+  timeframeMonthsAfter: null,
+  timeframeMonthsPeek: null,
+
+  contributionKind: null,
+  contributionGroup: null,
+
+  timeframeSelectionSomeLine: null,
+  timeframeSelectionNoneLine: null,
+
+  timeframeSelectionContributionCount: null,
+  timeframeSelectionTimeframeCount: null,
+  timeframeSelectionFirstDate: null,
+  timeframeSelectionLastDate: null,
+
+  timeframeSelectionControl: null,
+  timeframeSelectionMenu: null,
+  timeframeSelectionPrevious: null,
+  timeframeSelectionNext: null,
+
+  timeframeEmptyLine: null,
+
+  sourceArea: null,
+  sourceGrid: null,
+  sources: null,
+};
+
+export function getPageReferences() {
+  if (document.documentElement.dataset.urlKey !== 'localized.artistRollingWindow') {
+    return;
+  }
+
+  info.timeframeMonthsBefore =
+    document.getElementById('timeframe-months-before');
+
+  info.timeframeMonthsAfter =
+    document.getElementById('timeframe-months-after');
+
+  info.timeframeMonthsPeek =
+    document.getElementById('timeframe-months-peek');
+
+  info.contributionKind =
+    document.getElementById('contribution-kind');
+
+  info.contributionGroup =
+    document.getElementById('contribution-group');
+
+  info.timeframeSelectionSomeLine =
+    document.getElementById('timeframe-selection-some');
+
+  info.timeframeSelectionNoneLine =
+    document.getElementById('timeframe-selection-none');
+
+  info.timeframeSelectionContributionCount =
+    document.getElementById('timeframe-selection-contribution-count');
+
+  info.timeframeSelectionTimeframeCount =
+    document.getElementById('timeframe-selection-timeframe-count');
+
+  info.timeframeSelectionFirstDate =
+    document.getElementById('timeframe-selection-first-date');
+
+  info.timeframeSelectionLastDate =
+    document.getElementById('timeframe-selection-last-date');
+
+  info.timeframeSelectionControl =
+    document.getElementById('timeframe-selection-control');
+
+  info.timeframeSelectionMenu =
+    document.getElementById('timeframe-selection-menu');
+
+  info.timeframeSelectionPrevious =
+    document.getElementById('timeframe-selection-previous');
+
+  info.timeframeSelectionNext =
+    document.getElementById('timeframe-selection-next');
+
+  info.timeframeEmptyLine =
+    document.getElementById('timeframe-empty');
+
+  info.sourceArea =
+    document.getElementById('timeframe-source-area');
+
+  info.sourceGrid =
+    info.sourceArea.querySelector('.grid-listing');
+
+  info.sources =
+    info.sourceGrid.getElementsByClassName('grid-item');
+}
+
+export function addPageListeners() {
+  if (!info.sourceArea) {
+    return;
+  }
+
+  for (const input of [
+    info.timeframeMonthsBefore,
+    info.timeframeMonthsAfter,
+    info.timeframeMonthsPeek,
+    info.contributionKind,
+    info.contributionGroup,
+  ]) {
+    input.addEventListener('change', () => {
+      updateArtistRollingWindow()
+    });
+  }
+
+  info.timeframeSelectionMenu.addEventListener('change', () => {
+    updateRollingWindowTimeframeSelection();
+  });
+
+  const eatClicks = (element, callback) => {
+    element.addEventListener('click', domEvent => {
+      domEvent.preventDefault();
+      callback();
+    });
+
+    element.addEventListener('mousedown', domEvent => {
+      if (domEvent.detail > 1) {
+        domEvent.preventDefault();
+      }
+    });
+  };
+
+  eatClicks(info.timeframeSelectionNext, nextRollingTimeframeSelection);
+  eatClicks(info.timeframeSelectionPrevious, previousRollingTimeframeSelection);
+}
+
+export function mutatePageContent() {
+  if (!info.sourceArea) {
+    return;
+  }
+
+  updateArtistRollingWindow();
+}
+
+function previousRollingTimeframeSelection() {
+  const menu = info.timeframeSelectionMenu;
+
+  if (menu.selectedIndex > 0) {
+    menu.selectedIndex--;
+  }
+
+  updateRollingWindowTimeframeSelection();
+}
+
+function nextRollingTimeframeSelection() {
+  const menu = info.timeframeSelectionMenu;
+
+  if (menu.selectedIndex < menu.length - 1) {
+    menu.selectedIndex++;
+  }
+
+  updateRollingWindowTimeframeSelection();
+}
+
+function getArtistRollingWindowSourceInfo() {
+  const sourceElements =
+    Array.from(info.sources);
+
+  const sourceTimeElements =
+    sourceElements
+      .map(el => Array.from(el.getElementsByTagName('time')));
+
+  const sourceTimeClasses =
+    sourceTimeElements
+      .map(times => times
+        .map(time => Array.from(time.classList)));
+
+  const sourceKinds =
+    sourceTimeClasses
+      .map(times => times
+        .map(classes => classes
+          .find(cl => cl.endsWith('-contribution-date'))
+          .slice(0, -'-contribution-date'.length)));
+
+  const sourceGroups =
+    sourceElements
+      .map(el =>
+        Array.from(el.querySelectorAll('.contribution-group'))
+          .map(data => data.value));
+
+  const sourceDates =
+    sourceTimeElements
+      .map(times => times
+        .map(time => new Date(time.getAttribute('datetime'))));
+
+  return stitchArrays({
+    element: sourceElements,
+    kinds: sourceKinds,
+    groups: sourceGroups,
+    dates: sourceDates,
+  });
+}
+
+function getArtistRollingWindowTimeframeInfo() {
+  const contributionKind =
+    info.contributionKind.value;
+
+  const contributionGroup =
+    info.contributionGroup.value;
+
+  const sourceInfo =
+    getArtistRollingWindowSourceInfo();
+
+  const principalSources =
+    sourceInfo.filter(source => {
+      if (!source.kinds.includes(contributionKind)) {
+        return false;
+      }
+
+      if (contributionGroup !== '-') {
+        if (!source.groups.includes(contributionGroup)) {
+          return false;
+        }
+      }
+
+      return true;
+    });
+
+  const principalSourceDates =
+    principalSources.map(source =>
+      stitchArrays({
+        kind: source.kinds,
+        date: source.dates,
+      }).find(({kind}) => kind === contributionKind)
+        .date);
+
+  const getPeekDate = inputDate => {
+    const date = new Date(inputDate);
+
+    date.setMonth(
+      (date.getMonth()
+     - parseInt(info.timeframeMonthsBefore.value)
+     - parseInt(info.timeframeMonthsPeek.value)));
+
+    return date;
+  };
+
+  const getEntranceDate = inputDate => {
+    const date = new Date(inputDate);
+
+    date.setMonth(
+      (date.getMonth()
+     - parseInt(info.timeframeMonthsBefore.value)));
+
+    return date;
+  };
+
+  const getExitDate = inputDate => {
+    const date = new Date(inputDate);
+
+    date.setMonth(
+      (date.getMonth()
+     + parseInt(info.timeframeMonthsAfter.value)));
+
+    return date;
+  };
+
+  const principalSourceIndices =
+    Array.from({length: principalSources.length}, (_, i) => i);
+
+  const timeframeSourceChunks =
+    chunkByConditions(principalSourceIndices, [
+      (previous, next) =>
+        +principalSourceDates[previous] !==
+        +principalSourceDates[next],
+    ]);
+
+  const timeframeSourceChunkDates =
+    timeframeSourceChunks
+      .map(indices => indices[0])
+      .map(index => principalSourceDates[index]);
+
+  const timeframeSourceChunkPeekDates =
+    timeframeSourceChunkDates
+      .map(getPeekDate);
+
+  const timeframeSourceChunkEntranceDates =
+    timeframeSourceChunkDates
+      .map(getEntranceDate);
+
+  const timeframeSourceChunkExitDates =
+    timeframeSourceChunkDates
+      .map(getExitDate);
+
+  const peekDateInfo =
+    stitchArrays({
+      peek: timeframeSourceChunkPeekDates,
+      indices: timeframeSourceChunks,
+    }).map(({peek, indices}) => ({
+        date: peek,
+        peek: indices,
+      }));
+
+  const entranceDateInfo =
+    stitchArrays({
+      entrance: timeframeSourceChunkEntranceDates,
+      indices: timeframeSourceChunks,
+    }).map(({entrance, indices}) => ({
+        date: entrance,
+        entrance: indices,
+      }));
+
+  const exitDateInfo =
+    stitchArrays({
+      exit: timeframeSourceChunkExitDates,
+      indices: timeframeSourceChunks,
+    }).map(({exit, indices}) => ({
+        date: exit,
+        exit: indices,
+      }));
+
+  const dateInfoChunks =
+    chunkByProperties(
+      sortByDate([
+        ...peekDateInfo,
+        ...entranceDateInfo,
+        ...exitDateInfo,
+      ]),
+      ['date']);
+
+  const dateInfo =
+    dateInfoChunks
+      .map(({chunk}) =>
+        Object.assign({
+          peek: null,
+          entrance: null,
+          exit: null,
+        }, ...chunk));
+
+  const timeframeInfo =
+    dateInfo.reduce(
+      (accumulator, {date, peek, entrance, exit}) => {
+        const previous = accumulator.at(-1);
+
+        // These mustn't be mutated!
+        let peeking = (previous ? previous.peeking : []);
+        let tracking = (previous ? previous.tracking : []);
+
+        if (peek) {
+          peeking =
+            peeking.concat(peek);
+        }
+
+        if (entrance) {
+          peeking =
+            peeking.filter(index => !entrance.includes(index));
+
+          tracking =
+            tracking.concat(entrance);
+        }
+
+        if (exit) {
+          tracking =
+            tracking.filter(index => !exit.includes(index));
+        }
+
+        return [...accumulator, {
+          date,
+          peeking,
+          tracking,
+          peek,
+          entrance,
+          exit,
+        }];
+      },
+      []);
+
+  const indicesToSources = indices =>
+    (indices
+      ? indices.map(index => principalSources[index])
+      : null);
+
+  const finalizedTimeframeInfo =
+    timeframeInfo.map(({
+      date,
+      peeking,
+      tracking,
+      peek,
+      entrance,
+      exit,
+    }) => ({
+      date,
+      peeking: indicesToSources(peeking),
+      tracking: indicesToSources(tracking),
+      peek: indicesToSources(peek),
+      entrance: indicesToSources(entrance),
+      exit: indicesToSources(exit),
+    }));
+
+  return finalizedTimeframeInfo;
+}
+
+function updateArtistRollingWindow() {
+  const timeframeInfo =
+    getArtistRollingWindowTimeframeInfo();
+
+  if (empty(timeframeInfo)) {
+    cssProp(info.timeframeSelectionControl, 'display', 'none');
+    cssProp(info.timeframeSelectionSomeLine, 'display', 'none');
+    cssProp(info.timeframeSelectionNoneLine, 'display', null);
+
+    updateRollingWindowTimeframeSelection(timeframeInfo);
+
+    return;
+  }
+
+  cssProp(info.timeframeSelectionControl, 'display', null);
+  cssProp(info.timeframeSelectionSomeLine, 'display', null);
+  cssProp(info.timeframeSelectionNoneLine, 'display', 'none');
+
+  // The last timeframe is just the exit of the final tracked sources,
+  // so we aren't going to display a menu option for it, and will just use
+  // it as the end of the final option's date range.
+
+  const usedTimeframes = timeframeInfo.slice(0, -1);
+  const firstTimeframe = timeframeInfo.at(0);
+  const lastTimeframe = timeframeInfo.at(-1);
+
+  const sourceCount =
+    timeframeInfo
+      .flatMap(({entrance}) => entrance ?? [])
+      .length;
+
+  const timeframeCount =
+    usedTimeframes.length;
+
+  info.timeframeSelectionContributionCount.innerText = sourceCount;
+  info.timeframeSelectionTimeframeCount.innerText = timeframeCount;
+
+  const firstDate = firstTimeframe.date;
+  const lastDate = lastTimeframe.date;
+
+  info.timeframeSelectionFirstDate.innerText = formatDate(firstDate);
+  info.timeframeSelectionLastDate.innerText = formatDate(lastDate);
+
+  while (info.timeframeSelectionMenu.firstChild) {
+    info.timeframeSelectionMenu.firstChild.remove();
+  }
+
+  for (const [index, timeframe] of usedTimeframes.entries()) {
+    const nextTimeframe = timeframeInfo[index + 1];
+
+    const option = document.createElement('option');
+
+    option.appendChild(document.createTextNode(
+      `${formatDate(timeframe.date)} – ${formatDate(nextTimeframe.date)}`));
+
+    info.timeframeSelectionMenu.appendChild(option);
+  }
+
+  updateRollingWindowTimeframeSelection(timeframeInfo);
+}
+
+function updateRollingWindowTimeframeSelection(timeframeInfo) {
+  timeframeInfo ??= getArtistRollingWindowTimeframeInfo();
+
+  updateRollingWindowTimeframeSelectionControls(timeframeInfo);
+  updateRollingWindowTimeframeSelectionSources(timeframeInfo);
+}
+
+function updateRollingWindowTimeframeSelectionControls(timeframeInfo) {
+  const currentIndex =
+    info.timeframeSelectionMenu.selectedIndex;
+
+  const atFirstTimeframe =
+    currentIndex === 0;
+
+  // The last actual timeframe is empty and not displayed as a menu option.
+  const atLastTimeframe =
+    currentIndex === timeframeInfo.length - 2;
+
+  if (atFirstTimeframe) {
+    info.timeframeSelectionPrevious.removeAttribute('href');
+  } else {
+    info.timeframeSelectionPrevious.setAttribute('href', '#');
+  }
+
+  if (atLastTimeframe) {
+    info.timeframeSelectionNext.removeAttribute('href');
+  } else {
+    info.timeframeSelectionNext.setAttribute('href', '#');
+  }
+}
+
+function updateRollingWindowTimeframeSelectionSources(timeframeInfo) {
+  const currentIndex =
+    info.timeframeSelectionMenu.selectedIndex;
+
+  const contributionGroup =
+    info.contributionGroup.value;
+
+  cssProp(info.sourceGrid, 'display', null);
+
+  const {peeking: peekingSources, tracking: trackingSources} =
+    (empty(timeframeInfo)
+      ? {peeking: [], tracking: []}
+      : timeframeInfo[currentIndex]);
+
+  const peekingElements =
+    peekingSources.map(source => source.element);
+
+  const trackingElements =
+    trackingSources.map(source => source.element);
+
+  const showingElements =
+    [...trackingElements, ...peekingElements];
+
+  const hidingElements =
+    Array.from(info.sources)
+      .filter(element =>
+        !peekingElements.includes(element) &&
+        !trackingElements.includes(element));
+
+  for (const element of peekingElements) {
+    element.classList.add('peeking');
+    element.classList.remove('tracking');
+  }
+
+  for (const element of trackingElements) {
+    element.classList.remove('peeking');
+    element.classList.add('tracking');
+  }
+
+  for (const element of hidingElements) {
+    element.classList.remove('peeking');
+    element.classList.remove('tracking');
+    cssProp(element, 'display', 'none');
+  }
+
+  for (const element of showingElements) {
+    cssProp(element, 'display', null);
+
+    for (const time of element.getElementsByTagName('time')) {
+      for (const className of time.classList) {
+        if (!className.endsWith('-contribution-date')) continue;
+
+        const kind = className.slice(0, -'-contribution-date'.length);
+        if (kind === info.contributionKind.value) {
+          cssProp(time, 'display', null);
+        } else {
+          cssProp(time, 'display', 'none');
+        }
+      }
+    }
+
+    for (const data of element.getElementsByClassName('contribution-group')) {
+      if (contributionGroup === '-' || data.value !== contributionGroup) {
+        cssProp(data, 'display', null);
+      } else {
+        cssProp(data, 'display', 'none');
+      }
+    }
+  }
+
+  if (empty(peekingElements) && empty(trackingElements)) {
+    cssProp(info.timeframeEmptyLine, 'display', null);
+  } else {
+    cssProp(info.timeframeEmptyLine, 'display', 'none');
+  }
+}
diff --git a/src/static/js/client/gallery-style-selector.js b/src/static/js/client/gallery-style-selector.js
new file mode 100644
index 00000000..c7086eae
--- /dev/null
+++ b/src/static/js/client/gallery-style-selector.js
@@ -0,0 +1,123 @@
+/* eslint-env browser */
+
+import {cssProp} from '../client-util.js';
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'galleryStyleSelectorInfo',
+
+  selectors: null,
+  sections: null,
+
+  selectorStyleInputs: null,
+  selectorStyleInputStyles: null,
+
+  selectorReleaseItems: null,
+  selectorReleaseItemStyles: null,
+
+  selectorCountAll: null,
+  selectorCountFiltered: null,
+  selectorCountFilteredCount: null,
+  selectorCountNone: null,
+};
+
+export function getPageReferences() {
+  info.selectors =
+    Array.from(document.querySelectorAll('.gallery-style-selector'));
+
+  info.sections =
+    info.selectors
+      .map(selector => selector.closest('section'));
+
+  info.selectorStyleInputs =
+    info.selectors
+      .map(selector => selector.querySelectorAll('.styles input'))
+      .map(inputs => Array.from(inputs));
+
+  info.selectorStyleInputStyles =
+    info.selectorStyleInputs
+      .map(inputs => inputs
+        .map(input => input.closest('label').dataset.style));
+
+  info.selectorReleaseItems =
+    info.sections
+      .map(section => section.querySelectorAll('.grid-item'))
+      .map(items => Array.from(items));
+
+  info.selectorReleaseItemStyles =
+    info.selectorReleaseItems
+      .map(items => items
+        .map(item => item.dataset.style));
+
+  info.selectorCountAll =
+    info.selectors
+      .map(selector => selector.querySelector('.count.all'));
+
+  info.selectorCountFiltered =
+    info.selectors
+      .map(selector => selector.querySelector('.count.filtered'));
+
+  info.selectorCountFilteredCount =
+    info.selectorCountFiltered
+      .map(selector => selector.querySelector('span'));
+
+  info.selectorCountNone =
+    info.selectors
+      .map(selector => selector.querySelector('.count.none'));
+}
+
+export function addPageListeners() {
+  for (const index of info.selectors.keys()) {
+    for (const input of info.selectorStyleInputs[index]) {
+      input.addEventListener('input', () => updateVisibleReleases(index));
+    }
+  }
+}
+
+function updateVisibleReleases(index) {
+  const inputs = info.selectorStyleInputs[index];
+  const inputStyles = info.selectorStyleInputStyles[index];
+
+  const selectedStyles =
+    stitchArrays({input: inputs, style: inputStyles})
+      .filter(({input}) => input.checked)
+      .map(({style}) => style);
+
+  const releases = info.selectorReleaseItems[index];
+  const releaseStyles = info.selectorReleaseItemStyles[index];
+
+  let visible = 0;
+
+  stitchArrays({
+    release: releases,
+    style: releaseStyles,
+  }).forEach(({release, style}) => {
+      if (selectedStyles.includes(style)) {
+        release.classList.remove('hidden-by-style-mismatch');
+        visible++;
+      } else {
+        release.classList.add('hidden-by-style-mismatch');
+      }
+    });
+
+  const countAll = info.selectorCountAll[index];
+  const countFiltered = info.selectorCountFiltered[index];
+  const countFilteredCount = info.selectorCountFilteredCount[index];
+  const countNone = info.selectorCountNone[index];
+
+  if (visible === releases.length) {
+    cssProp(countAll, 'display', null);
+    cssProp(countFiltered, 'display', 'none');
+    cssProp(countNone, 'display', 'none');
+  } else if (visible === 0) {
+    cssProp(countAll, 'display', 'none');
+    cssProp(countFiltered, 'display', 'none');
+    cssProp(countNone, 'display', null);
+  } else {
+    cssProp(countAll, 'display', 'none');
+    cssProp(countFiltered, 'display', null);
+    cssProp(countNone, 'display', 'none');
+    countFilteredCount.innerHTML = visible;
+  }
+}
diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js
index aeb9264a..016ce9ad 100644
--- a/src/static/js/client/index.js
+++ b/src/static/js/client/index.js
@@ -7,16 +7,19 @@ import * as albumCommentarySidebarModule from './album-commentary-sidebar.js';
 import * as artTagGalleryFilterModule from './art-tag-gallery-filter.js';
 import * as artTagNetworkModule from './art-tag-network.js';
 import * as artistExternalLinkTooltipModule from './artist-external-link-tooltip.js';
+import * as artistRollingWindowModule from './artist-rolling-window.js';
 import * as cssCompatibilityAssistantModule from './css-compatibility-assistant.js';
 import * as datetimestampTooltipModule from './datetimestamp-tooltip.js';
 import * as draggedLinkModule from './dragged-link.js';
 import * as expandableGallerySectionModule from './expandable-gallery-section.js';
+import * as galleryStyleSelectorModule from './gallery-style-selector.js';
 import * as hashLinkModule from './hash-link.js';
 import * as hoverableTooltipModule from './hoverable-tooltip.js';
 import * as imageOverlayModule from './image-overlay.js';
 import * as intrapageDotSwitcherModule from './intrapage-dot-switcher.js';
 import * as liveMousePositionModule from './live-mouse-position.js';
 import * as quickDescriptionModule from './quick-description.js';
+import * as revealAllGridControlModule from './reveal-all-grid-control.js';
 import * as scriptedLinkModule from './scripted-link.js';
 import * as sidebarSearchModule from './sidebar-search.js';
 import * as stickyHeadingModule from './sticky-heading.js';
@@ -30,16 +33,19 @@ export const modules = [
   artTagGalleryFilterModule,
   artTagNetworkModule,
   artistExternalLinkTooltipModule,
+  artistRollingWindowModule,
   cssCompatibilityAssistantModule,
   datetimestampTooltipModule,
   draggedLinkModule,
   expandableGallerySectionModule,
+  galleryStyleSelectorModule,
   hashLinkModule,
   hoverableTooltipModule,
   imageOverlayModule,
   intrapageDotSwitcherModule,
   liveMousePositionModule,
   quickDescriptionModule,
+  revealAllGridControlModule,
   scriptedLinkModule,
   sidebarSearchModule,
   stickyHeadingModule,
diff --git a/src/static/js/client/reveal-all-grid-control.js b/src/static/js/client/reveal-all-grid-control.js
new file mode 100644
index 00000000..1b362bea
--- /dev/null
+++ b/src/static/js/client/reveal-all-grid-control.js
@@ -0,0 +1,72 @@
+/* eslint-env browser */
+
+import {cssProp} from '../client-util.js';
+
+export const info = {
+  id: 'revealAllGridControlInfo',
+
+  revealAllLinks: null,
+  revealables: null,
+
+  revealLabels: null,
+  concealLabels: null,
+};
+
+export function getPageReferences() {
+  info.revealAllLinks =
+    Array.from(document.querySelectorAll('.reveal-all a'));
+
+  info.revealables =
+    info.revealAllLinks
+      .map(link => link.closest('.grid-listing'))
+      .map(listing => listing.querySelectorAll('.reveal'));
+
+  info.revealLabels =
+    info.revealAllLinks
+      .map(link => link.querySelector('.reveal-label'));
+
+  info.concealLabels =
+    info.revealAllLinks
+      .map(link => link.querySelector('.conceal-label'));
+}
+
+export function addPageListeners() {
+  for (const [index, link] of info.revealAllLinks.entries()) {
+    link.addEventListener('click', domEvent => {
+      domEvent.preventDefault();
+      handleRevealAllLinkClicked(index);
+    });
+  }
+}
+
+export function addInternalListeners() {
+  // Don't even think about it. "Reveal all artworks" is a stable control,
+  // meaning it only changes because the user interacted with it directly.
+}
+
+function handleRevealAllLinkClicked(index) {
+  const revealables = info.revealables[index];
+  const revealLabel = info.revealLabels[index];
+  const concealLabel = info.concealLabels[index];
+
+  const shouldReveal =
+    (cssProp(revealLabel, 'display') === 'none'
+      ? false
+      : true);
+
+  for (const revealable of revealables) {
+    if (shouldReveal) {
+      revealable.classList.add('revealed');
+    } else {
+      revealable.classList.remove('revealed');
+    }
+  }
+
+  if (shouldReveal) {
+    cssProp(revealLabel, 'display', 'none');
+    cssProp(concealLabel, 'display', null);
+  } else {
+    cssProp(revealLabel, 'display', null);
+    cssProp(concealLabel, 'display', 'none');
+  }
+}
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index 125cd976..4969bcff 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -368,7 +368,11 @@ releaseInfo:
 
   readCommentary:
     _: "Read {LINK}."
-    link: "artist commentary"
+
+    link:
+      _: "artist commentary"
+      withWikiCommentary: "artist and wiki commentary"
+      onlyWikiCommentary: "wiki commentary"
 
   readCreditingSources:
     _: "Read {LINK}."
@@ -492,7 +496,16 @@ misc:
   # artistCommentary:
 
   artistCommentary:
-    _: "Artist commentary:"
+    _: "Artist commentary for {THING}:"
+    sticky: "Artist commentary:"
+
+    withWikiCommentary:
+      _: "Artist and wiki commentary for {THING}:"
+      sticky: "Artist and wiki commentary:"
+
+    onlyWikiCommentary:
+      _: "Wiki commentary for {THING}:"
+      sticky: "Wiki commentary:"
 
     entry:
       title:
@@ -563,6 +576,10 @@ misc:
     noExternalLinkPlatformName: "Other"
 
     chronology:
+      heading:
+        artistReleases: "Releases by {ARTIST}:"
+        artistTracks: "Tracks by {ARTIST}:"
+
       previous:
         symbol: "←"
         info:
@@ -580,6 +597,7 @@ misc:
         bannerArt: "banner art"
         coverArt: "cover art"
         flash: "flash"
+        release: "release"
         track: "track"
         trackArt: "track art"
         trackContribution: "track contribution"
@@ -620,7 +638,8 @@ misc:
       onlyIndex: "Only"
 
   creditingSources:
-    _: "Crediting sources:"
+    _: "Crediting sources for {THING}:"
+    sticky: "Crediting sources:"
 
   # external:
   #   Links which will generally bring you somewhere off of the wiki.
@@ -648,7 +667,12 @@ misc:
     amazonMusic: "Amazon Music"
     appleMusic: "Apple Music"
     artstation: "ArtStation"
-    bandcamp: "Bandcamp"
+
+    bandcamp:
+      _: "Bandcamp"
+
+      composerRelease: "Bandcamp (composer's release)"
+      officialRelease: "Bandcamp (official release)"
 
     bgreco:
       _: "bgreco.net"
@@ -782,8 +806,10 @@ misc:
   nav:
     previous: "Previous"
     next: "Next"
+
     info: "Info"
     gallery: "Gallery"
+    rollingWindow: "Rolling Window"
 
   # pageTitle:
   #   Title set under the page's <title> HTML element, which is
@@ -840,7 +866,8 @@ misc:
       track: "Tracks"
 
   referencingSources:
-    _: "Referencing sources:"
+    _: "Referencing sources for {THING}:"
+    sticky: "Referencing sources:"
 
   # skippers:
   #
@@ -975,8 +1002,18 @@ misc:
   #   that thing.
 
   coverGrid:
+    revealAll:
+      reveal: "Reveal all artworks"
+      conceal: "Conceal all artworks"
+      warnings: "In this gallery: {WARNINGS}"
+
     noCoverArt: "{ALBUM}"
 
+    tab:
+      groups: "{GROUPS}"
+      artists: "{ARTISTS}"
+      artists.featuring: "{ARTISTS} feat. {FEATURING}"
+
     details:
       notFromThisGroup: "{NAME}{MARKER}"
       notFromThisGroup.marker: "*"
@@ -984,6 +1021,7 @@ misc:
       accent: "({DETAILS})"
 
       albumLength: "{TRACKS}, {TIME}"
+      albumLength.single: "single, {TIME}"
 
       coverArtists: "Artwork by {ARTISTS}"
       coverArtists.customLabel: "{LABEL} by {ARTISTS}"
@@ -1151,6 +1189,9 @@ albumGalleryPage:
   statsLine.withDate: >-
     {TRACKS} totaling {DURATION}. Released {DATE}.
 
+  statsLine.withDate.noDuration: >-
+    Released {DATE}.
+
   # coverArtistsLine:
   #   This is displayed if every track (which has artwork at all)
   #   has the same illustration credits.
@@ -1409,6 +1450,41 @@ artistGalleryPage:
   infoLine: >-
     Contributed to {COVER_ARTS}.
 
+artistRollingWindowPage:
+  title: "{ARTIST} - Rolling Window"
+
+  windowConfigurationLine: >-
+    With a rolling window of {TIME_BEFORE} before a given date, and {TIME_AFTER} after, peeking ahead {PEEK}...
+
+  contributionConfigurationLine: >-
+    Selecting {KIND} contributions from group {GROUP}...
+
+  timeframeSelectionLine:
+    _: >-
+      There are {CONTRIBUTIONS} contributions, making {TIMEFRAMES} timeframes between {FIRST_DATE} and {LAST_DATE}.
+    none: >-
+      There aren't any matching contributions, or those which do aren't dated, so there are no timeframes.
+
+  emptyTimeframeLine: >-
+    This timeframe is empty, since no contributions are in range.
+
+  timeframeSelectionControl:
+    _: "{PREVIOUS} {TIMEFRAMES} {NEXT}"
+    previous: "← Previous"
+    next: "Next →"
+
+  contributionKind:
+    artwork: "Artwork"
+    music: "Music"
+    flash: "Flash"
+
+  contributionGroup:
+    all: "All groups"
+    group: "{GROUP}"
+
+  timeframe:
+    months: "{INPUT} months"
+
 #
 # artTagPage:
 #   Stuff that's common between art tag pages.
@@ -1691,6 +1767,17 @@ groupGalleryPage:
     bySeries: "By series"
     byDate: "By date"
 
+  albumStyleSwitcher:
+    _: "Showing these releases:"
+
+    album: "Albums"
+    single: "Singles"
+
+    count:
+      all: "all {TOTAL}"
+      filtered: "{COUNT} of {TOTAL}"
+      none: "none at all"
+
   albumsByDate:
     title: "All albums"
 
@@ -1807,6 +1894,7 @@ listingPage:
       title: "Albums - by Tracks"
       title.short: "...by Tracks"
       item: "{ALBUM} ({TRACKS})"
+      item.single: "{ALBUM} ({TRACKS}—single)"
 
     # listAlbums.byDuration:
     #   Lists albums by total duration of all tracks, longest to
@@ -2454,6 +2542,8 @@ trackPage:
 
     backToTrack: "Return to track page"
 
+    singleAccent: "single"
+
     track:
       _: "{TRACK}"
       withNumber: "{NUMBER}. {TRACK}"
diff --git a/src/upd8.js b/src/upd8.js
index 40a25dfb..ae072d5a 100755
--- a/src/upd8.js
+++ b/src/upd8.js
@@ -42,8 +42,9 @@ import wrap from 'word-wrap';
 
 import {mapAggregate, openAggregate, showAggregate} from '#aggregate';
 import CacheableObject from '#cacheable-object';
-import {stringifyCache} from '#cli';
+import {formatDuration, stringifyCache} from '#cli';
 import {displayCompositeCacheAnalysis} from '#composite';
+import * as html from '#html';
 import find, {bindFind, getAllFindSpecs} from '#find';
 import {processLanguageFile, watchLanguageFile, internalDefaultStringsFile}
   from '#language';
@@ -518,6 +519,11 @@ async function main() {
       type: 'flag',
     },
 
+    'skip-self-diagnosis': {
+      help: `Disable some runtime validation for the wiki's own code, which speeds up long builds, but may allow unpredicted corner cases to fail strangely and silently`,
+      type: 'flag',
+    },
+
     'queue-size': {
       help: `Process more or fewer disk files at once to optimize performance or avoid I/O errors, unlimited if set to 0 (between 500 and 700 is usually a safe range for building HSMusic on Windows machines)\nDefaults to ${defaultQueueSize}`,
       type: 'value',
@@ -656,7 +662,8 @@ async function main() {
   const thumbsOnly = cliOptions['thumbs-only'] ?? false;
   const noInput = cliOptions['no-input'] ?? false;
 
-  const showAggregateTraces = cliOptions['show-traces'] ?? false;
+  const skipSelfDiagnosis = cliOptions['skip-self-diagnosis'] ?? false;
+  const showTraces = cliOptions['show-traces'] ?? false;
 
   const precacheMode = cliOptions['precache-mode'] ?? 'common';
 
@@ -1156,6 +1163,18 @@ async function main() {
     return false;
   }
 
+  if (skipSelfDiagnosis) {
+    logWarn`${'Skipping code self-diagnosis.'} (--skip-self-diagnosis provided)`;
+    logWarn`This build should run substantially faster, but corner cases`;
+    logWarn`not previously predicted may fail strangely and silently.`;
+
+    html.disableSlotValidation();
+  }
+
+  if (!showTraces) {
+    html.disableTagTracing();
+  }
+
   Object.assign(stepStatusSummary.determineMediaCachePath, {
     status: STATUS_STARTED_NOT_DONE,
     timeStart: Date.now(),
@@ -1334,7 +1353,7 @@ async function main() {
 
   const niceShowAggregate = (error, ...opts) => {
     showAggregate(error, {
-      showTraces: showAggregateTraces,
+      showTraces,
       pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)),
       ...opts,
     });
@@ -3207,6 +3226,7 @@ async function main() {
     developersComment,
     languages,
     missingImagePaths,
+    niceShowAggregate,
     thumbsCache,
     urlSpec,
     urls,
@@ -3363,23 +3383,6 @@ if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmus
   })();
 }
 
-function formatDuration(timeDelta) {
-  const seconds = timeDelta / 1000;
-
-  if (seconds > 90) {
-    const modSeconds = Math.floor(seconds % 60);
-    const minutes = Math.floor(seconds - seconds % 60) / 60;
-    return `${minutes}m${modSeconds}s`;
-  }
-
-  if (seconds < 0.1) {
-    return 'instant';
-  }
-
-  const precision = (seconds > 1 ? 3 : 2);
-  return `${seconds.toPrecision(precision)}s`;
-}
-
 function showStepStatusSummary() {
   const longestNameLength =
     Math.max(...
diff --git a/src/urls-default.yaml b/src/urls-default.yaml
index 74225efd..667f7d8b 100644
--- a/src/urls-default.yaml
+++ b/src/urls-default.yaml
@@ -11,7 +11,7 @@ yamlAliases:
   # part of a build. This is so that multiple builds of a wiki can coexist
   # served from the same server / file system root: older builds' HTML files
   # refer to earlier values of STATIC_VERSION, avoiding name collisions.
-  - &staticVersion 5p1
+  - &staticVersion 5p2
 
 data:
   prefix: 'data/'
@@ -41,6 +41,7 @@ localized:
 
     artist: 'artist/<>/'
     artistGallery: 'artist/<>/gallery/'
+    artistRollingWindow: 'artist/<>/rolling-window/'
 
     commentaryIndex: 'commentary/'
 
diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js
index d55ab215..afbf8b2f 100644
--- a/src/write/bind-utilities.js
+++ b/src/write/bind-utilities.js
@@ -24,6 +24,7 @@ export function bindUtilities({
   language,
   languages,
   missingImagePaths,
+  niceShowAggregate,
   pagePath,
   pagePathStringFromRoot,
   thumbsCache,
@@ -42,6 +43,7 @@ export function bindUtilities({
     language,
     languages,
     missingImagePaths,
+    niceShowAggregate,
     pagePath,
     pagePathStringFromRoot,
     thumb,