« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/common-util/sugar.js47
-rw-r--r--src/common-util/wiki-data.js88
-rw-r--r--src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js141
-rw-r--r--src/content/dependencies/generateContentEntry.js8
-rw-r--r--src/content/dependencies/generateContentEntryDate.js2
-rw-r--r--src/content/dependencies/generateLyricsEntry.js4
-rw-r--r--src/content/dependencies/generateTrackInfoPageOtherReleaseTooltip.js16
-rw-r--r--src/data/checks.js7
-rw-r--r--src/data/composite.js32
-rw-r--r--src/data/composite/things/content/withExpressedOrImplicitArtistReferences.js29
-rw-r--r--src/data/things/content/ContentEntry.js53
-rw-r--r--src/search-select.js3
-rw-r--r--src/static/css/responsive.css2
-rw-r--r--src/static/css/search.css63
-rw-r--r--src/static/js/client/index.js2
-rw-r--r--src/static/js/client/sidebar-search.js109
-rw-r--r--src/strings-default.yaml5
17 files changed, 401 insertions, 210 deletions
diff --git a/src/common-util/sugar.js b/src/common-util/sugar.js
index 4dd34785..c988156c 100644
--- a/src/common-util/sugar.js
+++ b/src/common-util/sugar.js
@@ -418,39 +418,28 @@ export function escapeRegex(string) {
 }
 
 // Adapted from here: https://emnudge.dev/notes/multiline-regex/
+// ...with a lot of changes
 export function re(...args) {
-  let flags = '';
-
-  const fn = (strings, ...substitutions) => {
-    strings = strings
-      .map(str => str.replace(/(?:\/\/.+)/gm, ''))
-      .map(str => str.replace(/\s+/g, ''));
-
-    substitutions = substitutions
-      .map(sub => [sub].flat(Infinity))
-      .map(sub => sub
-        .map(item =>
-          (item instanceof RegExp
-            ? item.source
-            : item.toString())))
-      .map(sub => sub.join(''));
-
-    const source =
-      String.raw({raw: strings}, ...substitutions);
-
-    return new RegExp(source, flags);
-  };
-
-  if (
-    args.length === 1 &&
-    typeof args[0] === 'string' &&
-    args[0].match(/^[a-z]+$/)
-  ) {
+  let flags, parts;
+  if (args.length === 2) {
     flags = args[0];
-    return fn;
+    parts = args[1];
+  } else if (args.length === 1) {
+    flags = '';
+    parts = args[0];
   } else {
-    return fn(...args);
+    throw new Error(`Expected 1 or 2 arguments`);
   }
+
+  const source = parts
+    .flat(Infinity)
+    .map(item =>
+      (item instanceof RegExp
+        ? item.source
+        : item.toString()))
+    .join('');
+
+  return new RegExp(source, flags);
 }
 
 export function splitKeys(key) {
diff --git a/src/common-util/wiki-data.js b/src/common-util/wiki-data.js
index 4c7f66f4..ff325b7a 100644
--- a/src/common-util/wiki-data.js
+++ b/src/common-util/wiki-data.js
@@ -1,6 +1,6 @@
 // Utility functions for interacting with wiki data.
 
-import {accumulateSum, chunkByConditions, empty, unique} from './sugar.js';
+import {accumulateSum, chunkByConditions, empty, re, unique} from './sugar.js';
 import {sortByDate} from './sort.js';
 
 // This is a duplicate binding of filterMultipleArrays that's included purely
@@ -76,42 +76,48 @@ export function compareKebabCase(name1, name2) {
 //   by slashes or dashes (only valid orders are MM/DD/YYYY and YYYY/MM/DD)
 //
 const dateRegex = groupName =>
-  String.raw`(?<${groupName}>` +
-    String.raw`[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|` +
-    String.raw`[0-9]{1,2} [^,]*[0-9]{4,4}|` +
-    String.raw`[0-9]{1,4}[-/][0-9]{1,4}[-/][0-9]{1,4}` +
-  String.raw`)`;
-
-const contentEntryHeadingRegexRaw =
-  String.raw`^(?:` +
-    String.raw`(?:` +
-      String.raw`<i>(?<artists>.+?):<\/i>` +
-      String.raw`(?: \((?<annotation1>.*)\))?` +
-    String.raw`)` +
-    String.raw`|` +
-    String.raw`(?:` +
-      String.raw`@@ (?<annotation2>.*)` +
-    String.raw`)` +
-  String.raw`)$`;
+  re([
+    `(?<${groupName}>`,
+      /[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}/, '|',
+      /[0-9]{1,2} [^,]*[0-9]{4,4}/, '|',
+      /[0-9]{1,4}[-/][0-9]{1,4}[-/][0-9]{1,4}/,
+    `)`,
+  ]);
 
 const contentEntryHeadingRegex =
-  new RegExp(contentEntryHeadingRegexRaw, 'gm');
-
-const contentEntryAnnotationTailRegexRaw =
-  String.raw`(?:, |^)` +
-
-  String.raw`(?:(?<dateKind>sometime|throughout|around) )?` +
-  String.raw`${dateRegex('date')}` +
-  String.raw`(?: ?- ?${dateRegex('secondDate')})?` +
-
-  String.raw`(?: ?(?<= )` +
-    String.raw`(?<accessKind>captured|accessed) ${dateRegex('accessDate')}` +
-  String.raw`)?` +
-
-  String.raw`$`;
+  re('gm', [
+    '^(?:',
+      '(?:',
+        /<i>(?<artists>.+?):<\/i>/,
+        /(?: \((?<annotation1>.*)\))?/,
+      ')',
+      '|',
+      '(?:',
+        /@@ (?<annotation2>.*)/,
+      ')',
+    ')$',
+  ]);
 
 const contentEntryAnnotationTailRegex =
-  new RegExp(contentEntryAnnotationTailRegexRaw);
+  re([
+    /(?:, |^)/,
+
+    /(?:(?<dateKind>sometime|throughout|around) )?/,
+    dateRegex('date'),
+
+    '(?:',
+      ' ?',
+      '-',
+      ' ?',
+      dateRegex('secondDate'),
+    ')?',
+
+    '(?: ?(?<= )',
+      /(?<accessKind>captured|accessed)/,
+      ' ',
+      dateRegex('accessDate'),
+    ')?',
+  ]);
 
 export function* matchContentEntries(sourceText) {
   let workingEntry = null;
@@ -593,7 +599,21 @@ export function* matchMarkdownLinks(markdownSource, {marked}) {
 }
 
 export function* matchInlineLinks(source) {
-  const plausibleLinkRegexp = /\b[a-z]*:\/\/[^ ]*?(?=(?:[,.!?]*)(?:\s|$))/gm;
+  const plausibleLinkRegexp =
+    re('gmi', [
+      /\b[a-z]*:\/\//,
+      /.*?/,
+
+      '(?=',
+        // Ordinary in-sentence punctuation doesn't terminate the
+        // un-greedy URL match above, but it shouldn't be counted
+        // as part of the link either, if it's at the end.
+        /(?:[,.!?]*)/,
+
+        // Actual terminators.
+        /(?:\s|$|<br>)/,
+      ')',
+    ]);
 
   let plausibleMatch = null;
   while (plausibleMatch = plausibleLinkRegexp.exec(source)) {
diff --git a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
index 572eb982..6b603375 100644
--- a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
+++ b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js
@@ -30,6 +30,10 @@ export default {
         flashAct,
         flash,
 
+        quoted:
+         !entry.headingArtists.includes(artist) &&
+          entry.quotedArtists.includes(artist),
+
         annotation: entry.annotation,
         annotationParts: entry.annotationParts,
       },
@@ -191,6 +195,11 @@ export default {
       query.chunks
         .map(({chunk}) => chunk
           .map(({itemType}) => itemType)),
+
+    itemQuoted:
+      query.chunks
+        .map(({chunk}) => chunk
+          .map(({quoted}) => quoted)),
   }),
 
   generate: (data, relations, {html, language}) =>
@@ -206,6 +215,7 @@ export default {
         itemLinks: relations.itemLinks,
         itemAnnotations: relations.itemAnnotations,
         itemTypes: data.itemTypes,
+        itemQuoted: data.itemQuoted,
       }).map(({
           chunk,
           chunkLink,
@@ -215,64 +225,77 @@ export default {
           itemLinks,
           itemAnnotations,
           itemTypes,
+          itemQuoted,
         }) =>
-          language.encapsulate('artistPage.creditList.entry', capsule =>
-            (chunkType === 'album'
-              ? chunk.slots({
-                  mode: 'album',
-                  link: chunkLink,
-
-                  list:
-                    html.tag('ul',
-                      stitchArrays({
-                        item: items,
-                        link: itemLinks,
-                        annotation: itemAnnotations,
-                        type: itemTypes,
-                      }).map(({item, link, annotation, type}) =>
-                        item.slots({
-                          // The citation slot, instead of annotation, gives commentary
-                          // a specially custom look.
-                          citation:
-                            annotation.slots({
-                              mode: 'inline',
-                              absorbPunctuationFollowingExternalLinks: false,
-                            }),
-
-                          content:
-                            (type === 'album'
-                              ? html.tag('i',
-                                  language.$(capsule, 'album.commentary'))
-                              : language.$(capsule, 'track', {track: link})),
-                        }))),
-                })
-
-             : chunkType === 'flash-act'
-              ? chunk.slots({
-                  mode: 'flash',
-                  link: chunkLink,
-
-                  list:
-                    html.tag('ul',
-                      stitchArrays({
-                        item: items,
-                        link: itemLinks,
-                        annotation: itemAnnotations,
-                      }).map(({item, link, annotation}) =>
-                        item.slots({
-                          annotation:
-                            (annotation
-                              ? annotation.slots({
-                                  mode: 'inline',
-                                  absorbPunctuationFollowingExternalLinks: false,
-                                })
-                              : null),
-
-                          content:
-                            language.$(capsule, 'flash', {
-                              flash: link,
-                            }),
-                        }))),
-                })
-              : null)))),
+          language.encapsulate('artistPage.creditList.entry', capsule => {
+            // The citation slot, instead of annotation, gives commentary
+            // a specially custom look.
+            const citations =
+              stitchArrays({annotation: itemAnnotations, quoted: itemQuoted})
+                .map(({annotation, quoted}) =>
+                  language.encapsulate(capsule, workingCapsule => {
+                    const workingOptions = {};
+
+                    let any = false;
+
+                    annotation.setSlots({
+                      mode: 'inline',
+                      absorbPunctuationFollowingExternalLinks: false,
+                    });
+
+                    if (!html.isBlank(annotation)) {
+                      workingCapsule += '.citation';
+                      workingOptions.citation = annotation;
+                      any = true;
+                    }
+
+                    if (quoted) {
+                      workingCapsule += '.quoted';
+                      any = true;
+                    }
+
+                    if (any) {
+                      return language.$(workingCapsule, workingOptions);
+                    } else {
+                      return html.blank();
+                    }
+                  }));
+
+            let contents;
+
+            if (chunkType === 'album') {
+              chunk.setSlot('mode', 'album');
+              contents =
+                stitchArrays({link: itemLinks, type: itemTypes})
+                  .map(({link, type}) =>
+                    (type === 'album'
+                      ? html.tag('i',
+                          language.$(capsule, 'album.commentary'))
+                      : language.$(capsule, 'track', {track: link})));
+
+            } else if (chunkType === 'flash-act') {
+              chunk.setSlot('mode', 'flash');
+              contents =
+                itemLinks.map(link =>
+                  language.$(capsule, 'flash', {flash: link}));
+
+            } else {
+              throw new Error(`Gyeep!!`);
+            }
+
+            chunk.setSlots({
+              link: chunkLink,
+
+              list:
+                html.tag('ul',
+                  stitchArrays({
+                    item: items,
+                    citation: citations,
+                    content: contents,
+                  }).map(({item, citation, content}) =>
+                      item.slots({citation, content}))),
+            });
+
+            return chunk;
+          }))),
 };
diff --git a/src/content/dependencies/generateContentEntry.js b/src/content/dependencies/generateContentEntry.js
index c77f744a..40c637a3 100644
--- a/src/content/dependencies/generateContentEntry.js
+++ b/src/content/dependencies/generateContentEntry.js
@@ -3,14 +3,14 @@ import {empty} from '#sugar';
 export default {
   relations: (relation, entry) => ({
     artistLinks:
-      (!empty(entry.artists) && !entry.artistText
-        ? entry.artists
+      (!empty(entry.headingArtists) && !entry.headingArtistText
+        ? entry.headingArtists
             .map(artist => relation('linkArtist', artist))
         : null),
 
     artistsContent:
-      (entry.artistText
-        ? relation('transformContent', entry.artistText)
+      (entry.headingArtistText
+        ? relation('transformContent', entry.headingArtistText)
         : null),
 
     annotationContent:
diff --git a/src/content/dependencies/generateContentEntryDate.js b/src/content/dependencies/generateContentEntryDate.js
index 845cb5ed..5255c7ea 100644
--- a/src/content/dependencies/generateContentEntryDate.js
+++ b/src/content/dependencies/generateContentEntryDate.js
@@ -35,7 +35,7 @@ export default {
 
      : entry.thing.isTrack &&
        entry.thing.date === entry.thing.album.date &&
-       entry.thing.style === 'single'
+       entry.thing.album.style === 'single'
         ? 'single'
 
      : entry.thing.isTrack &&
diff --git a/src/content/dependencies/generateLyricsEntry.js b/src/content/dependencies/generateLyricsEntry.js
index 15f84b27..0ecf319f 100644
--- a/src/content/dependencies/generateLyricsEntry.js
+++ b/src/content/dependencies/generateLyricsEntry.js
@@ -4,10 +4,10 @@ export default {
       relation('transformContent', entry.body),
 
     artistText:
-      relation('transformContent', entry.artistText),
+      relation('transformContent', entry.headingArtistText),
 
     artistLinks:
-      entry.artists
+      entry.headingArtists
         .filter(artist => artist.name !== 'HSMusic Wiki') // smh
         .map(artist => relation('linkArtist', artist)),
 
diff --git a/src/content/dependencies/generateTrackInfoPageOtherReleaseTooltip.js b/src/content/dependencies/generateTrackInfoPageOtherReleaseTooltip.js
index fcb2e2fa..4c6bda1b 100644
--- a/src/content/dependencies/generateTrackInfoPageOtherReleaseTooltip.js
+++ b/src/content/dependencies/generateTrackInfoPageOtherReleaseTooltip.js
@@ -20,6 +20,9 @@ export default {
       (compareKebabCase(otherTrack.name, currentTrack.name)
         ? null
         : otherTrack.name),
+
+    onSingle:
+      otherTrack.album.style === 'single',
   }),
 
   generate: (data, relations, {html, language}) =>
@@ -36,10 +39,17 @@ export default {
         ],
 
         content: [
-          language.$(capsule, 'differentName', {
-            [language.onlyIfOptions]: ['name'],
+          language.encapsulate(capsule, 'differentName', workingCapsule => {
+            const workingOptions = {
+              [language.onlyIfOptions]: ['name'],
+              name: data.differentName,
+            };
+
+            if (data.onSingle) {
+              workingCapsule += '.onSingle';
+            }
 
-            name: data.differentName,
+            return language.$(workingCapsule, workingOptions);
           }),
 
           data.otherDate && data.currentDate &&
diff --git a/src/data/checks.js b/src/data/checks.js
index 0a0e7f52..01b5cf9e 100644
--- a/src/data/checks.js
+++ b/src/data/checks.js
@@ -352,6 +352,9 @@ export function filterReferenceErrors(wikiData, {
 
             switch (findFnKey) {
               case '_content':
+                // note: quoted artists ("Quoted Artists" field)`) currently
+                // not handled here at all. limitation of current code! oops!
+
                 if (value) {
                   value =
                     value.map(entry =>
@@ -721,13 +724,13 @@ export function reportContentTextErrors(wikiData, {
 
   const commentaryShape = {
     body: 'commentary body',
-    artistText: 'commentary artist text',
+    headingArtistText: 'commentary artist text',
     annotation: 'commentary annotation',
   };
 
   const lyricsShape = {
     body: 'lyrics body',
-    artistText: 'lyrics artist text',
+    headingArtistText: 'lyrics artist text',
     annotation: 'lyrics annotation',
   };
 
diff --git a/src/data/composite.js b/src/data/composite.js
index 8ac906c7..3b462ef5 100644
--- a/src/data/composite.js
+++ b/src/data/composite.js
@@ -10,17 +10,27 @@ import {TupleMap} from '#wiki-data';
 
 const globalCompositeCache = {};
 
-const _valueIntoToken = shape =>
-  (value = null) =>
-    (value === null
-      ? Symbol.for(`hsmusic.composite.${shape}`)
-   : typeof value === 'string'
-      ? Symbol.for(`hsmusic.composite.${shape}:${value}`)
-      : {
-          symbol: Symbol.for(`hsmusic.composite.${shape.split('.')[0]}`),
-          shape,
-          value,
-        });
+const _valueIntoToken = shape => (value = null) => {
+  if (value === null) {
+    return Symbol.for(`hsmusic.composite.${shape}`);
+  }
+
+  if (typeof value === 'string') {
+    return Symbol.for(`hsmusic.composite.${shape}:${value}`);
+  }
+
+  if (typeof value === 'object') {
+    if (Object.values(value).some(isInputToken)) {
+      throw new TypeError(`Don't nest input tokens inside ${shape}()`);
+    }
+  }
+
+  return {
+    symbol: Symbol.for(`hsmusic.composite.${shape.split('.')[0]}`),
+    shape,
+    value,
+  };
+};
 
 export const input = _valueIntoToken('input');
 input.symbol = Symbol.for('hsmusic.composite.input');
diff --git a/src/data/composite/things/content/withExpressedOrImplicitArtistReferences.js b/src/data/composite/things/content/withExpressedOrImplicitArtistReferences.js
index 69da8c75..a6200ee8 100644
--- a/src/data/composite/things/content/withExpressedOrImplicitArtistReferences.js
+++ b/src/data/composite/things/content/withExpressedOrImplicitArtistReferences.js
@@ -8,16 +8,19 @@ export default templateCompositeFrom({
   annotation: `withExpressedOrImplicitArtistReferences`,
 
   inputs: {
-    from: input({type: 'array', acceptsNull: true}),
+    fromExpressed: input({type: 'array', acceptsNull: true}),
+    fromContent: input({type: 'string', acceptsNull: true}),
+
+    filterArtistTags: input({type: 'function', defaultValue: () => true}),
   },
 
   outputs: ['#artistReferences'],
 
   steps: () => [
     {
-      dependencies: [input('from')],
+      dependencies: [input('fromExpressed')],
       compute: (continuation, {
-        [input('from')]: expressedArtistReferences,
+        [input('fromExpressed')]: expressedArtistReferences,
       }) =>
         (expressedArtistReferences
           ? continuation.raiseOutput({'#artistReferences': expressedArtistReferences})
@@ -25,12 +28,12 @@ export default templateCompositeFrom({
     },
 
     raiseOutputWithoutDependency({
-      dependency: 'artistText',
-      output: input.value({'#artistReferences': null}),
+      dependency: input('fromContent'),
+      output: input.value({'#artistReferences': []}),
     }),
 
     withContentNodes({
-      from: 'artistText',
+      from: input('fromContent'),
     }),
 
     withMappedList({
@@ -42,13 +45,19 @@ export default templateCompositeFrom({
       '#mappedList': '#artistTagFilter',
     }),
 
-    withFilteredList({
-      list: '#contentNodes',
-      filter: '#artistTagFilter',
+    withFilteredList('#contentNodes', '#artistTagFilter')
+      .outputs({'#filteredList': '#artistTags'}),
+
+    withMappedList({
+      list: '#artistTags',
+      map: input('filterArtistTags'),
     }).outputs({
-      '#filteredList': '#artistTags',
+      '#mappedList': '#customFilter',
     }),
 
+    withFilteredList({list: '#artistTags', filter: '#customFilter'})
+      .outputs({'#filteredList': '#artistTags'}),
+
     withMappedList({
       list: '#artistTags',
       map: input.value(node =>
diff --git a/src/data/things/content/ContentEntry.js b/src/data/things/content/ContentEntry.js
index 04df303f..47f86622 100644
--- a/src/data/things/content/ContentEntry.js
+++ b/src/data/things/content/ContentEntry.js
@@ -1,5 +1,5 @@
 import {input, V} from '#composite';
-import {transposeArrays} from '#sugar';
+import {transposeArrays, unique} from '#sugar';
 import Thing from '#thing';
 import {is, isDate, validateReferenceList} from '#validators';
 import {parseDate} from '#yaml';
@@ -33,14 +33,43 @@ export class ContentEntry extends Thing {
 
     thing: thing(),
 
-    artists: [
+    headingArtists: [
       withExpressedOrImplicitArtistReferences({
-        from: input.updateValue({
+        fromExpressed: input.updateValue({
           validate: validateReferenceList('artist'),
         }),
+
+        fromContent: 'headingArtistText',
+      }),
+
+      withResolvedReferenceList({
+        list: '#artistReferences',
+        find: soupyFind.input('artist'),
       }),
 
-      exitWithoutDependency('#artistReferences', V([])),
+      exposeDependency('#resolvedReferenceList'),
+    ],
+
+    quotedArtists: [
+      exitWithoutDependency('body', V([])),
+
+      {
+        dependencies: ['body'],
+        compute: (continuation, {body}) => continuation({
+          ['#filterArtistTags']: node =>
+            /(\n|^)> <i>$/.test(body.slice(0, node.i)) &&
+            /^:<\/i>/.test(body.slice(node.iEnd)),
+        }),
+      },
+
+      withExpressedOrImplicitArtistReferences({
+        fromExpressed: input.updateValue({
+          validate: validateReferenceList('artist'),
+        }),
+
+        fromContent: 'body',
+        filterArtistTags: '#filterArtistTags',
+      }),
 
       withResolvedReferenceList({
         list: '#artistReferences',
@@ -50,7 +79,7 @@ export class ContentEntry extends Thing {
       exposeDependency('#resolvedReferenceList'),
     ],
 
-    artistText: contentString(),
+    headingArtistText: contentString(),
 
     annotation: contentString(),
 
@@ -119,6 +148,14 @@ export class ContentEntry extends Thing {
 
     isContentEntry: exposeConstant(V(true)),
 
+    artists: [
+      {
+        dependencies: ['headingArtists', 'quotedArtists'],
+        compute: ({headingArtists, quotedArtists}) =>
+          unique([...headingArtists, ...quotedArtists]),
+      },
+    ],
+
     annotationParts: [
       withAnnotationPartNodeLists(),
 
@@ -230,8 +267,10 @@ export class ContentEntry extends Thing {
 
   static [Thing.yamlDocumentSpec] = {
     fields: {
-      'Artists': {property: 'artists'},
-      'Artist Text': {property: 'artistText'},
+      'Artists': {property: 'headingArtists'},
+      'Artist Text': {property: 'headingArtistText'},
+
+      'Quoted Artists': {property: 'quotedArtists'},
 
       'Annotation': {property: 'annotation'},
 
diff --git a/src/search-select.js b/src/search-select.js
index 3133c642..dc1508be 100644
--- a/src/search-select.js
+++ b/src/search-select.js
@@ -158,7 +158,8 @@ function genericSelect(wikiData) {
   return [
     sortByGroupRank(wikiData.albumData.slice()),
 
-    wikiData.artTagData,
+    wikiData.artTagData
+      .filter(artTag => !artTag.isContentWarning),
 
     wikiData.artistData
       .filter(artist => !artist.isAlias),
diff --git a/src/static/css/responsive.css b/src/static/css/responsive.css
index 86cd7eb6..38e4188a 100644
--- a/src/static/css/responsive.css
+++ b/src/static/css/responsive.css
@@ -106,7 +106,7 @@
   }
 
   .wiki-search-sidebar-box {
-    max-height: max(245px, 60vh, calc(100vh - 205px));
+    --keep-viewport-visible: 205px;
   }
 
   /* End duplicated for "sidebars in content column" */
diff --git a/src/static/css/search.css b/src/static/css/search.css
index f421803b..3c56eed6 100644
--- a/src/static/css/search.css
+++ b/src/static/css/search.css
@@ -5,11 +5,17 @@
     padding: 1px 0 0 0;
 
     z-index: 100;
-    max-height: calc(100vh - 20px);
+
+    --keep-viewport-visible: 125px;
+    max-height: max(245px, 60vh, calc(100vh - var(--keep-viewport-visible)));
 
     display: flex;
     flex-direction: column;
   }
+
+  #banner.short ~ * .wiki-search-sidebar-box {
+    --keep-viewport-visible: 180px;
+  }
 }
 
 @layer material {
@@ -32,17 +38,20 @@
 
 /* Interactions with other sidebar boxes */
 
-@layer interaction {
-  /* This is to say, any sidebar that's *not*
-   * the first sidebar after the search box.
+@layer layout {
+  /* This is to say, any sidebar that's *not* the first sidebar
+   * after the search box, effectively squishing the rest of the
+   * boxes a bit tighter together.
    */
   .wiki-search-sidebar-box.showing-results + .sidebar ~ .sidebar {
-    margin-top: 5px;
+    margin-top: 8px;
   }
+}
 
+@layer interaction {
   .wiki-search-sidebar-box.showing-results ~ .sidebar:not(:hover) {
     opacity: 0.8;
-    filter: brightness(0.7);
+    filter: brightness(0.85);
   }
 }
 
@@ -191,6 +200,8 @@
 @layer layout {
   .wiki-search-context-container {
     padding: 2px 12px 4px;
+    padding-left: calc(12px + 1.2ch);
+    text-indent: -1.2ch;
   }
 }
 
@@ -322,11 +333,51 @@
 
 @layer layout {
   .wiki-search-results-container {
+    position: relative;
     margin-bottom: 0;
     padding: 2px;
   }
 }
 
+@layer interaction {
+  .wiki-search-results-container::before,
+  .wiki-search-results-container::after {
+    content: "";
+    display: block;
+    position: sticky;
+    pointer-events: none;
+    z-index: 1;
+  }
+
+  .wiki-search-result:hover {
+    z-index: 2;
+  }
+
+  /* Shadow along top edge */
+
+  .wiki-search-results-container > :first-child {
+    margin-top: -4px;
+  }
+
+  .wiki-search-results-container::before {
+    height: 8px; top: -2px;
+    background: linear-gradient(to bottom, black, black 50%, transparent);
+    opacity: 0.4;
+  }
+
+  /* Shadow along bottom edge */
+
+  .wiki-search-results-container > :last-child {
+    margin-bottom: -10px;
+  }
+
+  .wiki-search-results-container::after {
+    height: 16px; bottom: -2px;
+    background: linear-gradient(to top, black, black 30%, transparent);
+    opacity: 0.4;
+  }
+}
+
 /* Basic result styling, including interactions */
 
 @layer layout {
diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js
index 16ebe89f..cd617bea 100644
--- a/src/static/js/client/index.js
+++ b/src/static/js/client/index.js
@@ -133,7 +133,7 @@ for (const module of modules) {
             break;
 
           case 'boolean':
-            formatRead = Boolean;
+            formatRead = value => value === 'true' ? true : false;
             formatWrite = String;
             break;
 
diff --git a/src/static/js/client/sidebar-search.js b/src/static/js/client/sidebar-search.js
index 8b29cf63..7b01cb00 100644
--- a/src/static/js/client/sidebar-search.js
+++ b/src/static/js/client/sidebar-search.js
@@ -38,6 +38,9 @@ export const info = {
   searchLabel: null,
   searchInput: null,
 
+  contextContainer: null,
+  contextBackLink: null,
+
   progressRule: null,
   progressContainer: null,
   progressLabel: null,
@@ -46,9 +49,6 @@ export const info = {
   failedRule: null,
   failedContainer: null,
 
-  contextContainer: null,
-  contextBackLink: null,
-
   filterContainer: null,
   albumFilterLink: null,
   artistFilterLink: null,
@@ -106,6 +106,8 @@ export const info = {
     recallingRecentSearch: null,
     recallingRecentSearchFromMouse: null,
 
+    justPerformedActiveQuery: false,
+
     currentValue: null,
 
     workerStatus: null,
@@ -127,6 +129,7 @@ export const info = {
     activeQueryContextPageName: {type: 'string'},
     activeQueryContextPagePathname: {type: 'string'},
     activeQueryContextPageColor: {type: 'string'},
+    zapActiveQueryContext: {type: 'boolean'},
 
     activeQueryResults: {
       type: 'json',
@@ -163,6 +166,8 @@ export function* bindSessionStorage() {
     yield 'activeQueryContextPageName';
     yield 'activeQueryContextPagePathname';
     yield 'activeQueryContextPageColor';
+    yield 'zapActiveQueryContext';
+
     yield 'activeQueryResults';
     yield 'activeFilterType';
     yield 'resultsScrollOffset';
@@ -302,6 +307,25 @@ export function addInternalListeners() {
 export function mutatePageContent() {
   if (!info.searchBox) return;
 
+  // Context section
+
+  info.contextContainer =
+    document.createElement('div');
+
+  info.contextContainer.classList.add('wiki-search-context-container');
+
+  info.contextBackLink =
+    document.createElement('a');
+
+  info.contextContainer.appendChild(
+    templateContent(info.backString, {
+      page: info.contextBackLink,
+    }));
+
+  cssProp(info.contextContainer, 'display', 'none');
+
+  info.searchBox.appendChild(info.contextContainer);
+
   // Progress section
 
   info.progressRule =
@@ -355,25 +379,6 @@ export function mutatePageContent() {
   info.searchBox.appendChild(info.failedRule);
   info.searchBox.appendChild(info.failedContainer);
 
-  // Context section
-
-  info.contextContainer =
-    document.createElement('div');
-
-  info.contextContainer.classList.add('wiki-search-context-container');
-
-  info.contextBackLink =
-    document.createElement('a');
-
-  info.contextContainer.appendChild(
-    templateContent(info.backString, {
-      page: info.contextBackLink,
-    }));
-
-  cssProp(info.contextContainer, 'display', 'none');
-
-  info.searchBox.appendChild(info.contextContainer);
-
   // Filter section
 
   info.filterContainer =
@@ -582,7 +587,6 @@ export function addPageListeners() {
     clearSidebarSearch();
     clearSidebarFilter();
     possiblyHideSearchSidebarColumn();
-    restoreSidebarSearchColumn();
   });
 
   forEachFilter((type, filterLink) => {
@@ -728,6 +732,7 @@ async function activateSidebarSearch(query) {
     return;
   }
 
+  state.justPerformedActiveQuery = true;
   state.searchStage = 'complete';
   updateSidebarSearchStatus();
 
@@ -750,6 +755,20 @@ function recordActiveQueryContext() {
   const {session} = info;
 
   if (document.documentElement.dataset.urlKey === 'localized.home') {
+    session.activeQueryContextPageName = null;
+    session.activeQueryContextPagePathname = null;
+    session.activeQueryContextPageColor = null;
+    session.zapActiveQueryContext = true;
+    return;
+  }
+
+  // Zapping means subsequent searches don't record context.
+  if (session.zapActiveQueryContext) {
+    return;
+  }
+
+  // We also don't overwrite existing context.
+  if (session.activeQueryContextPagePathname) {
     return;
   }
 
@@ -779,12 +798,24 @@ function clearSidebarSearch() {
   info.searchInput.value = '';
 
   state.searchStage = null;
+  state.justPerformedActiveQuery = false;
+
+  clearActiveQuery();
+
+  hideSidebarSearchResults();
+}
+
+function clearActiveQuery() {
+  const {session} = info;
 
   session.activeQuery = null;
   session.activeQueryResults = null;
   session.resultsScrollOffset = null;
 
-  hideSidebarSearchResults();
+  session.activeQueryContextPageName = null;
+  session.activeQueryContextPagePathname = null;
+  session.activeQueryContextPageColor = null;
+  session.zapActiveQueryContext = false;
 }
 
 function clearSidebarFilter() {
@@ -1355,6 +1386,8 @@ function hideSidebarSearchResults() {
 
   cssProp(info.endSearchRule, 'display', 'none');
   cssProp(info.endSearchLine, 'display', 'none');
+
+  restoreSidebarSearchColumn();
 }
 
 function focusFirstSidebarSearchResult() {
@@ -1438,7 +1471,7 @@ function possiblyHideSearchSidebarColumn() {
 // This should be called after results are shown, since it checks the
 // elements added to understand the current search state.
 function tidySidebarSearchColumn() {
-  const {state} = info;
+  const {session, 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
@@ -1447,17 +1480,24 @@ function tidySidebarSearchColumn() {
     return;
   }
 
-  const here = location.href.replace(/\/$/, '');
+  const hrefHere = location.href.replace(/\/$/, '');
   const currentPageIsResult =
     Array.from(info.results.querySelectorAll('a'))
       .some(link => {
-        const there = link.href.replace(/\/$/, '');
-        return here === there;
+        const hrefThere = link.href.replace(/\/$/, '');
+        return hrefHere === hrefThere;
       });
 
+  const currentPageIsContext =
+    location.pathname === session.activeQueryContextPagePathname;
+
   // Don't tidy the sidebar if you've navigated to some other page than
   // what's in the current result list.
-  if (!currentPageIsResult) {
+  if (
+    !state.justPerformedActiveQuery &&
+    !currentPageIsResult &&
+    !currentPageIsContext
+  ) {
     return;
   }
 
@@ -1537,16 +1577,7 @@ function considerRecallingRecentSidebarSearch() {
 }
 
 function forgetRecentSidebarSearch() {
-  const {session} = info;
-
-  session.activeQuery = null;
-
-  session.activeQueryContextPageName = null;
-  session.activeQueryContextPagePathname = null;
-  session.activeQueryContextPageColor = null;
-
-  session.activeQueryResults = null;
-
+  clearActiveQuery();
   clearSidebarFilter();
 }
 
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index 54741239..ed8f236f 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -310,6 +310,7 @@ releaseInfo:
 
     tooltip:
       differentName: "as {NAME}"
+      differentName.onSingle: "{NAME}"
 
   tracksReferenced:
     _: "Tracks that {TRACK} references:"
@@ -1395,6 +1396,10 @@ artistPage:
 
       withCitation: "{ENTRY} ({CITATION})"
 
+      citation: "{CITATION}"
+      citation.quoted: "quoted: {CITATION}"
+      quoted: "quoted"
+
       # rerelease:
       #   Tracks which aren't the original release don't display co-
       #   artists or contributors, and get dimmed a little compared