@mvarble/mesearch-ui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +84 -2
  3. package/dist/ambient.d.ts +2 -0
  4. package/dist/components/Article.svelte +226 -0
  5. package/dist/components/Article.svelte.d.ts +16 -0
  6. package/dist/components/ArticleHeader.svelte +134 -0
  7. package/dist/components/ArticleHeader.svelte.d.ts +19 -0
  8. package/dist/components/Chips.svelte +22 -0
  9. package/dist/components/Chips.svelte.d.ts +8 -0
  10. package/dist/components/Graph.svelte +727 -0
  11. package/dist/components/Graph.svelte.d.ts +16 -0
  12. package/dist/components/GraphPanel.svelte +328 -0
  13. package/dist/components/GraphPanel.svelte.d.ts +17 -0
  14. package/dist/components/Icon.svelte +67 -0
  15. package/dist/components/Icon.svelte.d.ts +8 -0
  16. package/dist/components/Index.svelte +283 -0
  17. package/dist/components/Index.svelte.d.ts +9 -0
  18. package/dist/components/Pager.svelte +71 -0
  19. package/dist/components/Pager.svelte.d.ts +12 -0
  20. package/dist/components/Palette.svelte +333 -0
  21. package/dist/components/Palette.svelte.d.ts +9 -0
  22. package/dist/components/Parts.svelte +62 -0
  23. package/dist/components/Parts.svelte.d.ts +11 -0
  24. package/dist/components/Proof.svelte +39 -0
  25. package/dist/components/Proof.svelte.d.ts +8 -0
  26. package/dist/components/Rail.svelte +186 -0
  27. package/dist/components/Rail.svelte.d.ts +13 -0
  28. package/dist/components/References.svelte +94 -0
  29. package/dist/components/References.svelte.d.ts +8 -0
  30. package/dist/components/SequenceTrack.svelte +186 -0
  31. package/dist/components/SequenceTrack.svelte.d.ts +11 -0
  32. package/dist/components/Shell.svelte +82 -0
  33. package/dist/components/Shell.svelte.d.ts +14 -0
  34. package/dist/components/Statement.svelte +105 -0
  35. package/dist/components/Statement.svelte.d.ts +11 -0
  36. package/dist/components/ThemeToggle.svelte +19 -0
  37. package/dist/components/ThemeToggle.svelte.d.ts +3 -0
  38. package/dist/components/Toc.svelte +145 -0
  39. package/dist/components/Toc.svelte.d.ts +8 -0
  40. package/dist/fonts.d.ts +4 -0
  41. package/dist/fonts.js +6 -0
  42. package/dist/fuzzy.d.ts +12 -0
  43. package/dist/fuzzy.js +57 -0
  44. package/dist/graph-layout.d.ts +35 -0
  45. package/dist/graph-layout.js +98 -0
  46. package/dist/index.d.ts +24 -0
  47. package/dist/index.js +30 -0
  48. package/dist/reading.svelte.d.ts +3 -0
  49. package/dist/reading.svelte.js +4 -0
  50. package/dist/server.d.ts +26 -0
  51. package/dist/server.js +115 -0
  52. package/dist/settings.d.ts +10 -0
  53. package/dist/settings.js +28 -0
  54. package/dist/styles/base.css +136 -0
  55. package/dist/styles/index.css +6 -0
  56. package/dist/styles/prose.css +203 -0
  57. package/dist/styles/tokens.css +111 -0
  58. package/dist/theme.svelte.d.ts +12 -0
  59. package/dist/theme.svelte.js +66 -0
  60. package/dist/types.d.ts +84 -0
  61. package/dist/types.js +3 -0
  62. package/package.json +66 -3
@@ -0,0 +1,283 @@
1
+ <script lang="ts">
2
+ import { bestMatch, highlight } from '../fuzzy.js';
3
+ import { formatDate, isoDate, kindLabel } from '../settings.js';
4
+ import type { IndexEntry, Kind } from '../types.js';
5
+
6
+ // Every concept and writeup, as a ledger: newest change first, with a
7
+ // fuzzy filter over titles and summaries. Without JavaScript it is the
8
+ // same list in the same order, minus the controls.
9
+ //
10
+ // `kinds` are the kinds a reader can narrow the list to; with fewer than
11
+ // two there is nothing to choose, and the control is left out.
12
+ let {
13
+ entries,
14
+ kinds = [
15
+ ['concept', 'Concepts'],
16
+ ['writeup', 'Writeups'],
17
+ ],
18
+ empty = 'Nothing has been written yet.',
19
+ }: { entries: IndexEntry[]; kinds?: Array<[Kind, string]>; empty?: string } = $props();
20
+
21
+ type Sort = 'updated' | 'created' | 'title';
22
+ type Filter = 'all' | Kind;
23
+ let query = $state('');
24
+ let sort = $state<Sort>('updated');
25
+ let filter = $state<Filter>('all');
26
+
27
+ const sorts: Array<[Sort, string]> = [
28
+ ['updated', 'Updated'],
29
+ ['created', 'Created'],
30
+ ['title', 'A–Z'],
31
+ ];
32
+ let filters = $derived<Array<[Filter, string]>>([['all', 'All'], ...kinds]);
33
+
34
+ let shown = $derived.by(() => {
35
+ const kept = entries.filter((entry) => filter == 'all' || entry.kind == filter);
36
+ if (query.trim()) {
37
+ const matched = kept
38
+ .map((entry) => ({
39
+ entry,
40
+ match: bestMatch(query, [
41
+ [entry.title, 1.6],
42
+ [entry.summary, 0.6],
43
+ ]),
44
+ }))
45
+ .filter(({ match }) => match);
46
+ // Once something matches well, scattered letters elsewhere are noise.
47
+ const best = Math.max(0, ...matched.map(({ match }) => match!.score));
48
+ return matched
49
+ .filter(({ match }) => match!.score >= best * 0.35)
50
+ .sort((a, b) => b.match!.score - a.match!.score)
51
+ .map(({ entry, match }) => ({
52
+ entry,
53
+ indices: match!.field == 0 ? match!.indices : [],
54
+ }));
55
+ }
56
+ const order = {
57
+ updated: (a: IndexEntry, b: IndexEntry) => b.updated.valueOf() - a.updated.valueOf(),
58
+ created: (a: IndexEntry, b: IndexEntry) => b.created.valueOf() - a.created.valueOf(),
59
+ title: (a: IndexEntry, b: IndexEntry) => a.title.localeCompare(b.title),
60
+ }[sort];
61
+ return kept.toSorted(order).map((entry) => ({ entry, indices: [] as number[] }));
62
+ });
63
+
64
+ const dateOf = (entry: IndexEntry) => (sort == 'created' ? entry.created : entry.updated);
65
+ </script>
66
+
67
+ <div class="index">
68
+ <div class="controls needs-js">
69
+ <label class="search">
70
+ <span class="visually-hidden">Filter the index</span>
71
+ <input
72
+ bind:value={query}
73
+ type="search"
74
+ placeholder="Filter by title or summary…"
75
+ autocomplete="off"
76
+ />
77
+ </label>
78
+ <div class="toggles">
79
+ {#if kinds.length > 1}
80
+ <div class="segmented" role="radiogroup" aria-label="Show">
81
+ {#each filters as [value, label] (value)}
82
+ <button
83
+ type="button"
84
+ role="radio"
85
+ aria-checked={filter == value}
86
+ onclick={() => (filter = value)}
87
+ >
88
+ {label}
89
+ </button>
90
+ {/each}
91
+ </div>
92
+ {/if}
93
+ <div class="segmented" role="radiogroup" aria-label="Sort by">
94
+ {#each sorts as [value, label] (value)}
95
+ <button
96
+ type="button"
97
+ role="radio"
98
+ aria-checked={sort == value}
99
+ disabled={!!query.trim()}
100
+ onclick={() => (sort = value)}
101
+ >
102
+ {label}
103
+ </button>
104
+ {/each}
105
+ </div>
106
+ </div>
107
+ </div>
108
+
109
+ <ol class="entries">
110
+ {#each shown as { entry, indices } (entry.key)}
111
+ <li class="kind-{entry.kind}">
112
+ <time class="date" datetime={isoDate(dateOf(entry))}
113
+ >{formatDate(dateOf(entry))}</time
114
+ >
115
+ <div class="text">
116
+ <p class="eyebrow kind">
117
+ {kindLabel(entry.kind)}{#if entry.readingMinutes}<span class="minutes">
118
+ · {entry.readingMinutes} min</span
119
+ >{/if}
120
+ </p>
121
+ <a class="title" href={entry.url}>
122
+ {#if indices.length}
123
+ {#each highlight(entry.title, indices) as part, i (i)}
124
+ {#if part.hit}<mark>{part.text}</mark>{:else}{part.text}{/if}
125
+ {/each}
126
+ {:else}
127
+ {@html entry.titleHtml}
128
+ {/if}
129
+ </a>
130
+ {#if entry.summaryHtml}<p class="summary">{@html entry.summaryHtml}</p>{/if}
131
+ </div>
132
+ </li>
133
+ {:else}
134
+ <li class="none">
135
+ {entries.length ? `Nothing matches “${query}”.` : empty}
136
+ </li>
137
+ {/each}
138
+ </ol>
139
+ </div>
140
+
141
+ <style>
142
+ .controls {
143
+ display: flex;
144
+ flex-wrap: wrap;
145
+ align-items: center;
146
+ gap: 0.75rem 1rem;
147
+ margin-bottom: 1.25rem;
148
+ font-family: var(--font-ui);
149
+ }
150
+
151
+ .search {
152
+ flex: 1 1 16rem;
153
+ }
154
+
155
+ input {
156
+ width: 100%;
157
+ padding: 0.6rem 0.9rem;
158
+ border: 1px solid var(--rule-strong);
159
+ border-radius: 10px;
160
+ background: var(--paper-raised);
161
+ color: var(--ink);
162
+ font: 400 0.95rem / 1.3 var(--font-ui);
163
+ }
164
+
165
+ input:focus {
166
+ outline: none;
167
+ border-color: var(--accent);
168
+ box-shadow: 0 0 0 3px var(--accent-soft);
169
+ }
170
+
171
+ .toggles {
172
+ display: flex;
173
+ flex-wrap: wrap;
174
+ gap: 0.6rem;
175
+ }
176
+
177
+ .segmented {
178
+ display: inline-flex;
179
+ padding: 3px;
180
+ border: 1px solid var(--rule);
181
+ border-radius: 10px;
182
+ background: var(--paper-sunken);
183
+ }
184
+
185
+ .segmented button {
186
+ border: none;
187
+ border-radius: 7px;
188
+ padding: 0.32rem 0.7rem;
189
+ background: transparent;
190
+ color: var(--muted);
191
+ font: 500 0.8rem / 1.2 var(--font-ui);
192
+ cursor: pointer;
193
+ }
194
+
195
+ .segmented button[aria-checked='true'] {
196
+ background: var(--paper-raised);
197
+ color: var(--ink);
198
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
199
+ }
200
+
201
+ .segmented button:disabled {
202
+ opacity: 0.5;
203
+ cursor: default;
204
+ }
205
+
206
+ .entries {
207
+ list-style: none;
208
+ margin: 0;
209
+ padding: 0;
210
+ border-top: 1px solid var(--rule);
211
+ }
212
+
213
+ .entries li {
214
+ display: grid;
215
+ grid-template-columns: 7.5rem 1fr;
216
+ gap: 1.25rem;
217
+ padding: 1.1rem 0;
218
+ border-bottom: 1px solid var(--rule);
219
+ }
220
+
221
+ .date {
222
+ font: 500 0.8rem / 2.1 var(--font-ui);
223
+ color: var(--muted);
224
+ font-variant-numeric: tabular-nums;
225
+ }
226
+
227
+ .kind {
228
+ margin: 0 0 0.2rem;
229
+ color: var(--kind);
230
+ }
231
+
232
+ .minutes {
233
+ color: var(--faint);
234
+ letter-spacing: 0.04em;
235
+ }
236
+
237
+ .title {
238
+ font: 600 1.18rem / 1.3 var(--font-serif);
239
+ color: var(--ink);
240
+ text-decoration: none;
241
+ }
242
+
243
+ .title:hover {
244
+ color: var(--kind);
245
+ }
246
+
247
+ mark {
248
+ background: var(--kind-soft);
249
+ color: inherit;
250
+ border-radius: 2px;
251
+ }
252
+
253
+ .summary {
254
+ margin: 0.35rem 0 0;
255
+ color: var(--ink-soft);
256
+ font-size: 0.98rem;
257
+ line-height: 1.6;
258
+ display: -webkit-box;
259
+ -webkit-line-clamp: 3;
260
+ line-clamp: 3;
261
+ -webkit-box-orient: vertical;
262
+ overflow: hidden;
263
+ }
264
+
265
+ .none {
266
+ display: block !important;
267
+ color: var(--muted);
268
+ font-family: var(--font-ui);
269
+ text-align: center;
270
+ }
271
+
272
+ @media (max-width: 560px) {
273
+ .entries li {
274
+ grid-template-columns: 1fr;
275
+ gap: 0.2rem;
276
+ }
277
+
278
+ .date {
279
+ line-height: 1.4;
280
+ order: 2;
281
+ }
282
+ }
283
+ </style>
@@ -0,0 +1,9 @@
1
+ import type { IndexEntry, Kind } from '../types.js';
2
+ type $$ComponentProps = {
3
+ entries: IndexEntry[];
4
+ kinds?: Array<[Kind, string]>;
5
+ empty?: string;
6
+ };
7
+ declare const Index: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type Index = ReturnType<typeof Index>;
9
+ export default Index;
@@ -0,0 +1,71 @@
1
+ <script lang="ts">
2
+ import Icon from './Icon.svelte';
3
+
4
+ // The previous and next stops of a sequence, as two cards.
5
+ type Stop = { url: string; titleHtml: string };
6
+ let {
7
+ previous,
8
+ next,
9
+ label = 'Sequence',
10
+ }: { previous?: Stop; next?: Stop; label?: string } = $props();
11
+ </script>
12
+
13
+ {#if previous || next}
14
+ <nav class="pager kind-sequence" aria-label={label}>
15
+ {#if previous}
16
+ <a class="previous" href={previous.url}>
17
+ <span class="eyebrow"><Icon name="arrow-left" size={14} /> Previous</span>
18
+ <span class="title">{@html previous.titleHtml}</span>
19
+ </a>
20
+ {/if}
21
+ {#if next}
22
+ <a class="next" href={next.url}>
23
+ <span class="eyebrow">Next <Icon name="arrow-right" size={14} /></span>
24
+ <span class="title">{@html next.titleHtml}</span>
25
+ </a>
26
+ {/if}
27
+ </nav>
28
+ {/if}
29
+
30
+ <style>
31
+ .pager {
32
+ display: grid;
33
+ grid-template-columns: 1fr 1fr;
34
+ gap: 1rem;
35
+ font-family: var(--font-ui);
36
+ }
37
+
38
+ a {
39
+ display: flex;
40
+ flex-direction: column;
41
+ gap: 0.35rem;
42
+ padding: 1rem 1.1rem;
43
+ border: 1px solid var(--rule);
44
+ border-radius: var(--radius);
45
+ background: var(--paper-raised);
46
+ color: inherit;
47
+ text-decoration: none;
48
+ transition: border-color 120ms;
49
+ }
50
+
51
+ a:hover {
52
+ border-color: var(--kind);
53
+ }
54
+
55
+ .eyebrow {
56
+ display: inline-flex;
57
+ align-items: center;
58
+ gap: 0.3rem;
59
+ color: var(--kind);
60
+ }
61
+
62
+ .next {
63
+ grid-column: 2;
64
+ text-align: right;
65
+ align-items: flex-end;
66
+ }
67
+
68
+ .title {
69
+ font: 600 1rem / 1.3 var(--font-serif);
70
+ }
71
+ </style>
@@ -0,0 +1,12 @@
1
+ type Stop = {
2
+ url: string;
3
+ titleHtml: string;
4
+ };
5
+ type $$ComponentProps = {
6
+ previous?: Stop;
7
+ next?: Stop;
8
+ label?: string;
9
+ };
10
+ declare const Pager: import("svelte").Component<$$ComponentProps, {}, "">;
11
+ type Pager = ReturnType<typeof Pager>;
12
+ export default Pager;
@@ -0,0 +1,333 @@
1
+ <script lang="ts">
2
+ import { bestMatch, highlight } from '../fuzzy.js';
3
+ import { kindLabel } from '../settings.js';
4
+ import type { SearchEntry } from '../types.js';
5
+
6
+ // Search across the whole library from anywhere: `/` or Ctrl/⌘-K opens
7
+ // it, arrows move, Enter goes, Escape closes. Documents match on their
8
+ // titles and summaries; a heading inside one is its own result, so a
9
+ // search can land on a section.
10
+ //
11
+ // `index` is where the entries come from: the URL of a JSON array of
12
+ // them, fetched the first time the palette opens, or a function that
13
+ // returns them. `navigate` follows a result --- SvelteKit's `goto`, say;
14
+ // without it the browser navigates the ordinary way.
15
+ let {
16
+ index,
17
+ navigate = (url: string) => location.assign(url),
18
+ open = $bindable(false),
19
+ }: {
20
+ index: string | (() => Promise<SearchEntry[]>);
21
+ navigate?: (url: string) => unknown;
22
+ open?: boolean;
23
+ } = $props();
24
+
25
+ let entries = $state<SearchEntry[]>([]);
26
+ let loading: Promise<void> | undefined;
27
+ $effect(() => {
28
+ if (open && !loading) {
29
+ const load =
30
+ typeof index == 'string'
31
+ ? () => fetch(index).then((response) => response.json())
32
+ : index;
33
+ loading = load()
34
+ .then((loaded: SearchEntry[]) => void (entries = loaded))
35
+ .catch(() => (loading = undefined));
36
+ }
37
+ });
38
+
39
+ let dialog: HTMLDialogElement | undefined = $state();
40
+ let input: HTMLInputElement | undefined = $state();
41
+ let query = $state('');
42
+ let active = $state(0);
43
+
44
+ interface Result {
45
+ key: string;
46
+ kind: SearchEntry['kind'];
47
+ title: string;
48
+ context?: string;
49
+ contextHtml?: string;
50
+ url: string;
51
+ score: number;
52
+ indices: number[];
53
+ }
54
+
55
+ let results = $derived.by((): Result[] => {
56
+ if (!query.trim()) {
57
+ return entries.slice(0, 8).map((entry) => ({
58
+ key: entry.key,
59
+ kind: entry.kind,
60
+ title: entry.title,
61
+ contextHtml: entry.summaryHtml,
62
+ url: entry.url,
63
+ score: 0,
64
+ indices: [],
65
+ }));
66
+ }
67
+ const out: Result[] = [];
68
+ for (const entry of entries) {
69
+ const match = bestMatch(query, [
70
+ [entry.title, 1.6],
71
+ [entry.summary, 0.5],
72
+ ]);
73
+ if (match) {
74
+ out.push({
75
+ key: entry.key,
76
+ kind: entry.kind,
77
+ title: entry.title,
78
+ contextHtml: entry.summaryHtml,
79
+ url: entry.url,
80
+ score: match.score,
81
+ indices: match.field == 0 ? match.indices : [],
82
+ });
83
+ }
84
+ for (const heading of entry.headings) {
85
+ const hit = bestMatch(query, [[heading.title, 1.1]]);
86
+ if (hit) {
87
+ out.push({
88
+ key: `${entry.key}#${heading.slug}`,
89
+ kind: entry.kind,
90
+ title: heading.title,
91
+ context: entry.title,
92
+ url: `${entry.url}#${heading.slug}`,
93
+ score: hit.score,
94
+ indices: hit.indices,
95
+ });
96
+ }
97
+ }
98
+ }
99
+ // Once something matches well, scattered letters elsewhere are noise.
100
+ const best = Math.max(0, ...out.map((result) => result.score));
101
+ return out
102
+ .filter((result) => result.score >= best * 0.35)
103
+ .sort((a, b) => b.score - a.score)
104
+ .slice(0, 12);
105
+ });
106
+
107
+ $effect(() => {
108
+ void results;
109
+ active = 0;
110
+ });
111
+
112
+ $effect(() => {
113
+ if (!dialog) return;
114
+ if (open && !dialog.open) {
115
+ dialog.showModal();
116
+ input?.select();
117
+ } else if (!open && dialog.open) {
118
+ dialog.close();
119
+ }
120
+ });
121
+
122
+ function go(result: Result | undefined) {
123
+ if (!result) return;
124
+ open = false;
125
+ query = '';
126
+ void navigate(result.url);
127
+ }
128
+
129
+ function onkeydown(event: KeyboardEvent) {
130
+ if (event.key == 'ArrowDown') {
131
+ active = Math.min(active + 1, results.length - 1);
132
+ event.preventDefault();
133
+ } else if (event.key == 'ArrowUp') {
134
+ active = Math.max(active - 1, 0);
135
+ event.preventDefault();
136
+ } else if (event.key == 'Enter') {
137
+ go(results[active]);
138
+ event.preventDefault();
139
+ }
140
+ }
141
+
142
+ // Opening shortcuts, from anywhere but a text field.
143
+ function onwindowkeydown(event: KeyboardEvent) {
144
+ const target = event.target as HTMLElement | null;
145
+ const typing = target?.closest('input, textarea, [contenteditable="true"]');
146
+ if ((event.key == 'k' || event.key == 'K') && (event.metaKey || event.ctrlKey)) {
147
+ open = !open;
148
+ event.preventDefault();
149
+ } else if (event.key == '/' && !typing && !open) {
150
+ open = true;
151
+ event.preventDefault();
152
+ }
153
+ }
154
+ </script>
155
+
156
+ <svelte:window onkeydown={onwindowkeydown} />
157
+
158
+ <dialog
159
+ bind:this={dialog}
160
+ class="palette"
161
+ aria-label="Search the library"
162
+ onclose={() => (open = false)}
163
+ onclick={(event) => {
164
+ if (event.target == dialog) open = false;
165
+ }}
166
+ >
167
+ <div class="box">
168
+ <label class="field">
169
+ <span class="visually-hidden">Search</span>
170
+ <input
171
+ bind:this={input}
172
+ bind:value={query}
173
+ {onkeydown}
174
+ type="search"
175
+ placeholder="Search titles, summaries and sections…"
176
+ autocomplete="off"
177
+ spellcheck="false"
178
+ role="combobox"
179
+ aria-expanded="true"
180
+ aria-controls="palette-results"
181
+ aria-activedescendant={results[active] ? `palette-${active}` : undefined}
182
+ />
183
+ <kbd>esc</kbd>
184
+ </label>
185
+ <ul id="palette-results" class="results" role="listbox">
186
+ {#each results as result, i (result.key)}
187
+ <li
188
+ id={`palette-${i}`}
189
+ role="option"
190
+ aria-selected={i == active}
191
+ class:active={i == active}
192
+ class="kind-{result.kind}"
193
+ >
194
+ <a
195
+ href={result.url}
196
+ onclick={(event) => {
197
+ event.preventDefault();
198
+ go(result);
199
+ }}
200
+ onmousemove={() => (active = i)}
201
+ >
202
+ <span class="kind">{kindLabel(result.kind)}</span>
203
+ <span class="title">
204
+ {#each highlight(result.title, result.indices) as part, j (j)}
205
+ {#if part.hit}<mark>{part.text}</mark>{:else}{part.text}{/if}
206
+ {/each}
207
+ </span>
208
+ {#if result.contextHtml}
209
+ <span class="context">{@html result.contextHtml}</span>
210
+ {:else if result.context}
211
+ <span class="context">{result.context}</span>
212
+ {/if}
213
+ </a>
214
+ </li>
215
+ {:else}
216
+ <li class="empty">{entries.length ? `Nothing matches “${query}”.` : 'Loading…'}</li>
217
+ {/each}
218
+ </ul>
219
+ </div>
220
+ </dialog>
221
+
222
+ <style>
223
+ .palette {
224
+ width: min(40rem, calc(100vw - 2rem));
225
+ max-height: min(34rem, calc(100vh - 6rem));
226
+ margin: 12vh auto auto;
227
+ padding: 0;
228
+ border: 1px solid var(--rule-strong);
229
+ border-radius: var(--radius-large);
230
+ background: var(--paper-raised);
231
+ color: var(--ink);
232
+ box-shadow: var(--shadow-strong);
233
+ font-family: var(--font-ui);
234
+ overflow: hidden;
235
+ }
236
+
237
+ .palette::backdrop {
238
+ background: rgba(20, 16, 10, 0.32);
239
+ backdrop-filter: blur(2px);
240
+ }
241
+
242
+ .box {
243
+ display: flex;
244
+ flex-direction: column;
245
+ max-height: inherit;
246
+ }
247
+
248
+ .field {
249
+ display: flex;
250
+ align-items: center;
251
+ gap: 0.75rem;
252
+ padding: 0.9rem 1.1rem;
253
+ border-bottom: 1px solid var(--rule);
254
+ }
255
+
256
+ input {
257
+ flex: 1;
258
+ border: none;
259
+ outline: none;
260
+ background: transparent;
261
+ color: var(--ink);
262
+ font: 400 1.05rem / 1.4 var(--font-ui);
263
+ }
264
+
265
+ input::-webkit-search-cancel-button {
266
+ display: none;
267
+ }
268
+
269
+ kbd {
270
+ font: 500 0.7rem / 1 var(--font-ui);
271
+ color: var(--muted);
272
+ border: 1px solid var(--rule-strong);
273
+ border-radius: 4px;
274
+ padding: 0.2rem 0.35rem;
275
+ }
276
+
277
+ .results {
278
+ list-style: none;
279
+ margin: 0;
280
+ padding: 0.4rem;
281
+ overflow-y: auto;
282
+ }
283
+
284
+ .results a {
285
+ display: grid;
286
+ grid-template-columns: 5.5rem 1fr;
287
+ grid-template-rows: auto auto;
288
+ column-gap: 0.75rem;
289
+ padding: 0.55rem 0.7rem;
290
+ border-radius: 8px;
291
+ color: inherit;
292
+ text-decoration: none;
293
+ }
294
+
295
+ .active a {
296
+ background: var(--kind-soft);
297
+ }
298
+
299
+ .kind {
300
+ grid-row: span 2;
301
+ font: 600 0.66rem / 1.9 var(--font-ui);
302
+ letter-spacing: 0.1em;
303
+ text-transform: uppercase;
304
+ color: var(--kind);
305
+ }
306
+
307
+ .title {
308
+ font-weight: 550;
309
+ font-size: 0.95rem;
310
+ }
311
+
312
+ mark {
313
+ background: none;
314
+ color: var(--kind);
315
+ text-decoration: underline;
316
+ text-underline-offset: 0.15em;
317
+ }
318
+
319
+ .context {
320
+ font-size: 0.8rem;
321
+ color: var(--muted);
322
+ white-space: nowrap;
323
+ overflow: hidden;
324
+ text-overflow: ellipsis;
325
+ }
326
+
327
+ .empty {
328
+ padding: 1.5rem;
329
+ text-align: center;
330
+ color: var(--muted);
331
+ font-size: 0.9rem;
332
+ }
333
+ </style>