« 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.css507
-rw-r--r--src/static/js/client/expandable-gallery-section.js77
-rw-r--r--src/static/js/client/hoverable-tooltip.js30
-rw-r--r--src/static/js/client/index.js2
-rw-r--r--src/static/js/client/quick-description.js2
-rw-r--r--src/static/js/client/sidebar-search.js260
-rw-r--r--src/static/js/client/sticky-heading.js2
-rw-r--r--src/static/js/rectangles.js42
-rw-r--r--src/static/js/search-worker.js180
9 files changed, 925 insertions, 177 deletions
diff --git a/src/static/css/site.css b/src/static/css/site.css
index ab86915c..a9ed90c6 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 {
@@ -161,10 +176,9 @@ body::before, .wallpaper-part {
 }
 
 .sidebar-column {
-  flex: 1 1 20%;
+  flex: 1 1 35%;
   min-width: 150px;
   max-width: 250px;
-  flex-basis: 250px;
   align-self: flex-start;
 }
 
@@ -262,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 > * {
@@ -743,6 +761,96 @@ summary.underline-white > span:hover a:not(:hover) {
   cursor: default;
 }
 
+.wiki-search-filter-container {
+  padding: 4px;
+}
+
+.wiki-search-filter-link {
+  display: inline-block;
+  margin: 2px;
+  padding: 2px 4px;
+  border: 2px solid transparent;
+  border-radius: 4px;
+}
+
+.wiki-search-filter-link:where(.active.shown) {
+  animation:
+    0.15s ease   0.00s forwards normal    show-filter,
+    0.60s linear 0.15s infinite alternate blink-filter;
+}
+
+.wiki-search-filter-link:where(.active:not(.shown)) {
+  animation:
+    0.00s linear 0.00s forwards normal    show-filter,
+    0.60s linear 0.00s infinite alternate blink-filter;
+}
+
+.wiki-search-filter-link:where(:not(.active).hidden) {
+  /* We can't just reverse the show-filter animation,
+   * because that won't actually start it over again.
+   */
+  animation:
+    0.15s ease   0.00s forwards reverse   show-filter-the-sequel;
+}
+
+.wiki-search-filter-link.active-from-query {
+  background: var(--primary-color);
+  border-color: var(--primary-color);
+  color: #000a;
+  animation: none;
+}
+
+.wiki-search-filter-link.active-from-query::after {
+  content: "I";
+  color: black;
+  font-family: monospace;
+  font-weight: 800;
+  font-size: 1.2em;
+  margin-left: 0.5ch;
+  vertical-align: middle;
+  animation: 1s steps(2, jump-none) 0.6s infinite blink-caret;
+}
+
+@keyframes show-filter {
+  from {
+    background: transparent;
+    border-color: transparent;
+    color: var(--primary-color);
+  }
+
+  to {
+    background: var(--primary-color);
+    border-color: var(--primary-color);
+    color: black;
+  }
+}
+
+/* Exactly the same as show-filter above. */
+@keyframes show-filter-the-sequel {
+  from {
+    background: transparent;
+    border-color: transparent;
+    color: var(--primary-color);
+  }
+
+  to {
+    background: var(--primary-color);
+    border-color: var(--primary-color);
+    color: black;
+  }
+}
+
+@keyframes blink-filter {
+  to {
+    background: color-mix(in srgb, var(--primary-color) 90%, transparent);
+  }
+}
+
+@keyframes blink-caret {
+  from { opacity: 0; }
+  to { opacity: 1; }
+}
+
 .wiki-search-result {
   position: relative;
   display: flex;
@@ -931,7 +1039,11 @@ a .normal-content {
 
   background-color: var(--primary-color);
 
-  mask-image: url(/static-4p1/misc/image.svg);
+  /* mask-image is set in content JavaScript,
+   * because we can't identify the correct nor
+   * absolute path to the file from CSS.
+   */
+
   mask-repeat: no-repeat;
   mask-position: calc(100% - 2px);
   vertical-align: text-bottom;
@@ -1119,7 +1231,7 @@ a .normal-content {
   font-size: 0.9rem;
 }
 
-li:not(:first-child:last-child) .tooltip,
+li:not(:first-child:last-child) .tooltip:where(:not(.cover-artwork .tooltip)),
 .offset-tooltips > :not(:first-child:last-child) .tooltip {
   left: 14px;
 }
@@ -1153,7 +1265,8 @@ li:not(:first-child:last-child) .tooltip,
 .missing-duration-tooltip,
 .commentary-date-tooltip,
 .rerelease-tooltip,
-.first-release-tooltip {
+.first-release-tooltip,
+.content-tooltip {
   padding: 3px 4px 2px 2px;
   left: -10px;
 }
@@ -1161,7 +1274,7 @@ li:not(:first-child:last-child) .tooltip,
 .thing-name-tooltip,
 .wiki-edits-tooltip {
   padding: 3px 4px 2px 2px;
-  left: -6px !important;
+  left: -6px;
 }
 
 .thing-name-tooltip .tooltip-content,
@@ -1169,11 +1282,15 @@ li:not(:first-child:last-child) .tooltip,
   font-size: 0.85em;
 }
 
-/* Terrifying?
- * https://stackoverflow.com/a/64424759/4633828
- */
-.thing-name-tooltip { margin-right: -120px; }
-.wiki-edits-tooltip { margin-right: -200px; }
+.thing-name-tooltip .tooltip-content {
+  width: max-content;
+  max-width: 120px;
+}
+
+.wiki-edits-tooltip .tooltip-content {
+  width: max-content;
+  max-width: 200px;
+}
 
 .contribution-tooltip .tooltip-content {
   padding: 6px 2px 2px 2px;
@@ -1331,6 +1448,30 @@ li:not(:first-child:last-child) .tooltip,
   font-size: 0.9em;
 }
 
+.content-tooltip-guy .hoverable a {
+  text-decoration-color: transparent;
+  text-decoration-style: dotted;
+}
+
+.content-tooltip-guy {
+  display: inline-block;
+}
+
+.content-tooltip-guy.has-link .text-with-tooltip-interaction-cue {
+  text-decoration-color: var(--primary-color);
+}
+
+.content-tooltip .tooltip-content {
+  padding: 3px 4.5px;
+  width: 240px;
+}
+
+.cover-artwork .content-tooltip {
+  font-size: 0.85rem;
+  padding: 2px 3px;
+  width: 220px;
+}
+
 .external-icon {
   display: inline-block;
   padding: 0 3px;
@@ -1381,6 +1522,38 @@ s.spoiler::-moz-selection {
   background: white;
 }
 
+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 {
+  display: inline-block;
+  font-style: normal;
+}
+
+span.path i::before {
+  content: "\0020/\0020";
+  color: #ccc;
+}
+
 progress {
   accent-color: var(--primary-color);
 }
@@ -1442,12 +1615,23 @@ hr.cute,
     0 0 12px 12px #00000080;
 }
 
-#artwork-column .cover-artwork:not(:first-child) {
-  margin-top: 20px;
+#artwork-column .cover-artwork:not(:first-child),
+#artwork-column .cover-artwork-joiner {
   margin-left: 30px;
   margin-right: 5px;
 }
 
+#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;
+}
+
 #artwork-column .cover-artwork:last-child:not(:first-child) {
   margin-bottom: 25px;
 }
@@ -1530,6 +1714,29 @@ 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;
+}
+
+.cover-artwork-joiner::after {
+  content: "";
+  display: block;
+  width: 0;
+  height: 15px;
+  margin-left: auto;
+  margin-right: auto;
+  border-right: 3px solid var(--primary-color);
+}
+
+.cover-artwork-joiner + .cover-artwork {
+  margin-top: 0 !important;
+}
+
 .album-art-info {
   font-size: 0.8em;
   border: 2px solid var(--deep-color);
@@ -1548,40 +1755,27 @@ p.image-details.origin-details {
   margin: 0;
 }
 
-p.content-heading:has(+ .commentary-entry-heading.dated) {
-  clear: right;
-}
-
 .commentary-entry-heading {
-  display: flex;
-  flex-direction: row;
-
   margin-left: 15px;
-  padding-left: 5px;
-  max-width: 625px;
+  padding-left: calc(5px + 1.25ch);
+  text-indent: -1.25ch;
+  margin-right: min(calc(8vw - 35px), 45px);
   padding-bottom: 0.2em;
 
   border-bottom: 1px solid var(--dim-color);
 }
 
-.commentary-entry-heading-text {
-  flex-grow: 1;
-  padding-left: 1.25ch;
-  text-indent: -1.25ch;
-}
-
 .commentary-entry-accent {
   font-style: oblique;
 }
 
 .commentary-entry-heading .commentary-date {
-  flex-shrink: 0;
-
-  margin-left: 0.75ch;
-  align-self: flex-end;
+  display: inline-block;
+  text-indent: 0;
+}
 
-  padding-left: 0.5ch;
-  padding-right: 0.25ch;
+.commentary-entry-heading.dated .commentary-entry-heading-text {
+  margin-right: 0.75ch;
 }
 
 .commentary-entry-heading .hoverable {
@@ -1596,6 +1790,15 @@ p.content-heading:has(+ .commentary-entry-heading.dated) {
   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%;
@@ -1610,6 +1813,33 @@ p.content-heading:has(+ .commentary-entry-heading.dated) {
   box-shadow: 0 0 4px 5px rgba(0, 0, 0, 0.25) !important;
 }
 
+.lyrics-switcher {
+  padding-left: 20px;
+}
+
+.lyrics-switcher > span:not(:first-child)::before {
+  content: "\0020\00b7\0020";
+  font-weight: 800;
+}
+
+.lyrics-entry {
+  padding-left: 40px;
+}
+
+.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;
+}
+
 .js-hide,
 .js-show-once-data,
 .js-hide-once-data {
@@ -1617,25 +1847,32 @@ p.content-heading:has(+ .commentary-entry-heading.dated) {
 }
 
 .content-image-container,
-.content-video-container {
+.content-video-container,
+.content-audio-container {
   margin-top: 1em;
   margin-bottom: 1em;
 }
 
-.content-image-container.align-center,
-.content-video-container.align-center,
-.content-audio-container.align-center {
+.content-image-container.align-center {
   text-align: center;
   margin-top: 1.5em;
   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;
@@ -1750,6 +1987,50 @@ ul.quick-info li:not(:last-child)::after {
   text-align: center;
 }
 
+.gallery-view-switcher {
+  margin-left: auto;
+  margin-right: auto;
+  text-align: center;
+  line-height: 1.4;
+}
+
+#content.top-index section {
+  margin-bottom: 1.5em;
+}
+
+.expandable-gallery-section .section-expando {
+  margin-top: 1em;
+  margin-bottom: 2em;
+
+  display: flex;
+  flex-direction: row;
+  justify-content: space-around;
+}
+
+.expandable-gallery-section .section-expando-content {
+  text-align: center;
+  line-height: 1.5;
+}
+
+.expandable-gallery-section .section-expando-toggle {
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+.expandable-gallery-section.expanded .section-content-below-cut {
+  animation: expand-gallery-section 0.8s forwards;
+}
+
+@keyframes expand-gallery-section {
+  from {
+    opacity: 0;
+  }
+
+  to {
+    opacity: 1;
+  }
+}
+
 .quick-description:not(.has-external-links-only) {
   --clamped-padding-ratio: max(var(--responsive-padding-ratio), 0.06);
   margin-left: auto;
@@ -1780,6 +2061,7 @@ ul.quick-info li:not(:last-child)::after {
 
 .quick-description > blockquote {
   margin-left: 0 !important;
+  margin-right: 0 !important;
 }
 
 .quick-description .description-content.long hr ~ p {
@@ -1827,7 +2109,6 @@ ul.quick-info li:not(:last-child)::after {
 
 li .by {
   font-style: oblique;
-  max-width: 600px;
 }
 
 li .by a {
@@ -1843,8 +2124,8 @@ p code {
 
 #content blockquote {
   margin-left: 40px;
-  max-width: 600px;
-  margin-right: 0;
+  margin-right: min(8vw, 75px);
+  width: auto;
 }
 
 #content blockquote blockquote {
@@ -1891,7 +2172,6 @@ main.long-content > h1 {
 
 dl dt {
   padding-left: 40px;
-  max-width: 600px;
 }
 
 dl dt {
@@ -1920,6 +2200,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;
@@ -1931,6 +2218,15 @@ ul > li.has-details {
   margin-left: 0;
 }
 
+.album-group-list li {
+  padding-left: 1.5ch;
+  text-indent: -1.5ch;
+}
+
+.album-group-list li > * {
+  text-indent: 0;
+}
+
 .album-group-list blockquote {
   max-width: 540px;
   margin-bottom: 9px;
@@ -1961,31 +2257,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;
 }
@@ -2299,7 +2618,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 {
@@ -2331,7 +2676,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;
@@ -2341,6 +2687,30 @@ 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,
+.content-audio-container.align-center {
+  margin-left: auto;
+  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 {
@@ -2397,6 +2767,12 @@ img {
   object-fit: cover;
 }
 
+p > img {
+  max-width: 100%;
+  object-fit: contain;
+  height: auto;
+}
+
 .image-inner-area::after {
   content: "";
   display: block;
@@ -2627,6 +3003,10 @@ video.pixelate, .pixelate video {
   max-width: 200px;
 }
 
+.grid-name-marker {
+  color: white;
+}
+
 .grid-actions {
   display: flex;
   flex-direction: row;
@@ -3420,7 +3800,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
 
 /* Layout - Wide (most computers) */
 
-@media (min-width: 900px) {
+@media (min-width: 850px) {
   #page-container.showing-sidebar-left:not(.sidebars-in-content-column) #secondary-nav:not(.always-visible),
   #page-container.showing-sidebar-right:not(.sidebars-in-content-column) #secondary-nav:not(.always-visible) {
     display: none;
@@ -3434,7 +3814,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
  * if so desired.
  */
 
-@media (min-width: 600px) and (max-width: 899.98px) {
+@media (min-width: 600px) and (max-width: 849.98px) {
   /* Medium layout is mainly defined (to the user) by hiding the sidebar, so
    * don't apply the similar layout change of widening the long-content area
    * if this page doesn't have a sidebar to hide in the first place.
@@ -3465,7 +3845,8 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   #artwork-column {
     float: right;
     width: 40%;
-    max-width: 400px;
+    min-width: 220px;
+    max-width: 280px;
     margin: -60px 0 10px 20px;
 
     position: relative;
@@ -3481,12 +3862,12 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
     max-width: 375px;
   }
 
-  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:not(:nth-child(n+10)) {
+  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:not(:nth-child(n+7)) {
     flex-basis: 23%;
     margin: 15px;
   }
 
-  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:nth-child(n+10) {
+  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:nth-child(n+7) {
     flex-basis: 18%;
     margin: 10px;
   }
@@ -3494,7 +3875,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
 
 /* Layout - Medium or Thin */
 
-@media (max-width: 899.98px) {
+@media (max-width: 849.98px) {
   .sidebar.collapsible,
   .sidebar-box-joiner.collapsible,
   .sidebar-column.all-boxes-collapsible {
@@ -3588,6 +3969,12 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
     max-width: unset;
   }
 
+  #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/expandable-gallery-section.js b/src/static/js/client/expandable-gallery-section.js
new file mode 100644
index 00000000..dc83e8b7
--- /dev/null
+++ b/src/static/js/client/expandable-gallery-section.js
@@ -0,0 +1,77 @@
+/* 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/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 81ea3415..aeb9264a 100644
--- a/src/static/js/client/index.js
+++ b/src/static/js/client/index.js
@@ -10,6 +10,7 @@ import * as artistExternalLinkTooltipModule from './artist-external-link-tooltip
 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 hashLinkModule from './hash-link.js';
 import * as hoverableTooltipModule from './hoverable-tooltip.js';
 import * as imageOverlayModule from './image-overlay.js';
@@ -32,6 +33,7 @@ export const modules = [
   cssCompatibilityAssistantModule,
   datetimestampTooltipModule,
   draggedLinkModule,
+  expandableGallerySectionModule,
   hashLinkModule,
   hoverableTooltipModule,
   imageOverlayModule,
diff --git a/src/static/js/client/quick-description.js b/src/static/js/client/quick-description.js
index cff82252..6a7a6023 100644
--- a/src/static/js/client/quick-description.js
+++ b/src/static/js/client/quick-description.js
@@ -1,5 +1,7 @@
 /* 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/sidebar-search.js b/src/static/js/client/sidebar-search.js
index fb902636..eae1e74e 100644
--- a/src/static/js/client/sidebar-search.js
+++ b/src/static/js/client/sidebar-search.js
@@ -1,7 +1,7 @@
 /* eslint-env browser */
 
 import {getColors} from '../../shared-util/colors.js';
-import {accumulateSum, empty} from '../../shared-util/sugar.js';
+import {accumulateSum, empty, unique} from '../../shared-util/sugar.js';
 
 import {
   cssProp,
@@ -41,6 +41,14 @@ export const info = {
   failedRule: null,
   failedContainer: null,
 
+  filterContainer: null,
+  albumFilterLink: null,
+  artistFilterLink: null,
+  flashFilterLink: null,
+  groupFilterLink: null,
+  tagFilterLink: null,
+  trackFilterLink: null,
+
   resultsRule: null,
   resultsContainer: null,
   results: null,
@@ -65,6 +73,13 @@ export const info = {
   groupResultKindString: null,
   tagResultKindString: null,
 
+  albumResultFilterString: null,
+  artistResultFilterString: null,
+  flashResultFilterString: null,
+  groupResultFilterString: null,
+  tagResultFilterString: null,
+  trackResultFilterString: null,
+
   state: {
     sidebarColumnShownForSearch: null,
 
@@ -97,6 +112,10 @@ export const info = {
       maxLength: settings => settings.maxActiveResultsStorage,
     },
 
+    activeFilterType: {
+      type: 'string',
+    },
+
     repeatQueryOnReload: {
       type: 'boolean',
       default: false,
@@ -176,6 +195,24 @@ export function getPageReferences() {
 
   info.tagResultKindString =
     findString('tag-result-kind');
+
+  info.albumResultFilterString =
+    findString('album-result-filter');
+
+  info.artistResultFilterString =
+    findString('artist-result-filter');
+
+  info.flashResultFilterString =
+    findString('flash-result-filter');
+
+  info.groupResultFilterString =
+    findString('group-result-filter');
+
+  info.tagResultFilterString =
+    findString('tag-result-filter');
+
+  info.trackResultFilterString =
+    findString('track-result-filter');
 }
 
 export function addInternalListeners() {
@@ -265,6 +302,38 @@ export function mutatePageContent() {
   info.searchBox.appendChild(info.failedRule);
   info.searchBox.appendChild(info.failedContainer);
 
+  // Filter section
+
+  info.filterContainer =
+    document.createElement('div');
+
+  info.filterContainer.classList.add('wiki-search-filter-container');
+
+  cssProp(info.filterContainer, 'display', 'none');
+
+  forEachFilter((type, _filterLink) => {
+    // TODO: It's probably a sin to access `session` during this step LOL
+    const {session} = info;
+
+    const filterLink = document.createElement('a');
+
+    filterLink.href = '#';
+    filterLink.classList.add('wiki-search-filter-link');
+
+    if (session.activeFilterType === type) {
+      filterLink.classList.add('active');
+    }
+
+    const string = info[type + 'ResultFilterString'];
+    filterLink.appendChild(templateContent(string));
+
+    info[type + 'FilterLink'] = filterLink;
+
+    info.filterContainer.appendChild(filterLink);
+  });
+
+  info.searchBox.appendChild(info.filterContainer);
+
   // Results section
 
   info.resultsRule =
@@ -371,7 +440,7 @@ export function addPageListeners() {
     const {settings, state} = info;
 
     if (!info.searchInput.value) {
-      clearSidebarSearch();
+      clearSidebarSearch(); // ...but don't clear filter
       return;
     }
 
@@ -433,10 +502,18 @@ export function addPageListeners() {
   info.endSearchLink.addEventListener('click', domEvent => {
     domEvent.preventDefault();
     clearSidebarSearch();
+    clearSidebarFilter();
     possiblyHideSearchSidebarColumn();
     restoreSidebarSearchColumn();
   });
 
+  forEachFilter((type, filterLink) => {
+    filterLink.addEventListener('click', domEvent => {
+      domEvent.preventDefault();
+      toggleSidebarSearchFilter(type);
+    });
+  });
+
   info.resultsContainer.addEventListener('scroll', () => {
     const {settings, state} = info;
 
@@ -518,6 +595,21 @@ function trackSidebarSearchDownloadEnds(event) {
   }
 }
 
+function forEachFilter(callback) {
+  const filterOrder = [
+    'track',
+    'album',
+    'artist',
+    'group',
+    'flash',
+    'tag',
+  ];
+
+  for (const type of filterOrder) {
+    callback(type, info[type + 'FilterLink']);
+  }
+}
+
 async function activateSidebarSearch(query) {
   const {session, state} = info;
 
@@ -584,6 +676,16 @@ function clearSidebarSearch() {
   hideSidebarSearchResults();
 }
 
+function clearSidebarFilter() {
+  const {session} = info;
+
+  toggleSidebarSearchFilter(session.activeFilterType);
+
+  forEachFilter((_type, filterLink) => {
+    filterLink.classList.remove('shown', 'hidden');
+  });
+}
+
 function updateSidebarSearchStatus() {
   const {state} = info;
 
@@ -670,54 +772,122 @@ function showSidebarSearchFailed() {
 }
 
 function showSidebarSearchResults(results) {
-  console.debug(`Showing search results:`, results);
+  const {session} = info;
 
-  showSearchSidebarColumn();
+  console.debug(`Showing search results:`, tidyResults(results));
 
-  const flatResults =
-    Object.entries(results)
-      .filter(([index]) => index === 'generic')
-      .flatMap(([index, results]) => results
-        .flatMap(({doc, id}) => ({
-          index,
-          reference: id ?? null,
-          referenceType: (id ? id.split(':')[0] : null),
-          directory: (id ? id.split(':')[1] : null),
-          data: doc,
-        })));
+  showSearchSidebarColumn();
 
   info.searchBox.classList.add('showing-results');
   info.searchSidebarColumn.classList.add('search-showing-results');
 
-  while (info.results.firstChild) {
-    info.results.firstChild.remove();
+  let filterType = session.activeFilterType;
+  let shownAnyResults =
+    fillResultElements(results, {filterType: session.activeFilterType});
+
+  showFilterElements(results);
+
+  if (!shownAnyResults) {
+    shownAnyResults = toggleSidebarSearchFilter(filterType);
+    filterType = null;
   }
 
-  cssProp(info.resultsRule, 'display', 'block');
-  cssProp(info.resultsContainer, 'display', 'block');
+  if (shownAnyResults) {
+    cssProp(info.endSearchRule, 'display', 'block');
+    cssProp(info.endSearchLine, 'display', 'block');
 
-  if (empty(flatResults)) {
+    tidySidebarSearchColumn();
+  } else {
     const p = document.createElement('p');
     p.classList.add('wiki-search-no-results');
     p.appendChild(templateContent(info.noResultsString));
     info.results.appendChild(p);
   }
 
-  for (const result of flatResults) {
+  restoreSidebarSearchResultsScrollOffset();
+}
+
+function tidyResults(results) {
+  const tidiedResults =
+    results.results.map(({doc, id}) => ({
+      reference: id ?? null,
+      referenceType: (id ? id.split(':')[0] : null),
+      directory: (id ? id.split(':')[1] : null),
+      data: doc,
+    }));
+
+  return tidiedResults;
+}
+
+function fillResultElements(results, {
+  filterType = null,
+} = {}) {
+  const tidiedResults = tidyResults(results);
+
+  const filteredResults =
+    (filterType
+      ? tidiedResults.filter(result => result.referenceType === filterType)
+      : tidiedResults);
+
+  while (info.results.firstChild) {
+    info.results.firstChild.remove();
+  }
+
+  cssProp(info.resultsRule, 'display', 'block');
+  cssProp(info.resultsContainer, 'display', 'block');
+
+  if (empty(filteredResults)) {
+    return false;
+  }
+
+  for (const result of filteredResults) {
     const el = generateSidebarSearchResult(result);
     if (!el) continue;
 
     info.results.appendChild(el);
   }
 
-  if (!empty(flatResults)) {
-    cssProp(info.endSearchRule, 'display', 'block');
-    cssProp(info.endSearchLine, 'display', 'block');
+  return true;
+}
 
-    tidySidebarSearchColumn();
-  }
+function showFilterElements(results) {
+  const {queriedKind} = results;
 
-  restoreSidebarSearchResultsScrollOffset();
+  const tidiedResults = tidyResults(results);
+
+  const allReferenceTypes =
+    unique(tidiedResults.map(result => result.referenceType));
+
+  let shownAny = false;
+
+  forEachFilter((type, filterLink) => {
+    filterLink.classList.remove('shown', 'hidden');
+
+    if (allReferenceTypes.includes(type)) {
+      shownAny = true;
+      cssProp(filterLink, 'display', null);
+
+      if (queriedKind) {
+        filterLink.setAttribute('inert', 'inert');
+      } else {
+        filterLink.removeAttribute('inert');
+      }
+
+      if (type === queriedKind) {
+        filterLink.classList.add('active-from-query');
+      } else {
+        filterLink.classList.remove('active-from-query');
+      }
+    } else {
+      cssProp(filterLink, 'display', 'none');
+    }
+  });
+
+  if (shownAny) {
+    cssProp(info.filterContainer, 'display', null);
+  } else {
+    cssProp(info.filterContainer, 'display', 'none');
+  }
 }
 
 function generateSidebarSearchResult(result) {
@@ -908,6 +1078,8 @@ function generateSidebarSearchResultTemplate(slots) {
 }
 
 function hideSidebarSearchResults() {
+  cssProp(info.filterContainer, 'display', 'none');
+
   cssProp(info.resultsRule, 'display', 'none');
   cssProp(info.resultsContainer, 'display', 'none');
 
@@ -1040,6 +1212,36 @@ function tidySidebarSearchColumn() {
   }
 }
 
+function toggleSidebarSearchFilter(toggleType) {
+  const {session} = info;
+
+  if (!toggleType) return null;
+
+  let shownAnyResults = null;
+
+  forEachFilter((type, filterLink) => {
+    if (type === toggleType) {
+      const filterActive = filterLink.classList.toggle('active');
+      const filterType = (filterActive ? type : null);
+
+      if (cssProp(filterLink, 'display') !== 'none') {
+        filterLink.classList.add(filterActive ? 'shown' : 'hidden');
+      }
+
+      if (session.activeQueryResults) {
+        shownAnyResults =
+          fillResultElements(session.activeQueryResults, {filterType});
+      }
+
+      session.activeFilterType = filterType;
+    } else {
+      filterLink.classList.remove('active');
+    }
+  });
+
+  return shownAnyResults;
+}
+
 function restoreSidebarSearchColumn() {
   const {state} = info;
 
@@ -1073,6 +1275,8 @@ function forgetRecentSidebarSearch() {
 
   session.activeQuery = null;
   session.activeQueryResults = null;
+
+  clearSidebarFilter();
 }
 
 async function handleDroppedIntoSearchInput(domEvent) {
@@ -1101,7 +1305,7 @@ async function handleDroppedIntoSearchInput(domEvent) {
   let droppedURL;
   try {
     droppedURL = new URL(droppedText);
-  } catch (error) {
+  } catch {
     droppedURL = null;
   }
 
diff --git a/src/static/js/client/sticky-heading.js b/src/static/js/client/sticky-heading.js
index 02d3cd96..b65574d0 100644
--- a/src/static/js/client/sticky-heading.js
+++ b/src/static/js/client/sticky-heading.js
@@ -89,7 +89,7 @@ export function getPageReferences() {
 
   info.contentCovers =
     info.contentCoverColumns
-      .map(el => el.querySelector('.cover-artwork'));
+      .map(el => el ? el.querySelector('.cover-artwork') : null);
 
   info.contentCoversReveal =
     info.contentCovers
diff --git a/src/static/js/rectangles.js b/src/static/js/rectangles.js
index cdab2cb8..b00ed98e 100644
--- a/src/static/js/rectangles.js
+++ b/src/static/js/rectangles.js
@@ -510,4 +510,46 @@ export class WikiRect extends DOMRect {
       height: this.height,
     });
   }
+
+  // Other utilities
+
+  #display = null;
+
+  display() {
+    if (!this.#display) {
+      this.#display = document.createElement('div');
+      document.body.appendChild(this.#display);
+    }
+
+    Object.assign(this.#display.style, {
+      position: 'fixed',
+      background: '#000c',
+      border: '3px solid var(--primary-color)',
+      borderRadius: '4px',
+      top: this.top + 'px',
+      left: this.left + 'px',
+      width: this.width + 'px',
+      height: this.height + 'px',
+      pointerEvents: 'none',
+    });
+
+    let i = 0;
+    const int = setInterval(() => {
+      i++;
+      if (i >= 3) clearInterval(int);
+      if (!this.#display) return;
+
+      this.#display.style.display = 'none';
+      setTimeout(() => {
+        this.#display.style.display = '';
+      }, 200);
+    }, 600);
+  }
+
+  hide() {
+    if (this.#display) {
+      this.#display.remove();
+      this.#display = null;
+    }
+  }
 }
diff --git a/src/static/js/search-worker.js b/src/static/js/search-worker.js
index 1b4684ad..e32b4ad5 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;
@@ -371,58 +371,75 @@ function postActionResult(id, status, value) {
 }
 
 function performSearchAction({query, options}) {
-  const {generic, ...otherIndexes} = indexes;
+  const {queriedKind} = processTerms(query);
+  const genericResults = queryGenericIndex(query, options);
+  const verbatimResults = queryVerbatimIndex(query, options);
 
-  const genericResults =
-    queryGenericIndex(generic, query, options);
+  const verbatimIDs =
+    new Set(verbatimResults?.map(result => result.id));
 
-  const otherResults =
-    withEntries(otherIndexes, entries => entries
-      .map(([indexName, index]) => [
-        indexName,
-        index.search(query, options),
-      ]));
+  const commonResults =
+    (verbatimResults && genericResults
+      ? genericResults
+          .filter(({id}) => verbatimIDs.has(id))
+      : verbatimResults ?? genericResults);
 
   return {
-    generic: genericResults,
-    ...otherResults,
+    results: commonResults,
+    queriedKind,
   };
 }
 
-function queryGenericIndex(index, query, options) {
-  const interestingFieldCombinations = [
-    ['primaryName', 'parentName', 'groups'],
-    ['primaryName', 'parentName'],
-    ['primaryName', 'groups', 'contributors'],
-    ['primaryName', 'groups', 'artTags'],
-    ['primaryName', 'groups'],
-    ['primaryName', 'contributors'],
-    ['primaryName', 'artTags'],
-    ['parentName', 'groups', 'artTags'],
-    ['parentName', 'artTags'],
-    ['groups', 'contributors'],
-    ['groups', 'artTags'],
-
-    // This prevents just matching *everything* tagged "john" if you
-    // only search "john", but it actually supports matching more than
-    // *two* tags at once: "john rose lowas" works! This is thanks to
-    // flexsearch matching multiple field values in a single query.
-    ['artTags', 'artTags'],
-
-    ['contributors', 'parentName'],
-    ['contributors', 'groups'],
-    ['primaryName', 'contributors'],
-    ['primaryName'],
-  ];
+const interestingFieldCombinations = [
+  ['primaryName', 'parentName', 'groups'],
+  ['primaryName', 'parentName'],
+  ['primaryName', 'groups', 'contributors'],
+  ['primaryName', 'groups', 'artTags'],
+  ['primaryName', 'groups'],
+  ['primaryName', 'contributors'],
+  ['primaryName', 'artTags'],
+  ['parentName', 'groups', 'artTags'],
+  ['parentName', 'artTags'],
+  ['groups', 'contributors'],
+  ['groups', 'artTags'],
+
+  // This prevents just matching *everything* tagged "john" if you
+  // only search "john", but it actually supports matching more than
+  // *two* tags at once: "john rose lowas" works! This is thanks to
+  // flexsearch matching multiple field values in a single query.
+  ['artTags', 'artTags'],
+
+  ['contributors', 'parentName'],
+  ['contributors', 'groups'],
+  ['primaryName', 'contributors'],
+  ['primaryName'],
+];
+
+function queryGenericIndex(query, options) {
+  return queryIndex({
+    indexKey: 'generic',
+    termsKey: 'genericTerms',
+  }, query, options);
+}
+
+function queryVerbatimIndex(query, options) {
+  return queryIndex({
+    indexKey: 'verbatim',
+    termsKey: 'verbatimTerms',
+  }, query, options);
+}
 
+function queryIndex({termsKey, indexKey}, query, options) {
   const interestingFields =
     unique(interestingFieldCombinations.flat());
 
-  const {genericTerms, queriedKind} =
+  const {[termsKey]: terms, queriedKind} =
     processTerms(query);
 
+  if (empty(terms)) return null;
+
   const particles =
-    particulate(genericTerms);
+    particulate(terms);
 
   const groupedParticles =
     groupArray(particles, ({length}) => length);
@@ -437,7 +454,7 @@ function queryGenericIndex(index, query, options) {
           query: values,
         }));
 
-  const boilerplate = queryBoilerplate(index);
+  const boilerplate = queryBoilerplate(indexes[indexKey]);
 
   const particleResults =
     Object.fromEntries(
@@ -459,62 +476,73 @@ function queryGenericIndex(index, query, options) {
             ])),
       ]));
 
-  const results = new Set();
+  let matchedResults = new Set();
 
   for (const interestingFieldCombination of interestingFieldCombinations) {
     for (const query of queriesBy(interestingFieldCombination)) {
-      const idToMatchingFieldsMap = new Map();
-      for (const {field, query: fieldQuery} of query) {
-        for (const id of particleResults[field][fieldQuery]) {
-          if (idToMatchingFieldsMap.has(id)) {
-            idToMatchingFieldsMap.get(id).push(field);
-          } else {
-            idToMatchingFieldsMap.set(id, [field]);
-          }
-        }
-      }
+      const [firstQueryFieldLine, ...restQueryFieldLines] = query;
 
       const commonAcrossFields =
-        Array.from(idToMatchingFieldsMap.entries())
-          .filter(([id, matchingFields]) =>
-            matchingFields.length === interestingFieldCombination.length)
-          .map(([id]) => id);
+        new Set(
+          particleResults
+            [firstQueryFieldLine.field]
+            [firstQueryFieldLine.query]);
+
+      for (const currQueryFieldLine of restQueryFieldLines) {
+        const tossResults = new Set(commonAcrossFields);
+
+        const keepResults =
+          particleResults
+            [currQueryFieldLine.field]
+            [currQueryFieldLine.query];
+
+        for (const result of keepResults) {
+          tossResults.delete(result);
+        }
+
+        for (const result of tossResults) {
+          commonAcrossFields.delete(result);
+        }
+      }
 
       for (const result of commonAcrossFields) {
-        results.add(result);
+        matchedResults.add(result);
       }
     }
   }
 
-  const constituted =
-    boilerplate.constitute(results);
+  matchedResults = Array.from(matchedResults);
 
-  const constitutedAndFiltered =
-    constituted
-      .filter(({id}) =>
-        (queriedKind
-          ? id.split(':')[0] === queriedKind
-          : true));
+  const filteredResults =
+    (queriedKind
+      ? matchedResults.filter(id => id.split(':')[0] === queriedKind)
+      : matchedResults);
 
-  return constitutedAndFiltered;
+  const constitutedResults =
+    boilerplate.constitute(filteredResults);
+
+  return constitutedResults;
 }
 
 function processTerms(query) {
   const kindTermSpec = [
-    {kind: 'album', terms: ['album']},
-    {kind: 'artist', terms: ['artist']},
-    {kind: 'flash', terms: ['flash']},
-    {kind: 'group', terms: ['group']},
-    {kind: 'tag', terms: ['art tag', 'tag']},
-    {kind: 'track', terms: ['track']},
+    {kind: 'album', terms: ['album', 'albums']},
+    {kind: 'artist', terms: ['artist', 'artists']},
+    {kind: 'flash', terms: ['flash', 'flashes']},
+    {kind: 'group', terms: ['group', 'groups']},
+    {kind: 'tag', terms: ['art tag', 'art tags', 'tag', 'tags']},
+    {kind: 'track', terms: ['track', 'tracks']},
   ];
 
   const genericTerms = [];
+  const verbatimTerms = [];
   let queriedKind = null;
 
   const termRegexp =
     new RegExp(
-      String.raw`(?<kind>${kindTermSpec.flatMap(spec => spec.terms).join('|')})` +
+      String.raw`(?<kind>(?<=^|\s)(?:${kindTermSpec.flatMap(spec => spec.terms).join('|')})(?=$|\s))` +
+      String.raw`|(?<=^|\s)(?<quote>["'])(?<regularVerbatim>.+?)\k<quote>(?=$|\s)` +
+      String.raw`|(?<=^|\s)[“”‘’](?<curlyVerbatim>.+?)[“”‘’](?=$|\s)` +
       String.raw`|[^\s\-]+`,
       'gi');
 
@@ -530,10 +558,16 @@ function processTerms(query) {
       continue;
     }
 
+    const verbatim = groups.regularVerbatim || groups.curlyVerbatim;
+    if (verbatim) {
+      verbatimTerms.push(verbatim);
+      continue;
+    }
+
     genericTerms.push(match[0]);
   }
 
-  return {genericTerms, queriedKind};
+  return {genericTerms, verbatimTerms, queriedKind};
 }
 
 function particulate(terms) {