@dmthepm/commune 0.1.1 → 0.2.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dmthepm/commune",
3
3
  "type": "module",
4
- "version": "0.1.1",
4
+ "version": "0.2.0",
5
5
  "description": "An Astro wiki engine with WikiLinks, sliding panes, backlinks and static search, plus a commune CLI that queries the content graph and checks links",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -85,15 +85,12 @@
85
85
  "unist-util-visit": "^5.1.0"
86
86
  },
87
87
  "devDependencies": {
88
- "@astrojs/check": "^0.9.10",
89
88
  "@astrojs/markdown-remark": "^7.3.0",
90
89
  "@astrojs/sitemap": "^3.7.4",
91
- "@tailwindcss/typography": "^0.5.20",
92
90
  "@types/hast": "^3.0.5",
93
91
  "@types/mdast": "^4.0.4",
94
92
  "@types/node": "^22.20.1",
95
93
  "astro": "^7.2.10",
96
- "autoprefixer": "^10.5.4",
97
94
  "tailwindcss": "^3.4.0",
98
95
  "typescript": "^5.6.0"
99
96
  }
@@ -2,7 +2,11 @@
2
2
  export interface Props { slug: string; title?: string; hideCount?: boolean; }
3
3
  const { slug, hideCount = false } = Astro.props;
4
4
  ---
5
- <aside class="backlinks" data-backlinks-for={slug} data-hide-count={hideCount ? 'true' : undefined}>
5
+ <!-- Starts hidden: the list is filled from `/backlinks.json` at runtime, so
6
+ without JavaScript, or on a note nothing links to, the heading would
7
+ otherwise stand over an empty list. `BacklinksScript` reveals it once it
8
+ has links to show. -->
9
+ <aside class="backlinks hidden" data-backlinks-for={slug} data-hide-count={hideCount ? 'true' : undefined}>
6
10
  <h4>Links to this note</h4>
7
11
  <ul class="backlinks-list"></ul>
8
12
  </aside>
@@ -5,6 +5,16 @@
5
5
  (function() {
6
6
  let backlinksData = null;
7
7
 
8
+ // Note titles are the vault author's prose and go into markup below; a stray
9
+ // <, & or " in one should read as that character rather than break the list
10
+ // open.
11
+ const escapeHtml = (value) => String(value ?? '')
12
+ .replace(/&/g, '&amp;')
13
+ .replace(/</g, '&lt;')
14
+ .replace(/>/g, '&gt;')
15
+ .replace(/"/g, '&quot;')
16
+ .replace(/'/g, '&#39;');
17
+
8
18
  async function initializeBacklinks(scope = document) {
9
19
  try {
10
20
  // Fetch backlinks data if not already loaded
@@ -57,7 +67,7 @@
57
67
  };
58
68
 
59
69
  // Render links and then add stars as separate clickable elements
60
- list.innerHTML = items.map(s => `<li><a href="${s}">${titleFor(s)}</a></li>`).join('');
70
+ list.innerHTML = items.map(s => `<li><a href="${escapeHtml(s)}">${escapeHtml(titleFor(s))}</a></li>`).join('');
61
71
 
62
72
  // Add stars to starred notes
63
73
  list.querySelectorAll('li').forEach((li, index) => {
@@ -1,5 +1,11 @@
1
1
  ---
2
2
  ---
3
+ <!-- First thing in the tab order on every page: reading pages put the whole
4
+ note between the header and the footer, and a keyboard reader should not
5
+ have to walk the header on each one. #main is on the page's <main>, or on
6
+ the first pane's scroll container where <main> is the pane container. -->
7
+ <a class="skip-link" href="#main">Skip to content</a>
8
+
3
9
  <header class="commune-header">
4
10
  <div class="wrap">
5
11
  <a href="/" class="brand-section">
@@ -21,6 +27,30 @@
21
27
  </header>
22
28
 
23
29
  <style>
30
+ .skip-link{
31
+ /* Fixed, not absolute: the reader may be scrolled anywhere in a long note
32
+ when they reach for Tab, and the link has to be on screen to be useful. */
33
+ position:fixed;
34
+ left:0.5rem;
35
+ top:0.5rem;
36
+ z-index:calc(var(--z-modal) + 1);
37
+ padding:0.6rem 0.9rem;
38
+ border:1.5px solid var(--c-accent);
39
+ border-radius:var(--c-radius-md);
40
+ background:var(--c-bg);
41
+ color:var(--c-accent);
42
+ font-size:0.9rem;
43
+ font-weight:600;
44
+ box-shadow:var(--c-shadow-md);
45
+ transform:translateY(calc(-100% - 1rem));
46
+ transition:transform 0.15s ease;
47
+ }
48
+ .skip-link:focus{
49
+ transform:translateY(0);
50
+ }
51
+ @media print{
52
+ .skip-link{display:none}
53
+ }
24
54
  .commune-header{
25
55
  position:sticky;
26
56
  top:0;
@@ -193,6 +223,16 @@
193
223
  border-radius:var(--c-radius-sm);
194
224
  }
195
225
 
226
+ /* A hover state whose whole content is movement loses the movement. The
227
+ rule lives here rather than in `design-system.css` because Astro scopes
228
+ this block's selectors, and a global `.theme-btn:hover` would lose the
229
+ specificity contest to the scoped rule above. */
230
+ @media (prefers-reduced-motion: reduce) {
231
+ .theme-btn:hover {
232
+ transform: none;
233
+ }
234
+ }
235
+
196
236
  /* Mobile: hide Command-K text and search text, show only icon */
197
237
  @media (max-width: 768px) {
198
238
  .search-btn kbd,
@@ -239,9 +279,44 @@
239
279
  });
240
280
  })();
241
281
 
282
+ // Publish the header's real height. The pane container positions itself
283
+ // against --header-height, and the fallback in the stylesheet is a
284
+ // hand-counted number the header has outgrown — it measures 73px at the
285
+ // default font size and more once the text is zoomed. Measuring keeps the
286
+ // pane's top gap equal to its bottom gap at any zoom level or font size,
287
+ // and means no consumer has to re-count the number after restyling the
288
+ // header.
289
+ (function trackHeaderHeight(){
290
+ const header = document.querySelector('.commune-header');
291
+ if (!header) return;
292
+ const publish = () => {
293
+ const height = Math.round(header.getBoundingClientRect().height);
294
+ if (height > 0) document.documentElement.style.setProperty('--header-height', height + 'px');
295
+ };
296
+ publish();
297
+ if ('ResizeObserver' in window) new ResizeObserver(publish).observe(header);
298
+ else addEventListener('resize', publish);
299
+ })();
300
+
301
+ // One platform check, shared with the search modal's own handler, so the
302
+ // printed shortcut and the key that actually works cannot drift apart.
303
+ // navigator.platform is deprecated but still the only thing every browser
304
+ // answers; userAgentData.platform is preferred where it exists.
305
+ window.CommuneIsMac = () => /mac/i.test(
306
+ (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || ''
307
+ );
308
+
309
+ // The button printed ⌘K to everyone. On Windows and Linux the shortcut is
310
+ // Ctrl+K, so the label named a key combination that did nothing.
311
+ (function labelSearchShortcut(){
312
+ if (window.CommuneIsMac()) return;
313
+ document.querySelectorAll('.search-btn kbd').forEach((el) => { el.textContent = 'Ctrl K'; });
314
+ document.querySelectorAll('.search-btn').forEach((el) => { el.setAttribute('aria-label', 'Search (Ctrl K)'); });
315
+ })();
316
+
242
317
  // cmd/ctrl-k opens modal
243
318
  addEventListener('keydown', (e) => {
244
- const mac = navigator.platform.toUpperCase().includes('MAC');
319
+ const mac = window.CommuneIsMac();
245
320
  if ((mac ? e.metaKey : e.ctrlKey) && e.key.toLowerCase() === 'k') {
246
321
  e.preventDefault();
247
322
  dispatchEvent(new CustomEvent('commune:openSearch'));
@@ -5,13 +5,24 @@
5
5
  (function() {
6
6
  let backlinksData = null;
7
7
 
8
+ // Focus belongs to the dialog while it is open, and goes back to the star
9
+ // that opened it on close — otherwise a keyboard reader who opens the modal
10
+ // is dropped at the top of the document with no way back to where they were.
11
+ let starModalOpener = null;
12
+
13
+ const starModalFocusables = (modal) => Array.from(
14
+ modal.querySelectorAll('a[href], button, [tabindex]:not([tabindex="-1"])')
15
+ ).filter((el) => !el.hasAttribute('disabled'));
16
+
8
17
  // Show the star modal
9
18
  function showStarModal() {
10
19
  const modal = document.getElementById('star-modal');
11
20
  if (modal) {
21
+ starModalOpener = document.activeElement;
12
22
  modal.style.display = 'flex';
13
23
  // Prevent body scroll when modal is open
14
24
  document.body.style.overflow = 'hidden';
25
+ starModalFocusables(modal)[0]?.focus();
15
26
  }
16
27
  }
17
28
 
@@ -21,6 +32,10 @@
21
32
  if (modal) {
22
33
  modal.style.display = 'none';
23
34
  document.body.style.overflow = '';
35
+ if (starModalOpener instanceof HTMLElement && document.contains(starModalOpener)) {
36
+ starModalOpener.focus({ preventScroll: true });
37
+ }
38
+ starModalOpener = null;
24
39
  }
25
40
  }
26
41
 
@@ -31,6 +46,9 @@
31
46
  const modal = document.createElement('div');
32
47
  modal.id = 'star-modal';
33
48
  modal.className = 'star-modal';
49
+ modal.setAttribute('role', 'dialog');
50
+ modal.setAttribute('aria-modal', 'true');
51
+ modal.setAttribute('aria-label', 'Top 5%');
34
52
  modal.innerHTML = `
35
53
  <div class="star-modal-backdrop"></div>
36
54
  <div class="star-modal-content">
@@ -60,10 +78,25 @@
60
78
  hideStarModal();
61
79
  });
62
80
 
63
- // Close on Escape key
81
+ // Close on Escape, and keep Tab inside the dialog while it is open.
64
82
  document.addEventListener('keydown', (e) => {
65
- if (e.key === 'Escape' && modal.style.display === 'flex') {
83
+ if (modal.style.display !== 'flex') return;
84
+ if (e.key === 'Escape') {
66
85
  hideStarModal();
86
+ return;
87
+ }
88
+ if (e.key !== 'Tab') return;
89
+ const items = starModalFocusables(modal);
90
+ if (!items.length) { e.preventDefault(); return; }
91
+ const first = items[0];
92
+ const last = items[items.length - 1];
93
+ const active = document.activeElement;
94
+ if (e.shiftKey && (active === first || !modal.contains(active))) {
95
+ e.preventDefault();
96
+ last.focus();
97
+ } else if (!e.shiftKey && (active === last || !modal.contains(active))) {
98
+ e.preventDefault();
99
+ first.focus();
67
100
  }
68
101
  });
69
102
  }
@@ -110,9 +143,13 @@
110
143
  star.setAttribute('role', 'button');
111
144
  star.setAttribute('tabindex', '0');
112
145
 
113
- // Click handler to open modal
146
+ // Click handler to open modal. stopPropagation matters: the pane
147
+ // container's delegated click handler treats any click that is not
148
+ // on a link or a real button as "focus this pane", and would pull
149
+ // focus straight back out of the modal we just opened.
114
150
  star.addEventListener('click', (e) => {
115
151
  e.preventDefault();
152
+ e.stopPropagation();
116
153
  showStarModal();
117
154
  });
118
155
 
@@ -120,6 +157,7 @@
120
157
  star.addEventListener('keydown', (e) => {
121
158
  if (e.key === 'Enter' || e.key === ' ') {
122
159
  e.preventDefault();
160
+ e.stopPropagation();
123
161
  showStarModal();
124
162
  }
125
163
  });
@@ -1,11 +1,32 @@
1
1
  ---
2
- interface Result { url: string; title: string; excerpt?: string; score?: number; source?: 'pagefind'|'semantic'; }
2
+ interface Result { url: string; title: string; excerpt?: string; score?: number; source?: 'substring'|'semantic'; }
3
+
4
+ interface Props {
5
+ /**
6
+ * A URL this site serves that answers `GET <endpoint>?q=<query>` with
7
+ * `{ results: [{ url, title, snippet }] }`. Supplying it adds a second,
8
+ * semantic search tier, merged with the local substring tier by RRF.
9
+ *
10
+ * There is no default, deliberately. The engine ships no component that
11
+ * calls a URL a stranger does not have (#56): with this prop unset, the
12
+ * semantic tier is not rendered at all — no fetch, no code for one — and
13
+ * search is the static substring pass over `/backlinks.json`, which the
14
+ * build itself produces.
15
+ */
16
+ semanticEndpoint?: string;
17
+ }
18
+
19
+ const { semanticEndpoint } = Astro.props;
3
20
  ---
4
21
  <div id="commune-search" hidden>
5
22
  <div class="shade" onclick="window.CommuneSearch.close()"></div>
6
- <div class="modal" role="dialog" aria-modal="true">
7
- <input id="q" placeholder="Search notes & docs… (Esc to close)" autofocus />
8
- <ul id="results"></ul>
23
+ <div class="modal" role="dialog" aria-modal="true" aria-label="Search">
24
+ <label class="sr-only" for="q">Search notes and pages</label>
25
+ <input id="q" type="search" autocomplete="off" autocorrect="off" spellcheck="false"
26
+ placeholder="Search notes & docs… (Esc to close)"
27
+ aria-describedby="results-status" />
28
+ <p id="results-status" class="empty" role="status" aria-live="polite">Start typing to search.</p>
29
+ <ul id="results" aria-label="Search results"></ul>
9
30
  </div>
10
31
  </div>
11
32
 
@@ -48,6 +69,13 @@ interface Result { url: string; title: string; excerpt?: string; score?: number;
48
69
  #q:focus{
49
70
  border-color:var(--c-accent);
50
71
  }
72
+ /* type="search" earns the Search key on touch keyboards; its stock clear
73
+ button is an unthemed browser widget in the middle of a designed modal. */
74
+ #q::-webkit-search-cancel-button,
75
+ #q::-webkit-search-decoration{
76
+ -webkit-appearance:none;
77
+ appearance:none;
78
+ }
51
79
  #results{
52
80
  list-style:none;
53
81
  margin:1rem 0 .5rem;
@@ -55,6 +83,11 @@ interface Result { url: string; title: string; excerpt?: string; score?: number;
55
83
  max-height:60vh;
56
84
  overflow:auto;
57
85
  }
86
+ /* The status line stands in for the list when it is empty; the list should
87
+ not also reserve its own margin underneath. */
88
+ #results:empty{
89
+ margin:0;
90
+ }
58
91
  #results li{
59
92
  padding:.85rem .75rem;
60
93
  border-radius:var(--c-radius-sm);
@@ -78,12 +111,47 @@ interface Result { url: string; title: string; excerpt?: string; score?: number;
78
111
  }
79
112
  .empty{
80
113
  text-align:center;
81
- padding:2rem;
114
+ padding:2rem 1rem;
115
+ margin:0;
82
116
  color:var(--c-text-muted);
83
117
  font-size:.95rem;
118
+ line-height:1.6;
119
+ }
120
+ .empty[hidden]{display:none}
121
+ .empty .term{
122
+ color:var(--c-text);
123
+ font-weight:600;
124
+ overflow-wrap:anywhere;
125
+ }
126
+ .sr-only{
127
+ position:absolute;
128
+ width:1px;
129
+ height:1px;
130
+ padding:0;
131
+ margin:-1px;
132
+ overflow:hidden;
133
+ clip:rect(0, 0, 0, 0);
134
+ white-space:nowrap;
135
+ border:0;
84
136
  }
85
137
  </style>
86
138
 
139
+ {semanticEndpoint && (
140
+ <script is:inline define:vars={{ semanticEndpoint }}>
141
+ // The semantic tier, rendered only because a page passed an endpoint.
142
+ // `semanticEndpoint` is that page's own URL; the engine never picks one.
143
+ window.CommuneSemanticSearch = async function (query) {
144
+ try {
145
+ const sep = semanticEndpoint.includes('?') ? '&' : '?';
146
+ const r = await fetch(`${semanticEndpoint}${sep}q=${encodeURIComponent(query)}`);
147
+ if (!r.ok) return [];
148
+ const j = await r.json();
149
+ return (j.results ?? []).slice(0, 10).map(x => ({ url: x.url, title: x.title, excerpt: x.snippet, source: 'semantic' }));
150
+ } catch { return []; }
151
+ };
152
+ </script>
153
+ )}
154
+
87
155
  <script is:inline>
88
156
  // Lightweight RRF combiner
89
157
  const rrf = (lists) => {
@@ -97,18 +165,6 @@ interface Result { url: string; title: string; excerpt?: string; score?: number;
97
165
  return Array.from(map.values()).sort((a,b) => b.score - a.score);
98
166
  };
99
167
 
100
- // Pagefind removed - semantic search will handle full-text needs
101
- // If we ever need dedicated lexical full-text search, we can re-add it
102
-
103
- async function doSemantic(query){
104
- try{
105
- const r = await fetch(`/api/ask?q=${encodeURIComponent(query)}`);
106
- if (!r.ok) return [];
107
- const j = await r.json();
108
- return (j.results ?? []).slice(0,10).map(x => ({ url: x.url, title: x.title, excerpt: x.snippet, source:'semantic' }));
109
- }catch{ return []; }
110
- }
111
-
112
168
  // DEV fallback: use backlinks graph as minimal search index
113
169
  async function devIndex() {
114
170
  try {
@@ -135,70 +191,147 @@ interface Result { url: string; title: string; excerpt?: string; score?: number;
135
191
  const pos = hay.indexOf(norm);
136
192
  return pos === -1 ? null : { ...d, score: 1 / (1 + pos) };
137
193
  }).filter(Boolean).sort((a,b) => b.score - a.score);
138
- return scored.slice(0, 20).map(d => ({ url: d.url, title: d.title, excerpt: d.excerpt, source: 'pagefind' }));
194
+ return scored.slice(0, 20).map(d => ({ url: d.url, title: d.title, excerpt: d.excerpt, source: 'substring' }));
139
195
  }
140
196
 
141
197
  // Wire up modal
142
198
  (function mountSearch(){
143
199
  const root = document.getElementById('commune-search');
200
+ const modal = root.querySelector('.modal');
144
201
  const input = root.querySelector('#q');
145
202
  const list = root.querySelector('#results');
203
+ const status = root.querySelector('#results-status');
204
+
205
+ const IDLE_MESSAGE = 'Start typing to search.';
146
206
 
147
- function render(results) {
148
- if(!results.length){
149
- list.innerHTML = '<div class="empty">No results found</div>';
207
+ // Titles and summaries are the vault author's prose. A stray <, & or " in
208
+ // one of them should read as that character, not break out of the markup
209
+ // being built here.
210
+ const escapeHtml = (value) => String(value ?? '')
211
+ .replace(/&/g, '&amp;')
212
+ .replace(/</g, '&lt;')
213
+ .replace(/>/g, '&gt;')
214
+ .replace(/"/g, '&quot;')
215
+ .replace(/'/g, '&#39;');
216
+
217
+ function showStatus(html) {
218
+ status.innerHTML = html;
219
+ status.hidden = false;
220
+ list.replaceChildren();
221
+ }
222
+
223
+ function render(results, query) {
224
+ // Before anything is typed there is no result set to be empty, so
225
+ // saying "no results" is a false negative. Only a query with no matches
226
+ // is one.
227
+ if (!query) {
228
+ showStatus(IDLE_MESSAGE);
229
+ return;
230
+ }
231
+ if (!results.length) {
232
+ showStatus(`No results for <span class="term">${escapeHtml(query)}</span>.<br />Try a shorter word, a tag, or an alias.`);
150
233
  return;
151
234
  }
235
+ status.hidden = true;
152
236
  list.innerHTML = results.map(r => (
153
237
  `<li>
154
- <a href="${r.url}">${r.title}</a>
155
- ${r.excerpt ? `<div class="excerpt">${r.excerpt}</div>` : ''}
238
+ <a href="${escapeHtml(r.url)}">${escapeHtml(r.title)}</a>
239
+ ${r.excerpt ? `<div class="excerpt">${escapeHtml(r.excerpt)}</div>` : ''}
156
240
  </li>`
157
241
  )).join('');
158
242
  // Wire close on click and enter-to-go
159
243
  list.querySelectorAll('a').forEach(a => a.onclick = () => window.CommuneSearch.close());
160
244
  }
161
245
 
162
- // SEARCH STRATEGY (2-tier hybrid):
163
- // 1. Substring (title/summary) - Fast exact matching on backlinks.json
164
- // 2. Semantic (Qdrant) - Conceptual similarity, foundation for "Ask the Brain"
165
- // RRF combines both for best results
246
+ // SEARCH STRATEGY:
247
+ // 1. Substring over `/backlinks.json` — always present, same-origin, built
248
+ // by the engine. Exact matching on title, summary, aliases and tags.
249
+ // 2. Semantic — present only when the page passed `semanticEndpoint`, in
250
+ // which case the block above defined `window.CommuneSemanticSearch`.
251
+ // With both, RRF merges them; with one, RRF preserves its order.
166
252
 
167
253
  let t;
254
+ let queryToken = 0;
168
255
  async function search(q) {
256
+ const token = ++queryToken;
257
+ const semanticTier = typeof window.CommuneSemanticSearch === 'function'
258
+ ? window.CommuneSemanticSearch(q).catch(() => [])
259
+ : Promise.resolve([]);
169
260
  const [substring, semantic] = await Promise.all([
170
- doDevSearch(q), // Tier 1: Exact phrase matching
171
- doSemantic(q).catch(() => []), // Tier 2: Conceptual (when API ready)
261
+ doDevSearch(q),
262
+ semanticTier,
172
263
  ]);
264
+ // A slower earlier query must not overwrite a later one's results.
265
+ if (token !== queryToken) return;
173
266
  let merged = rrf([substring, semantic]);
174
- render(merged);
267
+ render(merged, q);
268
+ }
269
+
270
+ // Focus belongs to the dialog while it is open: tabbing past the last
271
+ // control must not land on the page behind it, and closing must put focus
272
+ // back where the reader left it rather than at the top of the document.
273
+ let lastFocused = null;
274
+
275
+ const focusables = () => Array.from(
276
+ modal.querySelectorAll('a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])')
277
+ ).filter((el) => !el.hasAttribute('disabled') && el.offsetParent !== null);
278
+
279
+ function trapTab(e) {
280
+ if (e.key !== 'Tab' || root.hidden) return;
281
+ const items = focusables();
282
+ if (!items.length) { e.preventDefault(); return; }
283
+ const first = items[0];
284
+ const last = items[items.length - 1];
285
+ const active = document.activeElement;
286
+ if (e.shiftKey && (active === first || !modal.contains(active))) {
287
+ e.preventDefault();
288
+ last.focus();
289
+ } else if (!e.shiftKey && (active === last || !modal.contains(active))) {
290
+ e.preventDefault();
291
+ first.focus();
292
+ }
175
293
  }
176
294
 
177
295
  function open() {
296
+ if (!root.hidden) return;
297
+ lastFocused = document.activeElement;
178
298
  root.hidden = false;
179
299
  document.body.classList.add('modal-open');
180
300
  input.value = '';
181
- render([]);
301
+ queryToken++;
302
+ render([], '');
182
303
  input.focus();
183
304
  }
184
305
  function close() {
306
+ if (root.hidden) return;
185
307
  root.hidden = true;
186
308
  document.body.classList.remove('modal-open');
309
+ queryToken++;
310
+ if (lastFocused instanceof HTMLElement && document.contains(lastFocused)) {
311
+ lastFocused.focus({ preventScroll: true });
312
+ }
313
+ lastFocused = null;
187
314
  }
188
315
  window.CommuneSearch = { open, close };
189
316
 
190
317
  addEventListener('commune:openSearch', open);
191
318
  addEventListener('keydown', e => {
192
- const mac = navigator.platform.toUpperCase().includes('MAC');
319
+ const mac = window.CommuneIsMac
320
+ ? window.CommuneIsMac()
321
+ : /mac/i.test((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || '');
193
322
  if ((mac ? e.metaKey : e.ctrlKey) && e.key.toLowerCase() === 'k') {
194
323
  e.preventDefault(); open();
195
324
  }
196
325
  if (e.key === 'Escape' && !root.hidden) close();
326
+ trapTab(e);
197
327
  });
198
328
 
199
329
  input.addEventListener('input', () => {
200
330
  clearTimeout(t);
201
331
  const q = input.value.trim();
332
+ // Clearing the box returns to the idle state at once rather than
333
+ // running an empty query 200ms later and calling it "no results".
334
+ if (!q) { queryToken++; render([], ''); return; }
202
335
  t = setTimeout(() => search(q), 200);
203
336
  });
204
337