« 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-basic.css (renamed from src/static/site-basic.css)0
-rw-r--r--src/static/css/site.css (renamed from src/static/site6.css)420
-rw-r--r--src/static/js/client.js (renamed from src/static/client3.js)1584
-rw-r--r--src/static/js/lazy-loading.js (renamed from src/static/lazy-loading.js)0
-rw-r--r--src/static/js/module-import-shims.js27
-rw-r--r--src/static/js/search-worker.js621
-rw-r--r--src/static/js/xhr-util.js64
-rw-r--r--src/static/misc/icons.svg (renamed from src/static/icons.svg)0
-rw-r--r--src/static/misc/warning.svg (renamed from src/static/warning.svg)0
-rw-r--r--src/static/shared-util/README.md11
10 files changed, 2602 insertions, 125 deletions
diff --git a/src/static/site-basic.css b/src/static/css/site-basic.css
index 586f37b5..586f37b5 100644
--- a/src/static/site-basic.css
+++ b/src/static/css/site-basic.css
diff --git a/src/static/site6.css b/src/static/css/site.css
index 73721956..e297993c 100644
--- a/src/static/site6.css
+++ b/src/static/css/site.css
@@ -32,7 +32,9 @@
 /* Layout - Common */
 
 body {
-  margin: 10px;
+  position: relative;
+  margin: 0;
+  padding: 10px;
   overflow-y: scroll;
 }
 
@@ -41,8 +43,8 @@ body::before {
   position: fixed;
   top: 0;
   left: 0;
-  width: 100%;
-  height: 100%;
+  width: 100vw;
+  height: 100vh;
   z-index: -1;
 
   /* NB: these are 100 LVW, "largest view width", etc.
@@ -56,7 +58,7 @@ body::before {
 
 #page-container {
   max-width: 1100px;
-  margin: 10px auto 50px;
+  margin: 0 auto 40px;
   padding: 15px 0;
 }
 
@@ -170,6 +172,10 @@ body::before {
   flex-grow: 1;
 }
 
+.sidebar-column.initially-hidden {
+  display: none;
+}
+
 .sidebar-multiple {
   display: flex;
   flex-direction: column;
@@ -224,7 +230,7 @@ body {
 }
 
 body::before {
-  background-image: url("../media/bg.jpg");
+  background-image: url("../../media/bg.jpg");
   background-position: center;
   background-size: cover;
   opacity: 0.5;
@@ -253,6 +259,11 @@ body::before {
   font-weight: 800;
 }
 
+#page-container:not(.showing-sidebar-left) #skippers .skipper[data-for=sidebar-left],
+#page-container:not(.showing-sidebar-right) #skippers .skipper[data-for=sidebar-right] {
+  display: none;
+}
+
 #banner {
   background: black;
   background-color: var(--dim-color);
@@ -392,6 +403,36 @@ summary > span:hover {
   text-decoration-color: var(--primary-color);
 }
 
+summary > span:hover a {
+  text-decoration: none !important;
+}
+
+summary > span:hover:has(a:hover),
+summary > span:hover:has(a.nested-hover),
+summary.has-nested-hover > span {
+  text-decoration: none !important;
+}
+
+summary > span:hover:has(a:hover) a,
+summary > span:hover:has(a.nested-hover) a,
+summary.has-nested-hover > span a {
+  text-decoration: underline !important;
+}
+
+summary.underline-white > span:hover {
+  text-decoration-color: white;
+}
+
+/* This link isn't supposed to be underlined *at all*
+ * when the summary (and not link) is hovered, but
+ * for some reason Safari is still applying its colored
+ * and dotted(!) underline. Get around the apparent
+ * effect by just making it white.
+ */
+summary.underline-white > span:hover a:not(:hover) {
+  text-decoration-color: white;
+}
+
 summary .group-name {
   color: var(--primary-color);
 }
@@ -430,6 +471,242 @@ summary .group-name {
   font-weight: normal;
 }
 
+.sidebar-column.search-showing-results {
+  position: sticky;
+  top: 5px;
+  align-self: flex-start !important; /* pls */
+}
+
+.wiki-search-sidebar-box {
+  padding: 1px 0 0 0;
+
+  z-index: 100;
+  max-height: calc(100vh - 20px);
+
+  display: flex;
+  flex-direction: column;
+
+  background-color: #000000c0;
+
+  -webkit-backdrop-filter:
+    brightness(1.2) blur(4px);
+
+          backdrop-filter:
+    brightness(1.2) blur(4px);
+}
+
+.wiki-search-sidebar-box.showing-results {
+  box-shadow:
+    0 4px 16px -8px var(--primary-color),
+    0 10px 6px var(--bg-black-color),
+    0 6px 4px #00000040;
+}
+
+/* This is to say, any sidebar that's *not*
+ * the first sidebar after the search box.
+ */
+.wiki-search-sidebar-box.showing-results + .sidebar ~ .sidebar {
+  margin-top: 5px;
+}
+
+.wiki-search-sidebar-box.showing-results ~ .sidebar:not(:hover) {
+  opacity: 0.8;
+  filter: brightness(0.7);
+}
+
+.wiki-search-input {
+  width: calc(100% - 4px);
+  padding: 2px 4px;
+  margin: 2px 2px 3px 2px;
+  box-sizing: border-box;
+
+  background: transparent;
+  border: 1px solid var(--dim-color);
+  border-radius: 3px;
+  color: inherit;
+}
+
+.wiki-search-input[disabled] {
+  opacity: 0.6;
+  cursor: not-allowed;
+}
+
+.wiki-search-sidebar-box hr {
+  border-color: var(--primary-color);
+  border-style: none none dotted none;
+  margin-top: 3px;
+  margin-bottom: 3px;
+}
+
+.wiki-search-progress-container {
+  padding: 2px 6px 4px 6px;
+  display: flex;
+  flex-direction: row;
+}
+
+.wiki-search-progress-label {
+  font-size: 0.9em;
+  font-style: oblique;
+  cursor: default;
+  margin-right: 1ch;
+}
+
+.wiki-search-progress-bar {
+  flex-grow: 1;
+}
+
+.wiki-search-failed-container {
+  padding: 2px 3px 4px 6px;
+}
+
+.wiki-search-failed-container p {
+  margin: 0;
+}
+
+.wiki-search-results-container {
+  margin-bottom: 0;
+  padding: 2px;
+}
+
+.wiki-search-no-results {
+  font-size: 0.9em;
+  padding: 2px 3px 4px 6px;
+  cursor: default;
+}
+
+.wiki-search-result {
+  position: relative;
+  display: flex;
+  padding: 4px 3px 4px 6px;
+}
+
+.wiki-search-result:hover {
+  text-decoration: none !important;
+}
+
+.wiki-search-result::before {
+  content: '';
+  position: absolute;
+  top: -2px;
+  bottom: -2px;
+  left: 0;
+  right: 0;
+
+  border: 1.5px solid var(--primary-color);
+  border-radius: 4px;
+  display: none;
+}
+
+.wiki-search-result.current-result {
+  background: var(--light-ghost-color);
+  border-top: 1px solid var(--dim-color);
+  border-bottom: 1px solid var(--dim-color);
+}
+
+.wiki-search-result:hover::before {
+  display: block;
+  background: var(--light-ghost-color);
+}
+
+.wiki-search-result.current-result:hover {
+  background: none;
+  border-color: transparent;
+}
+
+.wiki-search-result.current-result:hover .wiki-search-current-result-text {
+  filter: saturate(0.8) brightness(1.4);
+}
+
+.wiki-search-result-text-area {
+  align-self: center;
+  flex-grow: 1;
+  min-width: 0;
+  overflow-wrap: break-word;
+  padding-bottom: 2px;
+}
+
+.wiki-search-result-name {
+  margin-right: 0.25em;
+}
+
+.wiki-search-result:hover .wiki-search-result-name {
+  text-decoration: underline;
+}
+
+.wiki-search-current-result-text,
+.wiki-search-result-kind {
+  font-style: oblique;
+  opacity: 0.9;
+  display: inline-block;
+}
+
+.wiki-search-result-image-container {
+  align-self: flex-start;
+  flex-shrink: 0;
+  margin-right: 6px;
+  border-radius: 2px;
+  overflow: hidden;
+
+  background-color: var(--deep-color);
+  border: 2px solid var(--deep-color);
+}
+
+.wiki-search-results:not(:has(.wiki-search-result-image)) .wiki-search-result-image-container {
+  display: none;
+}
+
+.wiki-search-result-image,
+.wiki-search-result-image-placeholder {
+  display: block;
+  width: 1.8em;
+  height: 1.8em;
+  aspect-ratio: 1 / 1;
+  border-radius: 1.5px;
+}
+
+.wiki-search-result-image-placeholder {
+  background-color: #0004;
+  box-shadow: 0 1px 3px -1px #0008 inset;
+}
+
+.wiki-search-result-image.has-warning {
+  filter: blur(2px) brightness(0.8);
+}
+
+.wiki-search-end-search-line {
+  text-align: center;
+  margin-top: 6px;
+  margin-bottom: 2px;
+}
+
+.wiki-search-end-search-line a {
+  display: inline-block;
+  font-style: oblique;
+  opacity: 0.9;
+  padding: 3px 6px 4px 6px;
+  border-radius: 4px;
+  border: 1.5px solid transparent;
+}
+
+.wiki-search-end-search-line a:hover {
+  opacity: 1;
+  background: var(--light-ghost-color);
+  border-color: var(--deep-color);
+}
+
+.wiki-search-input:focus {
+  border-color: var(--primary-color);
+}
+
+.wiki-search-input::placeholder {
+  color: var(--primary-color);
+  font-style: oblique;
+}
+
+.wiki-search-input:focus::placeholder {
+  color: var(--dim-color);
+}
+
 #content {
   overflow-wrap: anywhere;
 }
@@ -474,6 +751,7 @@ a:not([href]):hover {
 
 .external-link.indicate-external::after {
   content: '\00a0➚';
+  font-style: normal;
 }
 
 .external-link.indicate-external:hover::after {
@@ -488,13 +766,13 @@ a:not([href]):hover {
   display: inline-block;
 }
 
-.nav-links-index .nav-link.has-divider::before,
-.nav-links-groups .nav-link.has-divider::before {
+.nav-links-index .nav-link:not(:first-child)::before,
+.nav-links-groups .nav-link:not(:first-child)::before {
   content: "\0020\00b7\0020";
   font-weight: 800;
 }
 
-.nav-links-hierarchical .nav-link.has-divider::before {
+.nav-links-hierarchical .nav-link:not(:first-child)::before {
   content: "\0020/\0020";
 }
 
@@ -503,6 +781,19 @@ a:not([href]):hover {
   white-space: nowrap;
 }
 
+#header .scoped-chronology {
+  display: none;
+}
+
+#header .scoped-chronology-switcher .switcher-link {
+  text-decoration: underline;
+  text-decoration-style: dotted;
+}
+
+#header .scoped-chronology-switcher > div {
+  margin-left: 20px;
+}
+
 #secondary-nav {
   text-align: center;
 }
@@ -720,6 +1011,10 @@ li:not(:first-child:last-child) .tooltip,
   color: var(--page-primary-color);
 }
 
+progress {
+  accent-color: var(--primary-color);
+}
+
 .content-columns {
   columns: 2;
 }
@@ -801,6 +1096,10 @@ ul.image-details li {
   content: " \00b7 ";
 }
 
+#artist-commentary.first-entry-is-dated {
+  clear: right;
+}
+
 .commentary-entry-heading {
   margin-left: 15px;
   padding-left: 5px;
@@ -813,6 +1112,19 @@ ul.image-details li {
   font-style: oblique;
 }
 
+.commentary-entry-heading time {
+  float: right;
+  padding-left: 0.5ch;
+  padding-right: 0.25ch;
+  margin-left: 0.75ch;
+  border-left: 1px dotted transparent;
+  transition: border-left-color 0.15s;
+}
+
+.commentary-entry-heading time:hover {
+  border-left-color: white;
+}
+
 .commentary-art {
   float: right;
   width: 30%;
@@ -844,6 +1156,17 @@ ul.image-details li {
   margin-bottom: 1.5em;
 }
 
+a.align-center, img.align-center {
+  display: block;
+  margin-left: auto;
+  margin-right: auto;
+}
+
+center {
+  margin-top: 1em;
+  margin-bottom: 1em;
+}
+
 .content-image {
   display: inline-block !important;
 }
@@ -995,6 +1318,17 @@ p code {
   margin-bottom: 0;
 }
 
+#content blockquote h2 {
+  font-size: 1em;
+  font-weight: 800;
+}
+
+#content blockquote h3 {
+  font-size: 1em;
+  font-weight: normal;
+  font-style: oblique;
+}
+
 main.long-content {
   --long-content-padding-ratio: 0.10;
 }
@@ -1011,6 +1345,9 @@ dl dt {
 }
 
 dl dt {
+  /* Heads up, this affects the measurement
+   * for dl dt which are .content-heading!
+   */
   margin-bottom: 2px;
 }
 
@@ -1346,7 +1683,6 @@ img.pixelate, .pixelate img {
 
   font-size: 1.6em;
   opacity: 0.8;
-  background-image: url("warning.svg");
 }
 
 .reveal-interaction {
@@ -1837,6 +2173,13 @@ html[data-url-key="localized.home"] .carousel-container {
   animation-delay: 125ms;
 }
 
+dl dt.content-heading {
+  /* Basic margin-bottom for dt is 2px,
+   * so just subtract 3px from that.
+   */
+  margin-bottom: -1px;
+}
+
 h3.content-heading {
   clear: both;
 }
@@ -2030,40 +2373,40 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
 
 /* Sticky sidebar */
 
-.sidebar-column.sidebar.sticky-column,
-.sidebar-column.sidebar.sticky-last,
-.sidebar-multiple.sticky-last > .sidebar:last-child,
-.sidebar-multiple.sticky-column {
-  position: sticky;
-  top: 10px;
-}
-
-.sidebar-multiple.sticky-last {
+.sidebar-column:not(.sticky-column) {
   align-self: stretch;
 }
 
-.sidebar-multiple.sticky-column {
+.sidebar-column.sticky-column {
+  position: sticky;
+  top: 10px;
   align-self: flex-start;
+  max-height: calc(100vh - 20px);
+  display: flex;
+  flex-direction: column;
 }
 
-.sidebar-column.sidebar.sticky-column {
-  max-height: calc(100vh - 20px);
-  align-self: start;
-  padding-bottom: 0;
-  box-sizing: border-box;
-  flex-basis: 275px;
-  padding-top: 0;
+.sidebar-multiple.sticky-column .sidebar:last-child {
+  flex-shrink: 1;
+  overflow-y: scroll;
+  scrollbar-width: thin;
+  scrollbar-color: var(--dark-color);
+}
+
+.wiki-search-sidebar-box .wiki-search-results-container {
   overflow-y: scroll;
   scrollbar-width: thin;
   scrollbar-color: var(--dark-color);
 }
 
-.sidebar-column.sidebar.sticky-column::-webkit-scrollbar {
+.sidebar-column.sticky-column .sidebar:last-child::-webkit-scrollbar,
+.wiki-search-sidebar-box .wiki-search-results-container::-webkit-scrollbar {
   background: var(--dark-color);
   width: 12px;
 }
 
-.sidebar-column.sidebar.sticky-column::-webkit-scrollbar-thumb {
+.sidebar-column.sticky-column .sidebar:last-child::-webkit-scrollbar-thumb,
+.wiki-search-sidebar-box .wiki-search-results-container::-webkit-scrollbar-thumb {
   transition: background 0.2s;
   background: rgba(255, 255, 255, 0.2);
   border: 3px solid transparent;
@@ -2099,6 +2442,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r
   left: 0;
   right: 0;
   bottom: 0;
+  z-index: 4000;
 
   background: rgba(0, 0, 0, 0.8);
   color: white;
@@ -2250,7 +2594,8 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
 /* Layout - Wide (most computers) */
 
 @media (min-width: 900px) {
-  #page-container:not(.has-zero-sidebars) #secondary-nav {
+  #page-container.showing-sidebar-left #secondary-nav,
+  #page-container.showing-sidebar-left #secondary-nav {
     display: none;
   }
 }
@@ -2267,7 +2612,8 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
    * 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.
    */
-  #page-container:not(.has-zero-sidebars) main.long-content {
+  #page-container.showing-sidebar-left main.long-content,
+  #page-container.showing-sidebar-right main.long-content {
     --long-content-padding-ratio: 0.06;
   }
 }
@@ -2299,12 +2645,12 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
     z-index: 2;
   }
 
-  html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:not(:nth-child(n+7)) {
+  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.has-one-sidebar .grid-listing > .grid-item:nth-child(n+7) {
+  html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:nth-child(n+7) {
     flex-basis: 18%;
     margin: 10px;
   }
@@ -2313,7 +2659,8 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
 /* Layout - Medium or Thin */
 
 @media (max-width: 899.98px) {
-  .sidebar-column:not(.no-hide) {
+  .sidebar.collapsible,
+  .sidebar-column.all-boxes-collapsible {
     display: none;
   }
 
@@ -2321,15 +2668,16 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content
     display: block;
   }
 
-  .layout-columns.vertical-when-thin {
+  .layout-columns {
     flex-direction: column;
   }
 
-  .layout-columns.vertical-when-thin > *:not(:last-child) {
+  .layout-columns > *:not(:last-child) {
     margin-bottom: 10px;
   }
 
-  .sidebar-column.no-hide {
+  .sidebar-column {
+    position: static !important;
     max-width: unset !important;
     flex-basis: unset !important;
     margin-right: 0 !important;
diff --git a/src/static/client3.js b/src/static/js/client.js
index 7d6544a0..68b1a013 100644
--- a/src/static/client3.js
+++ b/src/static/js/client.js
@@ -5,8 +5,19 @@
 // that cannot 8e done at static-site compile time, 8y its fundamentally
 // ephemeral nature.
 
-import {accumulateSum, empty, filterMultipleArrays, stitchArrays}
-  from '../util/sugar.js';
+import {getColors} from '../shared-util/colors.js';
+
+import {
+  accumulateSum,
+  atOffset,
+  empty,
+  filterMultipleArrays,
+  promiseWithResolvers,
+  stitchArrays,
+  withEntries,
+} from '../shared-util/sugar.js';
+
+import {fetchWithProgress} from './xhr-util.js';
 
 const clientInfo = window.hsmusicClientInfo = Object.create(null);
 
@@ -18,20 +29,142 @@ const clientSteps = {
   addPageListeners: [],
 };
 
-function initInfo(key, description) {
+function initInfo(infoKey, description) {
   const object = {...description};
 
   for (const obj of [
     object,
     object.state,
-    object.setting,
+    object.settings,
     object.event,
   ]) {
     if (!obj) continue;
     Object.preventExtensions(obj);
   }
 
-  clientInfo[key] = object;
+  if (object.session) {
+    const sessionSpecs = object.session;
+
+    object.session = {};
+
+    for (const [key, spec] of Object.entries(sessionSpecs)) {
+      const hasSpec =
+        typeof spec === 'object' && spec !== null;
+
+      const defaultValue =
+        (hasSpec
+          ? spec.default ?? null
+          : spec);
+
+      let formatRead = value => value;
+      let formatWrite = value => value;
+      if (hasSpec && spec.type) {
+        switch (spec.type) {
+          case 'number':
+            formatRead = parseFloat;
+            formatWrite = String;
+            break;
+
+          case 'boolean':
+            formatRead = Boolean;
+            formatWrite = String;
+            break;
+
+          case 'string':
+            formatRead = String;
+            formatWrite = String;
+            break;
+
+          case 'json':
+            formatRead = JSON.parse;
+            formatWrite = JSON.stringify;
+            break;
+
+          default:
+            throw new Error(`Unknown type for session storage spec "${spec.type}"`);
+        }
+      }
+
+      let getMaxLength =
+        (!hasSpec
+          ? () => Infinity
+       : typeof spec.maxLength === 'function'
+          ? (object.settings
+              ? () => spec.maxLength(object.settings)
+              : () => spec.maxLength())
+          : () => spec.maxLength);
+
+      const storageKey = `hsmusic.${infoKey}.${key}`;
+
+      let fallbackValue = defaultValue;
+
+      Object.defineProperty(object.session, key, {
+        get: () => {
+          let value;
+          try {
+            value = sessionStorage.getItem(storageKey) ?? defaultValue;
+          } catch (error) {
+            if (error instanceof DOMException) {
+              value = fallbackValue;
+            } else {
+              throw error;
+            }
+          }
+
+          if (value === null) {
+            return null;
+          }
+
+          return formatRead(value);
+        },
+
+        set: (value) => {
+          if (value !== null && value !== '') {
+            value = formatWrite(value);
+          }
+
+          if (value === null) {
+            value = '';
+          }
+
+          const maxLength = getMaxLength();
+          if (value.length > maxLength) {
+            console.warn(
+              `Requested to set session storage ${storageKey} ` +
+              `beyond maximum length ${maxLength}, ` +
+              `ignoring this value.`);
+            console.trace();
+            return;
+          }
+
+          let operation;
+          if (value === '') {
+            fallbackValue = null;
+            operation = () => {
+              sessionStorage.removeItem(storageKey);
+            };
+          } else {
+            fallbackValue = value;
+            operation = () => {
+              sessionStorage.setItem(storageKey, value);
+            };
+          }
+
+          try {
+            operation();
+          } catch (error) {
+            if (!(error instanceof DOMException)) {
+              throw error;
+            }
+          }
+        },
+      });
+    }
+
+    Object.preventExtensions(object.session);
+  }
+
+  clientInfo[infoKey] = object;
 
   return object;
 }
@@ -103,6 +236,18 @@ function cssProp(el, ...args) {
   }
 }
 
+function templateContent(el) {
+  if (el === null) {
+    return null;
+  }
+
+  if (el?.nodeName !== 'TEMPLATE') {
+    throw new Error(`Expected a <template> element`);
+  }
+
+  return el.content.cloneNode(true);
+}
+
 // Curry-style, so multiple points can more conveniently be tested at once.
 function pointIsOverAnyOf(elements) {
   return (clientX, clientY) => {
@@ -135,9 +280,12 @@ function getVisuallyContainingElement(child) {
 const getLinkHref = (type, directory) => rebase(`${type}/${directory}`);
 */
 
-const openAlbum = (d) => rebase(`album/${d}`);
-const openTrack = (d) => rebase(`track/${d}`);
-const openArtist = (d) => rebase(`artist/${d}`);
+const openAlbum = d => rebase(`album/${d}`);
+const openArtTag = d => rebase(`tag/${d}`);
+const openArtist = d => rebase(`artist/${d}`);
+const openFlash = d => rebase(`flash/${d}`);
+const openGroup = d => rebase(`group/${d}`);
+const openTrack = d => rebase(`track/${d}`);
 
 // TODO: This should also use urlSpec.
 
@@ -169,8 +317,8 @@ function dispatchInternalEvent(event, eventName, ...args) {
     try {
       results.push(listener(...args));
     } catch (error) {
-      console.warn(`Uncaught error in listener for ${infoName}.${eventName}`);
-      console.debug(error);
+      console.error(`Uncaught error in listener for ${infoName}.${eventName}`);
+      console.error(error);
       results.push(undefined);
     }
   }
@@ -1004,6 +1152,54 @@ if (
     });
 }
 
+// Links nested in summaries ------------------------------
+
+const summaryNestedLinksInfo = initInfo('summaryNestedLinksInfo', {
+  summaries: null,
+  links: null,
+});
+
+function getSummaryNestedLinksReferences() {
+  const info = summaryNestedLinksInfo;
+
+  info.summaries =
+    Array.from(document.getElementsByTagName('summary'));
+
+  info.links =
+    info.summaries
+      .map(summary =>
+        Array.from(summary.getElementsByTagName('a')));
+
+  filterMultipleArrays(
+    info.summaries,
+    info.links,
+    (_summary, links) => !empty(links));
+}
+
+function addSummaryNestedLinksPageListeners() {
+  const info = summaryNestedLinksInfo;
+
+  for (const {summary, links} of stitchArrays({
+    summary: info.summaries,
+    links: info.links,
+  })) {
+    for (const link of links) {
+      link.addEventListener('mouseover', () => {
+        link.classList.add('nested-hover');
+        summary.classList.add('has-nested-hover');
+      });
+
+      link.addEventListener('mouseout', () => {
+        link.classList.remove('nested-hover');
+        summary.classList.remove('has-nested-hover');
+      });
+    }
+  }
+}
+
+clientSteps.getPageReferences.push(getSummaryNestedLinksReferences);
+clientSteps.getPageReferences.push(addSummaryNestedLinksPageListeners);
+
 // Tooltip-style hover (infrastructure) -------------------
 
 const hoverableTooltipInfo = initInfo('hoverableTooltipInfo', {
@@ -2510,9 +2706,10 @@ function updateStickySubheadingContent(index) {
     }
 
     const textContainer =
-      closestHeading.querySelector('.content-heading-main-title')
-        // Just for compatibility with older builds of the site.
-        ?? closestHeading;
+      templateContent(
+        closestHeading.querySelector('.content-heading-sticky-title')) ??
+      closestHeading.querySelector('.content-heading-main-title') ??
+      closestHeading;
 
     for (const child of textContainer.childNodes) {
       if (child.tagName === 'A') {
@@ -2613,7 +2810,7 @@ function addImageOverlayClickHandlers() {
   }
 }
 
-function handleImageLinkClicked(evt) {
+async function handleImageLinkClicked(evt) {
   if (evt.metaKey || evt.shiftKey || evt.altKey) {
     return;
   }
@@ -2680,26 +2877,46 @@ function handleImageLinkClicked(evt) {
   mainImage.addEventListener('load', handleMainImageLoaded);
   mainImage.addEventListener('error', handleMainImageErrored);
 
-  container.style.setProperty('--download-progress', '0%');
-  loadImage(mainSrc, progress => {
-    container.style.setProperty('--download-progress', (20 + 0.8 * progress) + '%');
-  }).then(
-    blobUrl => {
-      mainImage.src = blobUrl;
-      container.style.setProperty('--download-progress', '100%');
-    },
-    handleMainImageErrored);
+  const showProgress = amount => {
+    cssProp(container, '--download-progress', `${amount * 100}%`);
+  };
+
+  showProgress(0.00);
+
+  const response =
+    await fetchWithProgress(mainSrc, progress => {
+      if (progress === -1) {
+        // TODO: Indeterminate response progress cue
+        showProgress(0.00);
+      } else {
+        showProgress(0.20 + 0.80 * progress);
+      }
+    });
+
+  if (!response.status.toString().startsWith('2')) {
+    handleMainImageErrored();
+    return;
+  }
+
+  const blob = await response.blob();
+  const blobSrc = URL.createObjectURL(blob);
+
+  mainImage.src = blobSrc;
+  showProgress(1.00);
 
   function handleMainImageLoaded() {
-    mainImage.removeEventListener('load', handleMainImageLoaded);
-    mainImage.removeEventListener('error', handleMainImageErrored);
     container.classList.add('loaded');
+    removeEventListeners();
   }
 
   function handleMainImageErrored() {
+    container.classList.add('errored');
+    removeEventListeners();
+  }
+
+  function removeEventListeners() {
     mainImage.removeEventListener('load', handleMainImageLoaded);
     mainImage.removeEventListener('error', handleMainImageErrored);
-    container.classList.add('errored');
   }
 }
 
@@ -2799,67 +3016,6 @@ function updateFileSizeInformation(fileSize) {
 
 addImageOverlayClickHandlers();
 
-/**
- * Credits: Parziphal, Feb 13, 2017
- * https://stackoverflow.com/a/42196770
- *
- * Loads an image with progress callback.
- *
- * The `onprogress` callback will be called by XMLHttpRequest's onprogress
- * event, and will receive the loading progress ratio as an whole number.
- * However, if it's not possible to compute the progress ratio, `onprogress`
- * will be called only once passing -1 as progress value. This is useful to,
- * for example, change the progress animation to an undefined animation.
- *
- * @param  {string}   imageUrl   The image to load
- * @param  {Function} onprogress
- * @return {Promise}
- */
-function loadImage(imageUrl, onprogress) {
-  return new Promise((resolve, reject) => {
-    var xhr = new XMLHttpRequest();
-    var notifiedNotComputable = false;
-
-    xhr.open('GET', imageUrl, true);
-    xhr.responseType = 'arraybuffer';
-
-    xhr.onprogress = function(ev) {
-      if (ev.lengthComputable) {
-        onprogress(parseInt((ev.loaded / ev.total) * 1000) / 10);
-      } else {
-        if (!notifiedNotComputable) {
-          notifiedNotComputable = true;
-          onprogress(-1);
-        }
-      }
-    }
-
-    xhr.onloadend = function() {
-      if (!xhr.status.toString().match(/^2/)) {
-        reject(xhr);
-      } else {
-        if (!notifiedNotComputable) {
-          onprogress(100);
-        }
-
-        var options = {}
-        var headers = xhr.getAllResponseHeaders();
-        var m = headers.match(/^Content-Type:\s*(.*?)$/mi);
-
-        if (m && m[1]) {
-          options.type = m[1];
-        }
-
-        var blob = new Blob([this.response], options);
-
-        resolve(window.URL.createObjectURL(blob));
-      }
-    }
-
-    xhr.send();
-  });
-}
-
 // "Additional names" box ---------------------------------
 
 const additionalNamesBoxInfo = initInfo('additionalNamesBox', {
@@ -2950,6 +3106,114 @@ clientSteps.getPageReferences.push(getAdditionalNamesBoxReferences);
 clientSteps.addInternalListeners.push(addAdditionalNamesBoxInternalListeners);
 clientSteps.addPageListeners.push(addAdditionalNamesBoxListeners);
 
+// Scoped chronology links --------------------------------
+
+const scopedChronologyLinksInfo = initInfo('scopedChronologyLinksInfo', {
+  switcher: null,
+  containers: null,
+  switcherLinks: null,
+  modes: null,
+
+  session: {
+    selectedMode: 'wiki',
+  },
+});
+
+function getScopedChronologyLinksReferences() {
+  const info = scopedChronologyLinksInfo;
+
+  info.switcher =
+    document.querySelector('.scoped-chronology-switcher');
+
+  if (!info.switcher) {
+    return;
+  }
+
+  info.containers =
+    Array.from(info.switcher.querySelectorAll(':scope > div'));
+
+  info.switcherLinks =
+    Array.from(info.switcher.querySelectorAll('.switcher-link'));
+
+  info.modes =
+    info.containers
+      .map(container =>
+        Array.from(container.classList)
+          .find(className => className.startsWith('scope-'))
+          .slice('scope-'.length));
+}
+
+function addScopedChronologyLinksPageHandlers() {
+  const info = scopedChronologyLinksInfo;
+  const {session} = scopedChronologyLinksInfo;
+
+  if (!info.switcher) {
+    return;
+  }
+
+  for (const [index, {
+    container: currentContainer,
+    switcherLink: currentSwitcherLink,
+  }] of stitchArrays({
+    container: info.containers,
+    switcherLink: info.switcherLinks,
+  }).entries()) {
+    const nextContainer =
+      atOffset(info.containers, index, +1, {wrap: true});
+
+    const nextSwitcherLink =
+      atOffset(info.switcherLinks, index, +1, {wrap: true});
+
+    const nextMode =
+      atOffset(info.modes, index, +1, {wrap: true});
+
+    currentSwitcherLink.addEventListener('click', domEvent => {
+      domEvent.preventDefault();
+
+      cssProp(currentContainer, 'display', 'none');
+      cssProp(currentSwitcherLink, 'display', 'none');
+      cssProp(nextContainer, 'display', 'block');
+      cssProp(nextSwitcherLink, 'display', 'inline');
+
+      session.selectedMode = nextMode;
+    });
+  }
+}
+
+function mutateScopedChronologyLinksContent() {
+  const info = scopedChronologyLinksInfo;
+
+  if (!info.switcher) {
+    return;
+  }
+
+  const {selectedMode} = info.session;
+
+  if (info.modes.includes(selectedMode)) {
+    const selectedIndex = info.modes.indexOf(selectedMode);
+
+    for (const [index, {
+      container,
+      switcherLink,
+    }] of stitchArrays({
+      container: info.containers,
+      switcherLink: info.switcherLinks,
+    }).entries()) {
+      if (index === selectedIndex) {
+        cssProp(container, 'display', 'block');
+        cssProp(switcherLink, 'display', 'inline');
+      } else {
+        cssProp(container, 'display', 'none');
+        cssProp(switcherLink, 'display', 'none');
+      }
+    }
+  }
+}
+
+clientSteps.getPageReferences.push(getScopedChronologyLinksReferences);
+clientSteps.mutatePageContent.push(mutateScopedChronologyLinksContent);
+clientSteps.addPageListeners.push(addScopedChronologyLinksPageHandlers);
+
 // Group contributions table ------------------------------
 
 // TODO: Update to clientSteps style.
@@ -3260,6 +3524,1148 @@ clientSteps.getPageReferences.push(getArtistExternalLinkTooltipPageReferences);
 clientSteps.addInternalListeners.push(addArtistExternalLinkTooltipInternalListeners);
 clientSteps.addPageListeners.push(addArtistExternalLinkTooltipPageListeners);
 
+// Internal search functionality --------------------------
+
+const wikiSearchInfo = initInfo('wikiSearchInfo', {
+  state: {
+    worker: null,
+
+    workerReadyPromise: null,
+    workerReadyPromiseResolvers: null,
+
+    workerActionCounter: 0,
+    workerActionPromiseResolverMap: new Map(),
+
+    downloads: Object.create(null),
+  },
+
+  event: {
+    whenWorkerAlive: [],
+    whenWorkerReady: [],
+    whenWorkerFailsToInitialize: [],
+    whenWorkerHasRuntimeError: [],
+
+    whenDownloadBegins: [],
+    whenDownloadsBegin: [],
+    whenDownloadProgresses: [],
+    whenDownloadEnds: [],
+  },
+});
+
+async function initializeSearchWorker() {
+  const {state} = wikiSearchInfo;
+
+  if (state.worker) {
+    return await state.workerReadyPromise;
+  }
+
+  state.worker =
+    new Worker(
+      import.meta.resolve('./search-worker.js'),
+      {type: 'module'});
+
+  state.worker.onmessage = handleSearchWorkerMessage;
+
+  const {promise, resolve, reject} = promiseWithResolvers();
+
+  state.workerReadyPromiseResolvers = {resolve, reject};
+
+  return await (state.workerReadyPromise = promise);
+}
+
+function handleSearchWorkerMessage(message) {
+  switch (message.data.kind) {
+    case 'status':
+      handleSearchWorkerStatusMessage(message);
+      break;
+
+    case 'result':
+      handleSearchWorkerResultMessage(message);
+      break;
+
+    case 'download-begun':
+      handleSearchWorkerDownloadBegunMessage(message);
+      break;
+
+    case 'download-progress':
+      handleSearchWorkerDownloadProgressMessage(message);
+      break;
+
+    case 'download-complete':
+      handleSearchWorkerDownloadCompleteMessage(message);
+      break;
+
+    default:
+      console.warn(`Unknown message kind "${message.data.kind}" <- from search worker`);
+      break;
+  }
+}
+
+function handleSearchWorkerStatusMessage(message) {
+  const {state, event} = wikiSearchInfo;
+
+  switch (message.data.status) {
+    case 'alive':
+      console.debug(`Search worker is alive, but not yet ready.`);
+      dispatchInternalEvent(event, 'whenWorkerAlive');
+      break;
+
+    case 'ready':
+      console.debug(`Search worker has loaded corpuses and is ready.`);
+      state.workerReadyPromiseResolvers.resolve(state.worker);
+      dispatchInternalEvent(event, 'whenWorkerReady');
+      break;
+
+    case 'setup-error':
+      console.debug(`Search worker failed to initialize.`);
+      state.workerReadyPromiseResolvers.reject(new Error('Received "setup-error" status from worker'));
+      dispatchInternalEvent(event, 'whenWorkerFailsToInitialize');
+      break;
+
+    case 'runtime-error':
+      console.debug(`Search worker had an uncaught runtime error.`);
+      dispatchInternalEvent(event, 'whenWorkerHasRuntimeError');
+      break;
+
+    default:
+      console.warn(`Unknown status "${message.data.status}" <- from search worker`);
+      break;
+  }
+}
+
+function handleSearchWorkerResultMessage(message) {
+  const {state} = wikiSearchInfo;
+  const {id} = message.data;
+
+  if (!id) {
+    console.warn(`Result without id <- from search worker:`, message.data);
+    return;
+  }
+
+  if (!state.workerActionPromiseResolverMap.has(id)) {
+    console.warn(`Runaway result id <- from search worker:`, message.data);
+    return;
+  }
+
+  const {resolve, reject} =
+    state.workerActionPromiseResolverMap.get(id);
+
+  switch (message.data.status) {
+    case 'resolve':
+      resolve(message.data.value);
+      break;
+
+    case 'reject':
+      reject(message.data.value);
+      break;
+
+    default:
+      console.warn(`Unknown result status "${message.data.status}" <- from search worker`);
+      return;
+  }
+
+  state.workerActionPromiseResolverMap.delete(id);
+}
+
+function handleSearchWorkerDownloadBegunMessage(message) {
+  const {event} = wikiSearchInfo;
+  const {context: contextKey, keys} = message.data;
+
+  const context = getSearchWorkerDownloadContext(contextKey, true);
+
+  for (const key of keys) {
+    context[key] = 0.00;
+
+    dispatchInternalEvent(event, 'whenDownloadBegins', {
+      context: contextKey,
+      key,
+    });
+  }
+
+  dispatchInternalEvent(event, 'whenDownloadsBegin', {
+    context: contextKey,
+    keys,
+  });
+}
+
+function handleSearchWorkerDownloadProgressMessage(message) {
+  const {event} = wikiSearchInfo;
+  const {context: contextKey, key, progress} = message.data;
+
+  const context = getSearchWorkerDownloadContext(contextKey);
+
+  context[key] = progress;
+
+  dispatchInternalEvent(event, 'whenDownloadProgresses', {
+    context: contextKey,
+    key,
+    progress,
+  });
+}
+
+function handleSearchWorkerDownloadCompleteMessage(message) {
+  const {event} = wikiSearchInfo;
+  const {context: contextKey, key} = message.data;
+
+  const context = getSearchWorkerDownloadContext(contextKey);
+
+  context[key] = 1.00;
+
+  dispatchInternalEvent(event, 'whenDownloadEnds', {
+    context: contextKey,
+    key,
+  });
+}
+
+function getSearchWorkerDownloadContext(context, initialize = false) {
+  const {state} = wikiSearchInfo;
+
+  if (context in state.downloads) {
+    return state.downloads[context];
+  }
+
+  if (!initialize) {
+    return null;
+  }
+
+  return state.downloads[context] = Object.create(null);
+}
+
+async function postSearchWorkerAction(action, options) {
+  const {state} = wikiSearchInfo;
+
+  const worker = await initializeSearchWorker();
+  const id = ++state.workerActionCounter;
+
+  const {promise, resolve, reject} = promiseWithResolvers();
+
+  state.workerActionPromiseResolverMap.set(id, {resolve, reject});
+
+  worker.postMessage({
+    kind: 'action',
+    action: action,
+    id,
+    options,
+  });
+
+  return await promise;
+}
+
+async function searchAll(query, options = {}) {
+  return await postSearchWorkerAction('search', {
+    query,
+    options,
+  });
+}
+
+// Sidebar search box -------------------------------------
+
+const sidebarSearchInfo = initInfo('sidebarSearchInfo', {
+  pageContainer: null,
+
+  searchSidebarColumn: null,
+  searchBox: null,
+  searchInput: null,
+
+  progressRule: null,
+  progressContainer: null,
+  progressLabel: null,
+  progressBar: null,
+
+  failedRule: null,
+  failedContainer: null,
+
+  resultsRule: null,
+  resultsContainer: null,
+  results: null,
+
+  endSearchRule: null,
+  endSearchLine: null,
+  endSearchLink: null,
+
+  preparingString: null,
+  loadingDataString: null,
+  searchingString: null,
+  failedString: null,
+
+  noResultsString: null,
+  currentResultString: null,
+  endSearchString: null,
+
+  albumResultKindString: null,
+  artistResultKindString: null,
+  groupResultKindString: null,
+  tagResultKindString: null,
+
+  state: {
+    sidebarColumnShownForSearch: null,
+
+    tidiedSidebar: null,
+    collapsedDetailsForTidiness: null,
+
+    workerStatus: null,
+    searchStage: null,
+
+    stoppedTypingTimeout: null,
+    stoppedScrollingTimeout: null,
+
+    indexDownloadStatuses: Object.create(null),
+  },
+
+  session: {
+    activeQuery: {
+      type: 'string',
+    },
+
+    activeQueryResults: {
+      type: 'json',
+      maxLength: settings => settings.maxActiveResultsStorage,
+    },
+
+    repeatQueryOnReload: {
+      type: 'boolean',
+      default: false,
+    },
+
+    resultsScrollOffset: {
+      type: 'number',
+    },
+  },
+
+  settings: {
+    stoppedTypingDelay: 800,
+    stoppedScrollingDelay: 200,
+
+    maxActiveResultsStorage: 100000,
+  },
+});
+
+function getSidebarSearchReferences() {
+  const info = sidebarSearchInfo;
+
+  info.pageContainer =
+    document.getElementById('page-container');
+
+  info.searchBox =
+    document.querySelector('.wiki-search-sidebar-box');
+
+  if (!info.searchBox) {
+    return;
+  }
+
+  info.searchInput =
+    info.searchBox.querySelector('.wiki-search-input');
+
+  info.searchSidebarColumn =
+    info.searchBox.closest('.sidebar-column');
+
+  const findString = classPart =>
+    info.searchBox.querySelector(`.wiki-search-${classPart}-string`);
+
+  info.preparingString =
+    findString('preparing');
+
+  info.loadingDataString =
+    findString('loading-data');
+
+  info.searchingString =
+    findString('searching');
+
+  info.failedString =
+    findString('failed');
+
+  info.noResultsString =
+    findString('no-results');
+
+  info.currentResultString =
+    findString('current-result');
+
+  info.endSearchString =
+    findString('end-search');
+
+  info.albumResultKindString =
+    findString('album-result-kind');
+
+  info.artistResultKindString =
+    findString('artist-result-kind');
+
+  info.groupResultKindString =
+    findString('group-result-kind');
+
+  info.tagResultKindString =
+    findString('tag-result-kind');
+}
+
+function addSidebarSearchInternalListeners() {
+  const info = sidebarSearchInfo;
+
+  if (!info.searchBox) return;
+
+  wikiSearchInfo.event.whenWorkerAlive.push(
+    trackSidebarSearchWorkerAlive,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenWorkerReady.push(
+    trackSidebarSearchWorkerReady,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenWorkerFailsToInitialize.push(
+    trackSidebarSearchWorkerFailsToInitialize,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenWorkerHasRuntimeError.push(
+    trackSidebarSearchWorkerHasRuntimeError,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenDownloadsBegin.push(
+    trackSidebarSearchDownloadsBegin,
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenDownloadProgresses.push(
+    updateSidebarSearchStatus);
+
+  wikiSearchInfo.event.whenDownloadEnds.push(
+    trackSidebarSearchDownloadEnds,
+    updateSidebarSearchStatus);
+}
+
+function mutateSidebarSearchContent() {
+  const info = sidebarSearchInfo;
+
+  if (!info.searchBox) return;
+
+  // Progress section
+
+  info.progressRule =
+    document.createElement('hr');
+
+  info.progressContainer =
+    document.createElement('div');
+
+  info.progressContainer.classList.add('wiki-search-progress-container');
+
+  cssProp(info.progressRule, 'display', 'none');
+  cssProp(info.progressContainer, 'display', 'none');
+
+  info.progressLabel =
+    document.createElement('label');
+
+  info.progressLabel.classList.add('wiki-search-progress-label');
+  info.progressLabel.htmlFor = 'wiki-search-progress-bar';
+
+  info.progressBar =
+    document.createElement('progress');
+
+  info.progressBar.classList.add('wiki-search-progress-bar');
+  info.progressBar.id = 'wiki-search-progress-bar';
+
+  info.progressContainer.appendChild(info.progressLabel);
+  info.progressContainer.appendChild(info.progressBar);
+
+  info.searchBox.appendChild(info.progressRule);
+  info.searchBox.appendChild(info.progressContainer);
+
+  // Search failed section
+
+  info.failedRule =
+    document.createElement('hr');
+
+  info.failedContainer =
+    document.createElement('div');
+
+  info.failedContainer.classList.add('wiki-search-failed-container');
+
+  {
+    const p = document.createElement('p');
+    p.appendChild(templateContent(info.failedString));
+    info.failedContainer.appendChild(p);
+  }
+
+  cssProp(info.failedRule, 'display', 'none');
+  cssProp(info.failedContainer, 'display', 'none');
+
+  info.searchBox.appendChild(info.failedRule);
+  info.searchBox.appendChild(info.failedContainer);
+
+  // Results section
+
+  info.resultsRule =
+    document.createElement('hr');
+
+  info.resultsContainer =
+    document.createElement('div');
+
+  info.resultsContainer.classList.add('wiki-search-results-container');
+
+  cssProp(info.resultsRule, 'display', 'none');
+  cssProp(info.resultsContainer, 'display', 'none');
+
+  info.results =
+    document.createElement('div');
+
+  info.results.classList.add('wiki-search-results');
+
+  info.resultsContainer.appendChild(info.results);
+
+  info.searchBox.appendChild(info.resultsRule);
+  info.searchBox.appendChild(info.resultsContainer);
+
+  // End search section
+
+  info.endSearchRule =
+    document.createElement('hr');
+
+  info.endSearchLine =
+    document.createElement('p');
+
+  info.endSearchLink =
+    document.createElement('a');
+
+  {
+    const p = info.endSearchLine;
+    const a = info.endSearchLink;
+    p.classList.add('wiki-search-end-search-line');
+    a.setAttribute('href', '#');
+    a.appendChild(templateContent(info.endSearchString));
+    p.appendChild(a);
+  }
+
+  cssProp(info.endSearchRule, 'display', 'none');
+  cssProp(info.endSearchLine, 'display', 'none');
+
+  info.searchBox.appendChild(info.endSearchRule);
+  info.searchBox.appendChild(info.endSearchLine);
+}
+
+function addSidebarSearchListeners() {
+  const info = sidebarSearchInfo;
+
+  if (!info.searchInput) return;
+
+  info.searchInput.addEventListener('change', domEvent => {
+    if (info.searchInput.value) {
+      activateSidebarSearch(info.searchInput.value);
+    }
+  });
+
+  info.searchInput.addEventListener('input', domEvent => {
+    const {settings, state} = info;
+
+    if (!info.searchInput.value) {
+      clearSidebarSearch();
+      return;
+    }
+
+    if (state.stoppedTypingTimeout) {
+      clearTimeout(state.stoppedTypingTimeout);
+    }
+
+    state.stoppedTypingTimeout =
+      setTimeout(() => {
+        activateSidebarSearch(info.searchInput.value);
+      }, settings.stoppedTypingDelay);
+  });
+
+  info.endSearchLink.addEventListener('click', domEvent => {
+    domEvent.preventDefault();
+    clearSidebarSearch();
+    possiblyHideSearchSidebarColumn();
+    restoreSidebarSearchColumn();
+  });
+
+  info.resultsContainer.addEventListener('scroll', () => {
+    const {settings, state} = info;
+
+    if (state.stoppedScrollingTimeout) {
+      clearTimeout(state.stoppedScrollingTimeout);
+    }
+
+    state.stoppedScrollingTimeout =
+      setTimeout(() => {
+        saveSidebarSearchResultsScrollOffset();
+      }, settings.stoppedScrollingDelay);
+  });
+}
+
+function initializeSidebarSearchState() {
+  const info = sidebarSearchInfo;
+  const {session} = info;
+
+  if (!info.searchInput) return;
+
+  if (session.activeQuery) {
+    info.searchInput.value = session.activeQuery;
+    if (session.repeatQueryOnReload) {
+      activateSidebarSearch(session.activeQuery);
+    } else if (session.activeQueryResults) {
+      showSidebarSearchResults(session.activeQueryResults);
+    }
+  }
+}
+
+function trackSidebarSearchWorkerAlive() {
+  const {state} = sidebarSearchInfo;
+
+  state.workerStatus = 'alive';
+}
+
+function trackSidebarSearchWorkerReady() {
+  const {state} = sidebarSearchInfo;
+
+  state.workerStatus = 'ready';
+  state.searchStage = 'searching';
+}
+
+function trackSidebarSearchWorkerFailsToInitialize() {
+  const {state} = sidebarSearchInfo;
+
+  state.workerStatus = 'failed';
+  state.searchStage = 'failed';
+}
+
+function trackSidebarSearchWorkerHasRuntimeError() {
+  const {state} = sidebarSearchInfo;
+
+  state.workerStatus = 'failed';
+  state.searchStage = 'failed';
+}
+
+function trackSidebarSearchDownloadsBegin(event) {
+  const {state} = sidebarSearchInfo;
+
+  if (event.context === 'search-indexes') {
+    for (const key of event.keys) {
+      state.indexDownloadStatuses[key] = 'active';
+    }
+  }
+}
+
+function trackSidebarSearchDownloadEnds(event) {
+  const {state} = sidebarSearchInfo;
+
+  if (event.context === 'search-indexes') {
+    state.indexDownloadStatuses[event.key] = 'complete';
+
+    const statuses = Object.values(state.indexDownloadStatuses);
+    if (statuses.every(status => status === 'complete')) {
+      for (const key of Object.keys(state.indexDownloadStatuses)) {
+        delete state.indexDownloadStatuses[key];
+      }
+    }
+  }
+}
+
+async function activateSidebarSearch(query) {
+  const {session, settings, state} = sidebarSearchInfo;
+
+  if (state.stoppedTypingTimeout) {
+    clearTimeout(state.stoppedTypingTimeout);
+    state.stoppedTypingTimeout = null;
+  }
+
+  state.searchStage =
+    (state.workerStatus === 'ready'
+      ? 'searching'
+      : 'preparing');
+  updateSidebarSearchStatus();
+
+  let results;
+  try {
+    results = await searchAll(query, {enrich: true});
+  } catch (error) {
+    console.error(`There was an error performing a sidebar search:`);
+    console.error(error);
+    showSidebarSearchFailed();
+    return;
+  }
+
+  state.searchStage = 'complete';
+  updateSidebarSearchStatus();
+
+  session.activeQuery = query;
+  session.activeQueryResults = results;
+  session.resultsScrollOffset = 0;
+
+  showSidebarSearchResults(results);
+}
+
+function clearSidebarSearch() {
+  const info = sidebarSearchInfo;
+  const {session, state} = info;
+
+  if (state.stoppedTypingTimeout) {
+    clearTimeout(state.stoppedTypingTimeout);
+    state.stoppedTypingTimeout = null;
+  }
+
+  info.searchBox.classList.remove('showing-results');
+  info.searchSidebarColumn.classList.remove('search-showing-results');
+
+  info.searchInput.value = '';
+
+  state.searchStage = null;
+
+  session.activeQuery = null;
+  session.activeQueryResults = null;
+  session.resultsScrollOffset = null;
+
+  hideSidebarSearchResults();
+}
+
+function updateSidebarSearchStatus() {
+  const info = sidebarSearchInfo;
+  const {state} = info;
+
+  if (state.searchStage === 'failed') {
+    hideSidebarSearchResults();
+    showSidebarSearchFailed();
+
+    return;
+  }
+
+  const searchIndexDownloads =
+    getSearchWorkerDownloadContext('search-indexes');
+
+  const downloadProgressValues =
+    Object.values(searchIndexDownloads ?? {});
+
+  if (downloadProgressValues.some(v => v < 1.00)) {
+    const total = Object.keys(state.indexDownloadStatuses).length;
+    const sum = accumulateSum(downloadProgressValues);
+    showSidebarSearchProgress(
+      sum / total,
+      templateContent(info.loadingDataString));
+
+    return;
+  }
+
+  if (state.searchStage === 'preparing') {
+    showSidebarSearchProgress(
+      null,
+      templateContent(info.preparingString));
+
+    return;
+  }
+
+  if (state.searchStage === 'searching') {
+    showSidebarSearchProgress(
+      null,
+      templateContent(info.searchingString));
+
+    return;
+  }
+
+  hideSidebarSearchProgress();
+}
+
+function showSidebarSearchProgress(progress, label) {
+  const info = sidebarSearchInfo;
+
+  cssProp(info.progressRule, 'display', null);
+  cssProp(info.progressContainer, 'display', null);
+
+  if (progress === null) {
+    info.progressBar.removeAttribute('value');
+  } else {
+    info.progressBar.value = progress;
+  }
+
+  while (info.progressLabel.firstChild) {
+    info.progressLabel.firstChild.remove();
+  }
+
+  info.progressLabel.appendChild(label);
+}
+
+function hideSidebarSearchProgress() {
+  const info = sidebarSearchInfo;
+
+  cssProp(info.progressRule, 'display', 'none');
+  cssProp(info.progressContainer, 'display', 'none');
+}
+
+function showSidebarSearchFailed() {
+  const info = sidebarSearchInfo;
+  const {state} = info;
+
+  hideSidebarSearchProgress();
+  hideSidebarSearchResults();
+
+  cssProp(info.failedRule, 'display', null);
+  cssProp(info.failedContainer, 'display', null);
+
+  info.searchInput.disabled = true;
+
+  if (state.stoppedTypingTimeout) {
+    clearTimeout(state.stoppedTypingTimeout);
+    state.stoppedTypingTimeout = null;
+  }
+}
+
+function showSidebarSearchResults(results) {
+  const info = sidebarSearchInfo;
+
+  console.debug(`Showing search results:`, results);
+
+  showSearchSidebarColumn();
+
+  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,
+        })));
+
+  info.searchBox.classList.add('showing-results');
+  info.searchSidebarColumn.classList.add('search-showing-results');
+
+  while (info.results.firstChild) {
+    info.results.firstChild.remove();
+  }
+
+  cssProp(info.resultsRule, 'display', 'block');
+  cssProp(info.resultsContainer, 'display', 'block');
+
+  if (empty(flatResults)) {
+    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) {
+    const el = generateSidebarSearchResult(result);
+    if (!el) continue;
+
+    info.results.appendChild(el);
+  }
+
+  if (!empty(flatResults)) {
+    cssProp(info.endSearchRule, 'display', 'block');
+    cssProp(info.endSearchLine, 'display', 'block');
+
+    tidySidebarSearchColumn();
+  }
+
+  restoreSidebarSearchResultsScrollOffset();
+}
+
+function generateSidebarSearchResult(result) {
+  const info = sidebarSearchInfo;
+
+  const preparedSlots = {
+    color:
+      result.data.color ?? null,
+
+    name:
+      result.data.name ?? result.data.primaryName ?? null,
+
+    imageSource:
+      getSearchResultImageSource(result),
+  };
+
+  switch (result.referenceType) {
+    case 'album': {
+      preparedSlots.href =
+        openAlbum(result.directory);
+
+      preparedSlots.kindString =
+        info.albumResultKindString;
+
+      break;
+    }
+
+    case 'artist': {
+      preparedSlots.href =
+        openArtist(result.directory);
+
+      preparedSlots.kindString =
+        info.artistResultKindString;
+
+      break;
+    }
+
+    case 'group': {
+      preparedSlots.href =
+        openGroup(result.directory);
+
+      preparedSlots.kindString =
+        info.groupResultKindString;
+
+      break;
+    }
+
+    case 'flash': {
+      preparedSlots.href =
+        openFlash(result.directory);
+
+      break;
+    }
+
+    case 'tag': {
+      preparedSlots.href =
+        openArtTag(result.directory);
+
+      preparedSlots.kindString =
+        info.tagResultKindString;
+
+      break;
+    }
+
+    case 'track': {
+      preparedSlots.href =
+        openTrack(result.directory);
+
+      break;
+    }
+
+    default:
+      return null;
+  }
+
+  return generateSidebarSearchResultTemplate(preparedSlots);
+}
+
+function getSearchResultImageSource(result) {
+  const {artwork} = result.data;
+  if (!artwork) return null;
+
+  return (
+    rebase(
+      artwork.replace('<>', result.directory),
+      'rebaseThumb'));
+}
+
+function generateSidebarSearchResultTemplate(slots) {
+  const info = sidebarSearchInfo;
+
+  const link = document.createElement('a');
+  link.classList.add('wiki-search-result');
+
+  if (slots.href) {
+    link.setAttribute('href', slots.href);
+  }
+
+  if (slots.color) {
+    cssProp(link, '--primary-color', slots.color);
+
+    try {
+      const colors = getColors(slots.color, {chroma});
+      cssProp(link, '--light-ghost-color', colors.lightGhost);
+      cssProp(link, '--deep-color', colors.deep);
+    } catch (error) {
+      console.warn(error);
+    }
+  }
+
+  const imgContainer = document.createElement('span');
+  imgContainer.classList.add('wiki-search-result-image-container');
+
+  if (slots.imageSource) {
+    const img = document.createElement('img');
+    img.classList.add('wiki-search-result-image');
+    img.setAttribute('src', slots.imageSource);
+    imgContainer.appendChild(img);
+    if (slots.imageSource.endsWith('.mini.jpg')) {
+      img.classList.add('has-warning');
+    }
+  } else {
+    const placeholder = document.createElement('span');
+    placeholder.classList.add('wiki-search-result-image-placeholder');
+    imgContainer.appendChild(placeholder);
+  }
+
+  link.appendChild(imgContainer);
+
+  const text = document.createElement('span');
+  text.classList.add('wiki-search-result-text-area');
+
+  if (slots.name) {
+    const span = document.createElement('span');
+    span.classList.add('wiki-search-result-name');
+    span.appendChild(document.createTextNode(slots.name));
+    text.appendChild(span);
+  }
+
+  let accentSpan = null;
+
+  if (link.href) {
+    const here = location.href.replace(/\/$/, '');
+    const there = link.href.replace(/\/$/, '');
+    if (here === there) {
+      link.classList.add('current-result');
+      accentSpan = document.createElement('span');
+      accentSpan.classList.add('wiki-search-current-result-text');
+      accentSpan.appendChild(templateContent(info.currentResultString));
+    }
+  }
+
+  if (!accentSpan && slots.kindString) {
+    accentSpan = document.createElement('span');
+    accentSpan.classList.add('wiki-search-result-kind');
+    accentSpan.appendChild(templateContent(slots.kindString));
+  }
+
+  if (accentSpan) {
+    text.appendChild(document.createTextNode(' '));
+    text.appendChild(accentSpan);
+  }
+
+  link.appendChild(text);
+
+  link.addEventListener('click', () => {
+    saveSidebarSearchResultsScrollOffset();
+  });
+
+  return link;
+}
+
+function hideSidebarSearchResults() {
+  const info = sidebarSearchInfo;
+
+  cssProp(info.resultsRule, 'display', 'none');
+  cssProp(info.resultsContainer, 'display', 'none');
+
+  while (info.results.firstChild) {
+    info.results.firstChild.remove();
+  }
+
+  cssProp(info.endSearchRule, 'display', 'none');
+  cssProp(info.endSearchLine, 'display', 'none');
+}
+
+function saveSidebarSearchResultsScrollOffset() {
+  const info = sidebarSearchInfo;
+  const {session} = info;
+
+  session.resultsScrollOffset = info.resultsContainer.scrollTop;
+}
+
+function restoreSidebarSearchResultsScrollOffset() {
+  const info = sidebarSearchInfo;
+  const {session} = info;
+
+  if (session.resultsScrollOffset) {
+    info.resultsContainer.scrollTop = session.resultsScrollOffset;
+  }
+}
+
+function showSearchSidebarColumn() {
+  const info = sidebarSearchInfo;
+  const {state} = info;
+
+  if (!info.searchSidebarColumn) {
+    return;
+  }
+
+  if (!info.searchSidebarColumn.classList.contains('initially-hidden')) {
+    return;
+  }
+
+  info.searchSidebarColumn.classList.remove('initially-hidden');
+
+  if (info.searchSidebarColumn.id === 'sidebar-left') {
+    info.pageContainer.classList.add('showing-sidebar-left');
+  } else if (info.searchSidebarColumn.id === 'sidebar-right') {
+    info.pageContainer.classList.add('showing-sidebar-right');
+  }
+
+  state.sidebarColumnShownForSearch = true;
+}
+
+function possiblyHideSearchSidebarColumn() {
+  const info = sidebarSearchInfo;
+  const {state} = info;
+
+  if (!info.searchSidebarColumn) {
+    return;
+  }
+
+  if (!state.sidebarColumnShownForSearch) {
+    return;
+  }
+
+  info.searchSidebarColumn.classList.add('initially-hidden');
+
+  if (info.searchSidebarColumn.id === 'sidebar-left') {
+    info.pageContainer.classList.remove('showing-sidebar-left');
+  } else if (info.searchSidebarColumn.id === 'sidebar-right') {
+    info.pageContainer.classList.remove('showing-sidebar-right');
+  }
+
+  state.sidebarColumnShownForSearch = null;
+}
+
+// This should be called after results are shown, since it checks the
+// elements added to understand the current search state.
+function tidySidebarSearchColumn() {
+  const info = sidebarSearchInfo;
+  const {state} = info;
+
+  // Don't *re-tidy* the sidebar if we've already tidied it to display
+  // some results. This flag will get cleared if the search is dismissed
+  // altogether (and the pre-tidy state is restored).
+  if (state.tidiedSidebar) {
+    return;
+  }
+
+  const here = location.href.replace(/\/$/, '');
+  const currentPageIsResult =
+    Array.from(info.results.querySelectorAll('a'))
+      .some(link => {
+        const there = link.href.replace(/\/$/, '');
+        return here === there;
+      });
+
+  // Don't tidy the sidebar if you've navigated to some other page than
+  // what's in the current result list.
+  if (!currentPageIsResult) {
+    return;
+  }
+
+  state.tidiedSidebar = true;
+  state.collapsedDetailsForTidiness = [];
+
+  for (const box of info.searchSidebarColumn.querySelectorAll('.sidebar')) {
+    if (box === info.searchBox) {
+      continue;
+    }
+
+    for (const details of box.getElementsByTagName('details')) {
+      if (details.open) {
+        details.removeAttribute('open');
+        state.collapsedDetailsForTidiness.push(details);
+      }
+    }
+  }
+}
+
+function restoreSidebarSearchColumn() {
+  const {state} = sidebarSearchInfo;
+
+  if (!state.tidiedSidebar) {
+    return;
+  }
+
+  for (const details of state.collapsedDetailsForTidiness) {
+    details.setAttribute('open', '');
+  }
+
+  state.collapsedDetailsForTidiness = [];
+  state.tidiedSidebar = null;
+}
+
+clientSteps.getPageReferences.push(getSidebarSearchReferences);
+clientSteps.addInternalListeners.push(addSidebarSearchInternalListeners);
+clientSteps.mutatePageContent.push(mutateSidebarSearchContent);
+clientSteps.addPageListeners.push(addSidebarSearchListeners);
+clientSteps.initializeState.push(initializeSidebarSearchState);
+
 // Sticky commentary sidebar ------------------------------
 
 const albumCommentarySidebarInfo = initInfo('albumCommentarySidebarInfo', {
@@ -3476,8 +4882,8 @@ for (const [key, steps] of Object.entries(clientSteps)) {
     try {
       step();
     } catch (error) {
-      console.warn(`During ${key}, failed to run ${step.name}`);
-      console.debug(error);
+      console.error(`During ${key}, failed to run ${step.name}`);
+      console.error(error);
     }
   }
 }
diff --git a/src/static/lazy-loading.js b/src/static/js/lazy-loading.js
index 1df56f08..1df56f08 100644
--- a/src/static/lazy-loading.js
+++ b/src/static/js/lazy-loading.js
diff --git a/src/static/js/module-import-shims.js b/src/static/js/module-import-shims.js
new file mode 100644
index 00000000..e7e1e0cc
--- /dev/null
+++ b/src/static/js/module-import-shims.js
@@ -0,0 +1,27 @@
+export const loadDependency = {
+  async fromWindow(modulePath) {
+    globalThis.window = {};
+
+    await import(modulePath);
+
+    const exports = globalThis.window;
+
+    delete globalThis.window;
+
+    return exports;
+  },
+
+  async fromModuleExports(modulePath) {
+    globalThis.exports = {};
+    globalThis.module = {exports: globalThis.exports};
+
+    await import(modulePath);
+
+    const exports = globalThis.exports;
+
+    delete globalThis.module;
+    delete globalThis.exports;
+
+    return exports;
+  },
+};
diff --git a/src/static/js/search-worker.js b/src/static/js/search-worker.js
new file mode 100644
index 00000000..8d987a74
--- /dev/null
+++ b/src/static/js/search-worker.js
@@ -0,0 +1,621 @@
+/* eslint-env worker */
+
+import FlexSearch from '../lib/flexsearch/flexsearch.bundle.module.min.js';
+
+import {makeSearchIndex, searchSpec} from '../shared-util/search-spec.js';
+
+import {
+  empty,
+  groupArray,
+  promiseWithResolvers,
+  stitchArrays,
+  unique,
+  withEntries,
+} from '../shared-util/sugar.js';
+
+import {loadDependency} from './module-import-shims.js';
+import {fetchWithProgress} from './xhr-util.js';
+
+// Will be loaded from dependencies.
+let decompress;
+let unpack;
+
+let idb;
+
+let status = null;
+let indexes = null;
+
+onmessage = handleWindowMessage;
+onerror = handleRuntimeError;
+onunhandledrejection = handleRuntimeError;
+postStatus('alive');
+
+Promise.all([
+  loadDependencies(),
+  loadDatabase(),
+]).then(main)
+  .then(
+    () => {
+      postStatus('ready');
+    },
+    error => {
+      console.error(`Search worker setup error:`, error);
+      postStatus('setup-error');
+    });
+
+async function loadDependencies() {
+  const {compressJSON} =
+    await loadDependency.fromWindow('../lib/compress-json/bundle.min.js');
+
+  const msgpackr =
+    await loadDependency.fromModuleExports('../lib/msgpackr/index.js');
+
+  ({decompress} = compressJSON);
+  ({unpack} = msgpackr);
+}
+
+async function promisifyIDBRequest(request) {
+  const {promise, resolve, reject} = promiseWithResolvers();
+
+  request.addEventListener('success', () => resolve(request.result));
+  request.addEventListener('error', () => reject(request.error));
+
+  return promise;
+}
+
+async function* iterateIDBObjectStore(store, query) {
+  const request =
+    store.openCursor(query);
+
+  let promise, resolve, reject;
+  let cursor;
+
+  request.onsuccess = () => {
+    cursor = request.result;
+    if (cursor) {
+      resolve({done: false, value: [cursor.key, cursor.value]});
+    } else {
+      resolve({done: true});
+    }
+  };
+
+  request.onerror = () => {
+    reject(request.error);
+  };
+
+  do {
+    ({promise, resolve, reject} = promiseWithResolvers());
+
+    const result = await promise;
+
+    if (result.done) {
+      return;
+    }
+
+    yield result.value;
+
+    cursor.continue();
+  } while (true);
+}
+
+async function loadCachedIndexFromIDB() {
+  if (!idb) return null;
+
+  const transaction =
+    idb.transaction(['indexes'], 'readwrite');
+
+  const store =
+    transaction.objectStore('indexes');
+
+  const result = {};
+
+  for await (const [key, object] of iterateIDBObjectStore(store)) {
+    result[key] = object;
+  }
+
+  return result;
+}
+
+async function loadDatabase() {
+  const request =
+    globalThis.indexedDB.open('hsmusicSearchDatabase', 4);
+
+  request.addEventListener('upgradeneeded', () => {
+    const idb = request.result;
+
+    idb.createObjectStore('indexes', {
+      keyPath: 'key',
+    });
+  });
+
+  try {
+    idb = await promisifyIDBRequest(request);
+  } catch (error) {
+    console.warn(`Couldn't load search IndexedDB - won't use an internal cache.`);
+    console.warn(request.error);
+    idb = null;
+  }
+}
+
+function rebase(path) {
+  return `/search-data/` + path;
+}
+
+async function prepareIndexData() {
+  return Promise.all([
+    fetch(rebase('index.json'))
+      .then(resp => resp.json()),
+
+    loadCachedIndexFromIDB(),
+  ]).then(
+      ([indexData, idbIndexData]) =>
+        ({indexData, idbIndexData}));
+}
+
+function fetchIndexes(keysNeedingFetch) {
+  if (!empty(keysNeedingFetch)) {
+    postMessage({
+      kind: 'download-begun',
+      context: 'search-indexes',
+      keys: keysNeedingFetch,
+    });
+  }
+
+  return (
+    keysNeedingFetch.map(key =>
+      fetchWithProgress(
+        rebase(key + '.json.msgpack'),
+        progress => {
+          postMessage({
+            kind: 'download-progress',
+            context: 'search-indexes',
+            progress: progress / 1.00,
+            key,
+          });
+        }).then(response => {
+            postMessage({
+              kind: 'download-complete',
+              context: 'search-indexes',
+              key,
+            });
+
+            return response;
+          })));
+}
+
+async function main() {
+  const prepareIndexDataPromise = prepareIndexData();
+
+  indexes =
+    withEntries(searchSpec, entries => entries
+      .map(([key, descriptor]) => [
+        key,
+        makeSearchIndex(descriptor, {FlexSearch}),
+      ]));
+
+  const {indexData, idbIndexData} = await prepareIndexDataPromise;
+
+  const keysNeedingFetch =
+    (idbIndexData
+      ? Object.keys(indexData)
+          .filter(key =>
+            indexData[key].md5 !==
+            idbIndexData[key]?.md5)
+      : Object.keys(indexData));
+
+  const keysFromCache =
+    Object.keys(indexData)
+      .filter(key => !keysNeedingFetch.includes(key))
+
+  const cacheArrayBufferPromises =
+    keysFromCache
+      .map(key => idbIndexData[key])
+      .map(({cachedBinarySource}) =>
+        cachedBinarySource.arrayBuffer());
+
+  const fetchPromises =
+    fetchIndexes(keysNeedingFetch);
+
+  const fetchBlobPromises =
+    fetchPromises
+      .map(promise => promise
+        .then(response => response.blob()));
+
+  const fetchArrayBufferPromises =
+    fetchBlobPromises
+      .map(promise => promise
+        .then(blob => blob.arrayBuffer()));
+
+  function arrayBufferToJSON(data) {
+    data = new Uint8Array(data);
+    data = unpack(data);
+    data = decompress(data);
+    return data;
+  }
+
+  function importIndexes(keys, jsons) {
+    stitchArrays({key: keys, json: jsons})
+      .forEach(({key, json}) => {
+        importIndex(key, json);
+      });
+  }
+
+  if (idb) {
+    console.debug(`Reusing indexes from search cache:`, keysFromCache);
+    console.debug(`Fetching indexes anew:`, keysNeedingFetch);
+  }
+
+  await Promise.all([
+    async () => {
+      const cacheArrayBuffers =
+        await Promise.all(cacheArrayBufferPromises);
+
+      const cacheJSONs =
+        cacheArrayBuffers
+          .map(arrayBufferToJSON);
+
+      importIndexes(keysFromCache, cacheJSONs);
+    },
+
+    async () => {
+      const fetchArrayBuffers =
+        await Promise.all(fetchArrayBufferPromises);
+
+      const fetchJSONs =
+        fetchArrayBuffers
+          .map(arrayBufferToJSON);
+
+      importIndexes(keysNeedingFetch, fetchJSONs);
+    },
+
+    async () => {
+      if (!idb) return;
+
+      const fetchBlobs =
+        await Promise.all(fetchBlobPromises);
+
+      const transaction =
+        idb.transaction(['indexes'], 'readwrite');
+
+      const store =
+        transaction.objectStore('indexes');
+
+      for (const {key, blob} of stitchArrays({
+        key: keysNeedingFetch,
+        blob: fetchBlobs,
+      })) {
+        const value = {
+          key,
+          md5: indexData[key].md5,
+          cachedBinarySource: blob,
+        };
+
+        try {
+          await promisifyIDBRequest(store.put(value));
+        } catch (error) {
+          console.warn(`Error saving ${key} to internal search cache:`, value);
+          console.warn(error);
+          continue;
+        }
+      }
+    },
+  ].map(fn => fn()));
+}
+
+function importIndex(indexKey, indexData) {
+  // If this fails, it's because an outdated index was cached.
+  // TODO: If this fails, try again once with a cache busting url.
+  for (const [key, value] of Object.entries(indexData)) {
+    indexes[indexKey].import(key, JSON.stringify(value));
+  }
+}
+
+function handleRuntimeError() {
+  postStatus('runtime-error');
+}
+
+function handleWindowMessage(message) {
+  switch (message.data.kind) {
+    case 'action':
+      handleWindowActionMessage(message);
+      break;
+
+    default:
+      console.warn(`Unknown message kind -> to search worker:`, message.data);
+      break;
+  }
+}
+
+async function handleWindowActionMessage(message) {
+  const {id} = message.data;
+
+  if (!id) {
+    console.warn(`Action without id -> to search worker:`, message.data);
+    return;
+  }
+
+  if (status !== 'ready') {
+    return postActionResult(id, 'reject', 'not ready');
+  }
+
+  let value;
+
+  switch (message.data.action) {
+    case 'search':
+      value = await performSearchAction(message.data.options);
+      break;
+
+    default:
+      console.warn(`Unknown action "${message.data.action}" -> to search worker:`, message.data);
+      return postActionResult(id, 'reject', 'unknown action');
+  }
+
+  await postActionResult(id, 'resolve', value);
+}
+
+function postStatus(newStatus) {
+  status = newStatus;
+  postMessage({
+    kind: 'status',
+    status: newStatus,
+  });
+}
+
+function postActionResult(id, status, value) {
+  postMessage({
+    kind: 'result',
+    id,
+    status,
+    value,
+  });
+}
+
+function performSearchAction({query, options}) {
+  const {generic, ...otherIndexes} = indexes;
+
+  const genericResults =
+    queryGenericIndex(generic, query, options);
+
+  const otherResults =
+    withEntries(otherIndexes, entries => entries
+      .map(([indexName, index]) => [
+        indexName,
+        index.search(query, options),
+      ]));
+
+  return {
+    generic: genericResults,
+    ...otherResults,
+  };
+}
+
+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 interestingFields =
+    unique(interestingFieldCombinations.flat());
+
+  const {genericTerms, queriedKind} =
+    processTerms(query);
+
+  const particles =
+    particulate(genericTerms);
+
+  const groupedParticles =
+    groupArray(particles, ({length}) => length);
+
+  const queriesBy = keys =>
+    (groupedParticles.get(keys.length) ?? [])
+      .flatMap(permutations)
+      .map(values => values.map(({terms}) => terms.join(' ')))
+      .map(values =>
+        stitchArrays({
+          field: keys,
+          query: values,
+        }));
+
+  const boilerplate = queryBoilerplate(index);
+
+  const particleResults =
+    Object.fromEntries(
+      interestingFields.map(field => [
+        field,
+        Object.fromEntries(
+          particles.flat()
+            .map(({terms}) => terms.join(' '))
+            .map(query => [
+              query,
+              new Set(
+                boilerplate
+                  .query(query, {
+                    ...options,
+                    field,
+                    limit: Infinity,
+                  })
+                  .fieldResults[field]),
+            ])),
+      ]));
+
+  const results = 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 commonAcrossFields =
+        Array.from(idToMatchingFieldsMap.entries())
+          .filter(([id, matchingFields]) =>
+            matchingFields.length === interestingFieldCombination.length)
+          .map(([id]) => id);
+
+      for (const result of commonAcrossFields) {
+        results.add(result);
+      }
+    }
+  }
+
+  const constituted =
+    boilerplate.constitute(results);
+
+  const constitutedAndFiltered =
+    constituted
+      .filter(({id}) =>
+        (queriedKind
+          ? id.split(':')[0] === queriedKind
+          : true));
+
+  return constitutedAndFiltered;
+}
+
+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']},
+  ];
+
+  const genericTerms = [];
+  let queriedKind = null;
+
+  const termRegexp =
+    new RegExp(
+      String.raw`(?<kind>${kindTermSpec.flatMap(spec => spec.terms).join('|')})` +
+      String.raw`|\S+`,
+      'gi');
+
+  for (const match of query.matchAll(termRegexp)) {
+    const {groups} = match;
+
+    if (groups.kind && !queriedKind) {
+      queriedKind =
+        kindTermSpec
+          .find(({terms}) => terms.includes(groups.kind.toLowerCase()))
+          .kind;
+
+      continue;
+    }
+
+    genericTerms.push(match[0]);
+  }
+
+  return {genericTerms, queriedKind};
+}
+
+function particulate(terms) {
+  if (empty(terms)) return [];
+
+  const results = [];
+
+  for (let slice = 1; slice <= 2; slice++) {
+    if (slice === terms.length) {
+      break;
+    }
+
+    const front = terms.slice(0, slice);
+    const back = terms.slice(slice);
+
+    results.push(...
+      particulate(back)
+        .map(result => [
+          {terms: front},
+          ...result
+        ]));
+  }
+
+  results.push([{terms}]);
+
+  return results;
+}
+
+// This function doesn't even come close to "performant",
+// but it only operates on small data here.
+function permutations(array) {
+  switch (array.length) {
+    case 0:
+      return [];
+
+    case 1:
+      return [array];
+
+    default:
+      return array.flatMap((item, index) => {
+        const behind = array.slice(0, index);
+        const ahead = array.slice(index + 1);
+        return (
+          permutations([...behind, ...ahead])
+            .map(rest => [item, ...rest]));
+      });
+  }
+}
+
+function queryBoilerplate(index) {
+  const idToDoc = {};
+
+  return {
+    idToDoc,
+
+    constitute: (ids) =>
+      Array.from(ids)
+        .map(id => ({id, doc: idToDoc[id]})),
+
+    query: (query, options) => {
+      const rawResults =
+        index.search(query, options);
+
+      const fieldResults =
+        Object.fromEntries(
+          rawResults
+            .map(({field, result}) => [
+              field,
+              result.map(result =>
+                (typeof result === 'string'
+                  ? result
+                  : result.id)),
+            ]));
+
+      Object.assign(
+        idToDoc,
+        Object.fromEntries(
+          rawResults
+            .flatMap(({result}) => result)
+            .map(({id, doc}) => [id, doc])));
+
+      return {rawResults, fieldResults};
+    },
+  };
+}
diff --git a/src/static/js/xhr-util.js b/src/static/js/xhr-util.js
new file mode 100644
index 00000000..8a43072c
--- /dev/null
+++ b/src/static/js/xhr-util.js
@@ -0,0 +1,64 @@
+/* eslint-env browser */
+
+/**
+ * This fetch function is adapted from a `loadImage` function
+ * credited to Parziphal, Feb 13, 2017.
+ * https://stackoverflow.com/a/42196770
+ *
+ * The callback is generally run with the loading progress as a decimal 0-1.
+ * However, if it's not possible to compute the progress ration (which might
+ * only become apparent after a progress amount *has* been sent!),
+ * the callback will be run with the value -1.
+ *
+ * The return promise resolves to a manually instantiated Response object
+ * which generally behaves the same as a normal fetch response; access headers,
+ * text, blob, arrayBuffer as usual. Accordingly, non-200 responses do *not*
+ * reject the prmoise, so be sure to check the response status yourself.
+ */
+export function fetchWithProgress(url, progressCallback) {
+  return new Promise(resolve => {
+    const xhr = new XMLHttpRequest();
+    let notifiedNotComputable = false;
+
+    xhr.open('GET', url, true);
+    xhr.responseType = 'arraybuffer';
+
+    xhr.onprogress = event => {
+      if (notifiedNotComputable) {
+        return;
+      }
+
+      if (!event.lengthComputable) {
+        notifiedNotComputable = true;
+        progressCallback(-1);
+        return;
+      }
+
+      progressCallback(event.loaded / event.total);
+    };
+
+    xhr.onloadend = () => {
+      const body = xhr.response;
+
+      const options = {
+        status: xhr.status,
+        headers:
+          parseResponseHeaders(xhr.getAllResponseHeaders()),
+      };
+
+      resolve(new Response(body, options));
+    };
+
+    xhr.send();
+  });
+
+  function parseResponseHeaders(headers) {
+    return (
+      Object.fromEntries(
+        headers
+          .trim()
+          .split(/[\r\n]+/)
+          .map(line => line.match(/(.+?):\s*(.+)/))
+          .map(match => [match[1], match[2]])));
+  }
+}
diff --git a/src/static/icons.svg b/src/static/misc/icons.svg
index 8c9a80a9..8c9a80a9 100644
--- a/src/static/icons.svg
+++ b/src/static/misc/icons.svg
diff --git a/src/static/warning.svg b/src/static/misc/warning.svg
index 92e55778..92e55778 100644
--- a/src/static/warning.svg
+++ b/src/static/misc/warning.svg
diff --git a/src/static/shared-util/README.md b/src/static/shared-util/README.md
new file mode 100644
index 00000000..d21c0e6b
--- /dev/null
+++ b/src/static/shared-util/README.md
@@ -0,0 +1,11 @@
+# `src/static/shared-util`
+
+Module imports under `src/static/js` may appear to be pointing to files that aren't at quite the right place. For example, the import:
+
+    import {empty} from '../shared-util/sugar.js';
+
+...is reading a file that doesn't exist here, under `shared-util`. This isn't an error!
+
+This folder (`src/shared-util`) does not actually exist in a build of the website; instead, the folder `src/util` is symlinked in its place. So, all files under `src/util` are actually available at (e.g.) `/static/shared-util/` online.
+
+The above import would actually import from the bindings in `src/util/sugar.js`.