« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/static
diff options
context:
space:
mode:
Diffstat (limited to 'src/static')
-rw-r--r--src/static/css/site.css559
-rw-r--r--src/static/js/client-util.js29
-rw-r--r--src/static/js/client/artist-rolling-window.js573
-rw-r--r--src/static/js/client/expandable-gallery-section.js77
-rw-r--r--src/static/js/client/expandable-grid-section.js85
-rw-r--r--src/static/js/client/gallery-style-selector.js123
-rw-r--r--src/static/js/client/hoverable-tooltip.js30
-rw-r--r--src/static/js/client/index.js10
-rw-r--r--src/static/js/client/quick-description.js2
-rw-r--r--src/static/js/client/reveal-all-grid-control.js72
-rw-r--r--src/static/js/client/sidebar-search.js58
-rw-r--r--src/static/js/search-worker.js5
12 files changed, 1449 insertions, 174 deletions
diff --git a/src/static/css/site.css b/src/static/css/site.css
index 6fa4da38..e584f918 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 > * {
@@ -899,6 +918,11 @@ summary.underline-white > span:hover a:not(:hover) {
   display: inline-block;
 }
 
+.wiki-search-result-disambiguator {
+  opacity: 0.9;
+  display: inline-block;
+}
+
 .wiki-search-result-image-container {
   align-self: flex-start;
   flex-shrink: 0;
@@ -1013,10 +1037,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 +1054,6 @@ a .normal-content {
 
   mask-repeat: no-repeat;
   mask-position: calc(100% - 2px);
-  vertical-align: text-bottom;
 }
 
 .image-media-link:hover::after {
@@ -1095,7 +1121,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 +1144,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;
 
@@ -1193,7 +1237,12 @@ a .normal-content {
   white-space: nowrap;
 }
 
-.isolate-tooltip-z-indexing > * {
+:where(.isolate-tooltip-z-indexing) {
+  position: relative;
+  z-index: 1;
+}
+
+:where(.isolate-tooltip-z-indexing > *) {
   position: relative;
   z-index: -1;
 }
@@ -1296,6 +1345,16 @@ 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;
+  min-width: 30ch;
+
+  font-size: 0.85em;
+  font-style: oblique;
+  margin-bottom: 2px;
+}
+
 .contribution-tooltip .chronology-link {
   display: grid;
   grid-column-start: icon-start;
@@ -1503,10 +1562,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 {
@@ -1575,9 +1650,11 @@ hr.cute,
 }
 
 #artwork-column .cover-artwork {
+  --normal-shadow: 0 0 12px 12px #00000080;
+
   box-shadow:
     0 2px 14px -6px var(--primary-color),
-    0 0 12px 12px #00000080;
+    var(--normal-shadow);
 }
 
 #artwork-column .cover-artwork:not(:first-child),
@@ -1586,6 +1663,17 @@ hr.cute,
   margin-right: 5px;
 }
 
+#artwork-column .cover-artwork:not(:first-child) {
+  --normal-shadow: 0 0 9px 9px #00000068;
+}
+
+#artwork-column .cover-artwork:first-child + .cover-artwork-joiner,
+#artwork-column .cover-artwork.attached-artwork-is-main-artwork,
+#artwork-column .cover-artwork.attached-artwork-is-main-artwork + .cover-artwork-joiner {
+  margin-left: 17.5px;
+  margin-right: 17.5px;
+}
+
 .cover-artwork:where(#artwork-column .cover-artwork:not(:first-child)) {
   margin-top: 20px;
 }
@@ -1672,6 +1760,11 @@ p.image-details.origin-details {
   margin-bottom: 2px;
 }
 
+p.image-details.origin-details .origin-details {
+  display: block;
+  margin-top: 0.25em;
+}
+
 .cover-artwork-joiner {
   z-index: -2;
 }
@@ -1743,6 +1836,15 @@ p.image-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%;
@@ -1770,11 +1872,49 @@ p.image-details.origin-details {
   padding-left: 40px;
 }
 
-.lyrics-entry .lyrics-details {
+.lyrics-entry .lyrics-details,
+.lyrics-entry .origin-details {
   font-size: 0.9em;
   font-style: oblique;
 }
 
+.lyrics-entry .lyrics-details {
+  margin-bottom: 0;
+}
+
+.lyrics-entry .origin-details {
+  margin-top: 0.25em;
+}
+
+.lyrics-entry.long-lyrics {
+  clip-path: inset(-15px -20px);
+}
+
+.lyrics-entry.long-lyrics::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 {
@@ -1782,7 +1922,8 @@ p.image-details.origin-details {
 }
 
 .content-image-container,
-.content-video-container {
+.content-video-container,
+.content-audio-container {
   margin-top: 1em;
   margin-bottom: 1em;
 }
@@ -1793,12 +1934,20 @@ p.image-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, a.align-full img, img.align-full, video.align-full {
+  width: 100%;
+}
+
 center {
   margin-top: 1em;
   margin-bottom: 1em;
@@ -1913,46 +2062,34 @@ 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;
 }
 
-#content.top-index section {
-  margin-bottom: 1.5em;
+.gallery-style-selector .styles {
+  display: inline-flex;
+  justify-content: center;
 }
 
-.expandable-gallery-section .section-expando {
-  margin-top: 1em;
-  margin-bottom: 2em;
-
-  display: flex;
-  flex-direction: row;
-  justify-content: space-around;
+.gallery-style-selector .styles label:not(:last-child) {
+  margin-right: 1.25ch;
 }
 
-.expandable-gallery-section .section-expando-content {
-  text-align: center;
-  line-height: 1.5;
-}
+.gallery-style-selector .count {
+  font-size: 0.85em;
 
-.expandable-gallery-section .section-expando-toggle {
-  text-decoration: underline;
-  text-decoration-style: dotted;
-}
+  position: relative;
+  bottom: -0.25em;
 
-.expandable-gallery-section.expanded .section-content-below-cut {
-  animation: expand-gallery-section 0.8s forwards;
+  opacity: 0.9;
 }
 
-@keyframes expand-gallery-section {
-  from {
-    opacity: 0;
-  }
-
-  to {
-    opacity: 1;
-  }
+#content.top-index section {
+  margin-bottom: 1.5em;
 }
 
 .quick-description:not(.has-external-links-only) {
@@ -2124,6 +2261,13 @@ ul > li.has-details {
   margin-left: -17px;
 }
 
+li .origin-details {
+  display: block;
+  margin-left: 2ch;
+  font-size: 0.9em;
+  font-style: oblique;
+}
+
 .album-group-list dt,
 .group-series-list dt {
   font-style: oblique;
@@ -2174,31 +2318,54 @@ ul > li.has-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;
 }
@@ -2278,6 +2445,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);
 }
@@ -2512,7 +2738,33 @@ html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:las
     linear-gradient(#000000bb, #000000bb),
     var(--primary-color);
 
-  box-shadow: 0 -2px 6px -1px var(--dim-color) inset;
+  --drop-shadow: 0 -2px 6px -1px var(--dim-color) inset;
+  box-shadow: var(--drop-shadow);
+}
+
+.drop.shiny {
+  cursor: default;
+}
+
+@supports (box-shadow: 1px 1px 1px color-mix(in srgb, blue, 40% red)) {
+  @property --drop-shine {
+    syntax: '<percentage>';
+    initial-value: 0%;
+    inherits: false;
+  }
+
+  .drop.shiny {
+    cursor: default;
+    transition: --drop-shine 0.2s;
+  }
+
+  .drop.shiny:hover {
+    --drop-shine: 100%;
+
+    box-shadow:
+      var(--drop-shadow),
+      0 2px 4px -0.5px color-mix(in srgb, var(--primary-color), calc(100% - var(--drop-shine)) transparent);
+  }
 }
 
 .commentary-drop {
@@ -2544,7 +2796,8 @@ html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:las
 /* Videos and audios (in content) get a lite version of image-container. */
 .content-video-container,
 .content-audio-container {
-  width: min-content;
+  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;
@@ -2554,6 +2807,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,
@@ -2562,6 +2816,23 @@ 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;
+  display: block;
+  font-size: 0.9em;
+  padding-left: 1ch;
+  padding-right: 1ch;
+  padding-bottom: 0.25em;
+  margin-bottom: 0.5em;
+  border-bottom: 1px solid #fff4;
+}
+
 .image-text-area {
   position: absolute;
   top: 0;
@@ -2616,6 +2887,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;
@@ -2675,9 +2963,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 {
@@ -2701,7 +2989,6 @@ video.pixelate, .pixelate video {
 }
 
 .reveal.revealed .image {
-  filter: none;
   opacity: 1;
 }
 
@@ -2712,7 +2999,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);
 }
 
@@ -2748,7 +3034,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;
 }
 
@@ -2774,20 +3060,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 {
@@ -2804,10 +3158,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 {
@@ -2824,20 +3184,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;
 }
 
@@ -2846,6 +3213,10 @@ video.pixelate, .pixelate video {
   max-width: 200px;
 }
 
+.grid-name-marker {
+  color: white;
+}
+
 .grid-actions {
   display: flex;
   flex-direction: row;
@@ -2863,6 +3234,47 @@ video.pixelate, .pixelate video {
   --dim-color: inherit !important;
 }
 
+.grid-caption {
+  flex-basis: 100%;
+  text-align: center;
+  line-height: 1.5;
+}
+
+.grid-expando {
+  margin-top: 1em;
+  margin-bottom: 2em;
+  flex-basis: 100%;
+
+  display: flex;
+  flex-direction: row;
+  justify-content: space-around;
+}
+
+.grid-expando-content {
+  margin: 0;
+  text-align: center;
+  line-height: 1.5;
+}
+
+.grid-expando-toggle {
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+.grid-item.shown-by-expandable-cut {
+  animation: expand-cover-grid 0.8s forwards;
+}
+
+@keyframes expand-cover-grid {
+  from {
+    opacity: 0;
+  }
+
+  to {
+    opacity: 1;
+  }
+}
+
 /* Carousel */
 
 .carousel-container {
@@ -2883,7 +3295,6 @@ video.pixelate, .pixelate video {
   left: 0;
   right: 0;
   bottom: 0;
-  z-index: -20;
   background-color: var(--dim-color);
   filter: brightness(0.6);
 }
@@ -3273,15 +3684,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 {
@@ -3421,7 +3828,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 */
@@ -3808,6 +4217,16 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
     max-width: unset;
   }
 
+  #artwork-column .cover-artwork {
+    --normal-shadow: 0 0 transparent;
+  }
+
+  #artwork-column .cover-artwork:not(:first-child),
+  #artwork-column .cover-artwork-joiner {
+    margin-left: 30px;
+    margin-right: 30px;
+  }
+
   #additional-names-box {
     width: unset;
     max-width: unset;
diff --git a/src/static/js/client-util.js b/src/static/js/client-util.js
index 71112313..396c4889 100644
--- a/src/static/js/client-util.js
+++ b/src/static/js/client-util.js
@@ -37,7 +37,7 @@ export function cssProp(el, ...args) {
   }
 }
 
-export function templateContent(el) {
+export function templateContent(el, slots = {}) {
   if (el === null) {
     return null;
   }
@@ -46,7 +46,25 @@ export function templateContent(el) {
     throw new Error(`Expected a <template> element`);
   }
 
-  return el.content.cloneNode(true);
+  const content = el.content.cloneNode(true);
+
+  for (const [key, value] of Object.entries(slots)) {
+    const slot = content.querySelector(`slot[name="${key}"]`);
+
+    if (!slot) {
+      console.warn(`Slot ${key} missing in template:`, el);
+      continue;
+    }
+
+    if (value === null || value === undefined) {
+      console.warn(`Valueless slot ${key} in template:`, el);
+      continue;
+    }
+
+    slot.replaceWith(value);
+  }
+
+  return content;
 }
 
 // Curry-style, so multiple points can more conveniently be tested at once.
@@ -127,3 +145,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/expandable-gallery-section.js b/src/static/js/client/expandable-gallery-section.js
deleted file mode 100644
index dc83e8b7..00000000
--- a/src/static/js/client/expandable-gallery-section.js
+++ /dev/null
@@ -1,77 +0,0 @@
-/* eslint-env browser */
-
-// TODO: Combine this and quick-description.js
-
-import {cssProp} from '../client-util.js';
-
-import {stitchArrays} from '../../shared-util/sugar.js';
-
-export const info = {
-  id: 'expandableGallerySectionInfo',
-
-  sections: null,
-
-  sectionContentBelowCut: null,
-
-  sectionExpandoToggles: null,
-
-  sectionExpandCues: null,
-  sectionCollapseCues: null,
-};
-
-export function getPageReferences() {
-  info.sections =
-    Array.from(document.querySelectorAll('.expandable-gallery-section'))
-      .filter(section => section.querySelector('.section-expando-toggle'));
-
-  info.sectionContentBelowCut =
-    info.sections
-      .map(section => section.querySelector('.section-content-below-cut'));
-
-  info.sectionExpandoToggles =
-    info.sections
-      .map(section => section.querySelector('.section-expando-toggle'));
-
-  info.sectionExpandCues =
-    info.sections
-      .map(section => section.querySelector('.section-expand-cue'));
-
-  info.sectionCollapseCues =
-    info.sections
-      .map(section => section.querySelector('.section-collapse-cue'));
-}
-
-export function addPageListeners() {
-  for (const {
-    section,
-    contentBelowCut,
-    expandoToggle,
-    expandCue,
-    collapseCue,
-  } of stitchArrays({
-    section: info.sections,
-    contentBelowCut: info.sectionContentBelowCut,
-    expandoToggle: info.sectionExpandoToggles,
-    expandCue: info.sectionExpandCues,
-    collapseCue: info.sectionCollapseCues,
-  })) {
-    expandoToggle.addEventListener('click', domEvent => {
-      domEvent.preventDefault();
-
-      const collapsed =
-        cssProp(contentBelowCut, 'display') === 'none';
-
-      if (collapsed) {
-        section.classList.add('expanded');
-        cssProp(contentBelowCut, 'display', null);
-        cssProp(expandCue, 'display', 'none');
-        cssProp(collapseCue, 'display', null);
-      } else {
-        section.classList.remove('expanded');
-        cssProp(contentBelowCut, 'display', 'none');
-        cssProp(expandCue, 'display', null);
-        cssProp(collapseCue, 'display', 'none');
-      }
-    });
-  }
-}
diff --git a/src/static/js/client/expandable-grid-section.js b/src/static/js/client/expandable-grid-section.js
new file mode 100644
index 00000000..ce9a4c06
--- /dev/null
+++ b/src/static/js/client/expandable-grid-section.js
@@ -0,0 +1,85 @@
+/* eslint-env browser */
+
+import {cssProp} from '../client-util.js';
+
+import {stitchArrays} from '../../shared-util/sugar.js';
+
+export const info = {
+  id: 'expandableGallerySectionInfo',
+
+  items: null,
+  toggles: null,
+  expandCues: null,
+  collapseCues: null,
+};
+
+export function getPageReferences() {
+  const expandos =
+    Array.from(document.querySelectorAll('.grid-expando'));
+
+  const grids =
+    expandos
+      .map(expando => expando.closest('.grid-listing'));
+
+  info.items =
+    grids
+      .map(grid => grid.querySelectorAll('.grid-item'))
+      .map(items => Array.from(items));
+
+  info.toggles =
+    expandos
+      .map(expando => expando.querySelector('.grid-expando-toggle'));
+
+  info.expandCues =
+    info.toggles
+      .map(toggle => toggle.querySelector('.grid-expand-cue'));
+
+  info.collapseCues =
+    info.toggles
+      .map(toggle => toggle.querySelector('.grid-collapse-cue'));
+}
+
+export function addPageListeners() {
+  stitchArrays({
+    items: info.items,
+    toggle: info.toggles,
+    expandCue: info.expandCues,
+    collapseCue: info.collapseCues,
+  }).forEach(({
+      items,
+      toggle,
+      expandCue,
+      collapseCue,
+    }) => {
+      toggle.addEventListener('click', domEvent => {
+        domEvent.preventDefault();
+
+        const collapsed =
+          items.some(item =>
+            item.classList.contains('hidden-by-expandable-cut'));
+
+        for (const item of items) {
+          if (
+            !item.classList.contains('hidden-by-expandable-cut') &&
+            !item.classList.contains('shown-by-expandable-cut')
+          ) continue;
+
+          if (collapsed) {
+            item.classList.remove('hidden-by-expandable-cut');
+            item.classList.add('shown-by-expandable-cut');
+          } else {
+            item.classList.add('hidden-by-expandable-cut');
+            item.classList.remove('shown-by-expandable-cut');
+          }
+        }
+
+        if (collapsed) {
+          cssProp(expandCue, 'display', 'none');
+          cssProp(collapseCue, 'display', null);
+        } else {
+          cssProp(expandCue, 'display', null);
+          cssProp(collapseCue, '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/hoverable-tooltip.js b/src/static/js/client/hoverable-tooltip.js
index 9569de3e..89119a47 100644
--- a/src/static/js/client/hoverable-tooltip.js
+++ b/src/static/js/client/hoverable-tooltip.js
@@ -118,17 +118,17 @@ export function registerTooltipElement(tooltip) {
     handleTooltipMouseLeft(tooltip);
   });
 
-  tooltip.addEventListener('focusin', event => {
-    handleTooltipReceivedFocus(tooltip, event.relatedTarget);
+  tooltip.addEventListener('focusin', domEvent => {
+    handleTooltipReceivedFocus(tooltip, domEvent.relatedTarget);
   });
 
-  tooltip.addEventListener('focusout', event => {
+  tooltip.addEventListener('focusout', domEvent => {
     // This event gets activated for tabbing *between* links inside the
     // tooltip, which is no good and certainly doesn't represent the focus
     // leaving the tooltip.
-    if (currentlyShownTooltipHasFocus(event.relatedTarget)) return;
+    if (currentlyShownTooltipHasFocus(domEvent.relatedTarget)) return;
 
-    handleTooltipLostFocus(tooltip, event.relatedTarget);
+    handleTooltipLostFocus(tooltip, domEvent.relatedTarget);
   });
 }
 
@@ -158,20 +158,20 @@ export function registerTooltipHoverableElement(hoverable, tooltip) {
     handleTooltipHoverableMouseLeft(hoverable);
   });
 
-  hoverable.addEventListener('focusin', event => {
-    handleTooltipHoverableReceivedFocus(hoverable, event);
+  hoverable.addEventListener('focusin', domEvent => {
+    handleTooltipHoverableReceivedFocus(hoverable, domEvent);
   });
 
-  hoverable.addEventListener('focusout', event => {
-    handleTooltipHoverableLostFocus(hoverable, event);
+  hoverable.addEventListener('focusout', domEvent => {
+    handleTooltipHoverableLostFocus(hoverable, domEvent);
   });
 
-  hoverable.addEventListener('touchend', event => {
-    handleTooltipHoverableTouchEnded(hoverable, event);
+  hoverable.addEventListener('touchend', domEvent => {
+    handleTooltipHoverableTouchEnded(hoverable, domEvent);
   });
 
-  hoverable.addEventListener('click', event => {
-    handleTooltipHoverableClicked(hoverable, event);
+  hoverable.addEventListener('click', domEvent => {
+    handleTooltipHoverableClicked(hoverable, domEvent);
   });
 }
 
@@ -416,7 +416,7 @@ function handleTooltipHoverableTouchEnded(hoverable, domEvent) {
     }, 1200);
 }
 
-function handleTooltipHoverableClicked(hoverable) {
+function handleTooltipHoverableClicked(hoverable, domEvent) {
   const {state} = info;
 
   // Don't navigate away from the page if the this hoverable was recently
@@ -426,7 +426,7 @@ function handleTooltipHoverableClicked(hoverable) {
     state.currentlyActiveHoverable === hoverable &&
     state.hoverableWasRecentlyTouched
   ) {
-    event.preventDefault();
+    domEvent.preventDefault();
   }
 }
 
diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js
index aeb9264a..86081b5d 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 expandableGridSectionModule from './expandable-grid-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,
+  expandableGridSectionModule,
+  galleryStyleSelectorModule,
   hashLinkModule,
   hoverableTooltipModule,
   imageOverlayModule,
   intrapageDotSwitcherModule,
   liveMousePositionModule,
   quickDescriptionModule,
+  revealAllGridControlModule,
   scriptedLinkModule,
   sidebarSearchModule,
   stickyHeadingModule,
diff --git a/src/static/js/client/quick-description.js b/src/static/js/client/quick-description.js
index 6a7a6023..cff82252 100644
--- a/src/static/js/client/quick-description.js
+++ b/src/static/js/client/quick-description.js
@@ -1,7 +1,5 @@
 /* eslint-env browser */
 
-// TODO: Combine this and expandable-gallery-section.js
-
 import {stitchArrays} from '../../shared-util/sugar.js';
 
 export const info = {
diff --git a/src/static/js/client/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/static/js/client/sidebar-search.js b/src/static/js/client/sidebar-search.js
index c8f42e91..4467766c 100644
--- a/src/static/js/client/sidebar-search.js
+++ b/src/static/js/client/sidebar-search.js
@@ -73,6 +73,10 @@ export const info = {
   groupResultKindString: null,
   tagResultKindString: null,
 
+  groupResultDisambiguatorString: null,
+  flashResultDisambiguatorString: null,
+  trackResultDisambiguatorString: null,
+
   albumResultFilterString: null,
   artistResultFilterString: null,
   flashResultFilterString: null,
@@ -196,6 +200,15 @@ export function getPageReferences() {
   info.tagResultKindString =
     findString('tag-result-kind');
 
+  info.groupResultDisambiguatorString =
+    findString('group-result-disambiguator');
+
+  info.flashResultDisambiguatorString =
+    findString('flash-result-disambiguator');
+
+  info.trackResultDisambiguatorString =
+    findString('track-result-disambiguator');
+
   info.albumResultFilterString =
     findString('album-result-filter');
 
@@ -841,7 +854,7 @@ function fillResultElements(results, {
   }
 
   for (const result of filteredResults) {
-    const el = generateSidebarSearchResult(result);
+    const el = generateSidebarSearchResult(result, filteredResults);
     if (!el) continue;
 
     info.results.appendChild(el);
@@ -890,13 +903,13 @@ function showFilterElements(results) {
   }
 }
 
-function generateSidebarSearchResult(result) {
+function generateSidebarSearchResult(result, results) {
   const preparedSlots = {
     color:
       result.data.color ?? null,
 
     name:
-      result.data.name ?? result.data.primaryName ?? null,
+      getSearchResultName(result),
 
     imageSource:
       getSearchResultImageSource(result),
@@ -961,9 +974,37 @@ function generateSidebarSearchResult(result) {
       return null;
   }
 
+  const compareReferenceType = otherResult =>
+    otherResult.referenceType === result.referenceType;
+
+  const compareName = otherResult =>
+    getSearchResultName(otherResult) === getSearchResultName(result);
+
+  const ambiguous =
+    results.some(otherResult =>
+      otherResult !== result &&
+      compareReferenceType(otherResult) &&
+      compareName(otherResult));
+
+  if (ambiguous) {
+    preparedSlots.disambiguate =
+      result.data.disambiguator;
+
+    preparedSlots.disambiguatorString =
+      info[result.referenceType + 'ResultDisambiguatorString'];
+  }
+
   return generateSidebarSearchResultTemplate(preparedSlots);
 }
 
+function getSearchResultName(result) {
+  return (
+    result.data.name ??
+    result.data.primaryName ??
+    null
+  );
+}
+
 function getSearchResultImageSource(result) {
   const {artwork} = result.data;
   if (!artwork) return null;
@@ -1039,6 +1080,15 @@ function generateSidebarSearchResultTemplate(slots) {
     }
   }
 
+  if (!accentSpan && slots.disambiguate) {
+    accentSpan = document.createElement('span');
+    accentSpan.classList.add('wiki-search-result-disambiguator');
+    accentSpan.appendChild(
+      templateContent(slots.disambiguatorString, {
+        disambiguator: slots.disambiguate,
+      }));
+  }
+
   if (!accentSpan && slots.kindString) {
     accentSpan = document.createElement('span');
     accentSpan.classList.add('wiki-search-result-kind');
@@ -1305,7 +1355,7 @@ async function handleDroppedIntoSearchInput(domEvent) {
   let droppedURL;
   try {
     droppedURL = new URL(droppedText);
-  } catch (error) {
+  } catch {
     droppedURL = null;
   }
 
diff --git a/src/static/js/search-worker.js b/src/static/js/search-worker.js
index c3002b18..3e9fbfca 100644
--- a/src/static/js/search-worker.js
+++ b/src/static/js/search-worker.js
@@ -130,7 +130,7 @@ async function loadDatabase() {
 
   try {
     idb = await promisifyIDBRequest(request);
-  } catch (error) {
+  } catch {
     console.warn(`Couldn't load search IndexedDB - won't use an internal cache.`);
     console.warn(request.error);
     idb = null;
@@ -391,6 +391,8 @@ function performSearchAction({query, options}) {
 }
 
 const interestingFieldCombinations = [
+  ['primaryName'],
+
   ['primaryName', 'parentName', 'groups'],
   ['primaryName', 'parentName'],
   ['primaryName', 'groups', 'contributors'],
@@ -412,7 +414,6 @@ const interestingFieldCombinations = [
   ['contributors', 'parentName'],
   ['contributors', 'groups'],
   ['primaryName', 'contributors'],
-  ['primaryName'],
 ];
 
 function queryGenericIndex(query, options) {