diff --git a/zenno/src/lib/stringsearch.ts b/zenno/src/lib/stringsearch.ts
new file mode 100644
index 0000000..f390ff3
--- /dev/null
+++ b/zenno/src/lib/stringsearch.ts
@@ -0,0 +1,56 @@
+const WORD_BOUNDARY = " ,!?/-+();#○☆♡'=♪∀゚∴";
+
+function score(s: string, tt: string): number {
+ let k: number | undefined;
+ let r = 0;
+ let run = 0;
+ for (const c of s) {
+ const j = tt.indexOf(c, k);
+ // If the character isn't in the string, there's a major penalty.
+ if (j < 0) {
+ // The penalty scales with run length, on the assumption that we're
+ // typing something else.
+ // Really this should scale with the longest current run among all
+ // search terms, but that's infeasible to implement.
+ r -= 6 + run*run;
+ run = 0;
+ continue;
+ }
+ run++;
+ // Characters at word boundaries get extra score.
+ if (j == 0 || WORD_BOUNDARY.includes(tt[j-1])) {
+ r += 2;
+ }
+ // As do characters that *are* word boundaries.
+ if (WORD_BOUNDARY.includes(c)) {
+ r += 2;
+ }
+ // And runs of matches scale with run length.
+ if (j === k) {
+ r += (run+1) * (run+1);
+ } else {
+ run = 0;
+ }
+ k = j + 1;
+ }
+ return r;
+}
+
+/**
+ * Fuzzy string search.
+ * @param sub Substring to search for
+ * @param terms Iterable of values to search among
+ * @param map Mapping from term to string to search
+ * @returns Matching terms in decreasing match quality order
+ */
+export function stringsearch(sub: string, terms: Iterable, map: (t: T) => string): T[] {
+ const s = sub.toLocaleLowerCase();
+ const scored: [T, number][] = [];
+ for (const t of terms) {
+ const sc = score(s, map(t).toLocaleLowerCase());
+ if (sc >= 0) {
+ scored.push([t, sc]);
+ }
+ }
+ return scored.sort(([, a], [, b]) => b - a).map(([t,]) => t);
+}
diff --git a/zenno/src/routes/chara/affinity/+page.svelte b/zenno/src/routes/chara/affinity/+page.svelte
index c6f7618..07c1208 100644
--- a/zenno/src/routes/chara/affinity/+page.svelte
+++ b/zenno/src/routes/chara/affinity/+page.svelte
@@ -1,7 +1,7 @@