Skip to main content

max / makenotwork

Describe the discover search box, and delete the JS that drew it N8's second measured site, and the one 1ea614c8 could not take with it. Its candidates navigate -- each is a tag listing, a project or a creator page -- and until Action::navigates (quasicoherent 00ee7af5) a pick could only be spelled Action::get(url), which emits an href and an hx-get with no target, so htmx put a whole document inside the row that was clicked. The box is a described Field now. What it says out loud, all of which was a literal in static/page-discover.js: where its list comes from, that the value must stand still 200ms and carry two characters before the route is asked, that picking a row goes to the page it names, and separately that the same value re-reads the results under the current filters after 150ms. That second question is what Field::consults being a Vec is for: two questions about one string, asked at two rates, answered in two places, and only one of them used to be sayable. /discover/suggestions answers that list rather than JSON. Which tags, projects and creators are offered and in what order is unchanged; who draws them is not. 46 lines of hand-written renderer go, and page-discover.js is down to the focus backstop and the view toggle. Two consequences worth knowing, both in the code beside their reason. Every include on the screen names #q as well as .discover-filter: the box carries the classes its renderer writes rather than one this repo chose, and its id is the field's own name, which is the handle the description already had. And the replace-versus-push rule keys on a bare `div`, because a consult hangs its route on a wrapper around the control and that wrapper has no id, so htmx names it by tag alone. Two things the box no longer carries, both unsayable rather than dropped by accident: hx-indicator, so the spinner does not fire for this one control, and the `search` event a type=search box fires when its clear affordance is pressed. The lock also carries the other lane's 0.59 to 0.61 re-resolve, which nothing had committed yet.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-25 20:12 UTC
Signed with PGP, not checked
Commit: e375901a3e0bbe6228091d11b7e046415cf5de91
Parent: 8c48477
11 files changed, +446 insertions, -154 deletions
M server/Cargo.lock +24 -24
@@ -2875,7 +2875,7 @@
2875 2875 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
2876 2876 dependencies = [
2877 2877 "libc",
2878 - "windows-sys 0.52.0",
2878 + "windows-sys 0.59.0",
2879 2879 ]
2880 2880
2881 2881 [[package]]
@@ -4811,7 +4811,7 @@
4811 4811 source = "registry+https://github.com/rust-lang/crates.io-index"
4812 4812 checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd"
4813 4813 dependencies = [
4814 - "windows-sys 0.61.2",
4814 + "windows-sys 0.59.0",
4815 4815 ]
4816 4816
4817 4817 [[package]]
@@ -5712,7 +5712,7 @@
5712 5712 source = "registry+https://github.com/rust-lang/crates.io-index"
5713 5713 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
5714 5714 dependencies = [
5715 - "windows-sys 0.61.2",
5715 + "windows-sys 0.59.0",
5716 5716 ]
5717 5717
5718 5718 [[package]]
@@ -6626,7 +6626,7 @@
6626 6626
6627 6627 [[package]]
6628 6628 name = "quasi-axum"
6629 - version = "0.59.0"
6629 + version = "0.61.1"
6630 6630 dependencies = [
6631 6631 "axum",
6632 6632 "http 1.4.2",
@@ -6637,7 +6637,7 @@
6637 6637
6638 6638 [[package]]
6639 6639 name = "quasi-basics"
6640 - version = "0.59.0"
6640 + version = "0.61.1"
6641 6641 dependencies = [
6642 6642 "makeover-layout",
6643 6643 "quasi-router",
@@ -6645,7 +6645,7 @@
6645 6645
6646 6646 [[package]]
6647 6647 name = "quasi-http"
6648 - version = "0.59.0"
6648 + version = "0.61.1"
6649 6649 dependencies = [
6650 6650 "form_urlencoded",
6651 6651 "http 1.4.2",
@@ -6654,7 +6654,7 @@
6654 6654
6655 6655 [[package]]
6656 6656 name = "quasi-router"
6657 - version = "0.59.0"
6657 + version = "0.61.1"
6658 6658 dependencies = [
6659 6659 "makeover-layout",
6660 6660 ]
@@ -6677,7 +6677,7 @@
6677 6677
6678 6678 [[package]]
6679 6679 name = "quasi-webview"
6680 - version = "0.59.0"
6680 + version = "0.61.1"
6681 6681 dependencies = [
6682 6682 "docengine",
6683 6683 "makeover-layout",
@@ -6753,7 +6753,7 @@
6753 6753 "once_cell",
6754 6754 "socket2",
6755 6755 "tracing",
6756 - "windows-sys 0.52.0",
6756 + "windows-sys 0.59.0",
6757 6757 ]
6758 6758
6759 6759 [[package]]
@@ -7316,7 +7316,7 @@
7316 7316 "errno",
7317 7317 "libc",
7318 7318 "linux-raw-sys 0.4.15",
7319 - "windows-sys 0.52.0",
7319 + "windows-sys 0.59.0",
7320 7320 ]
7321 7321
7322 7322 [[package]]
@@ -7329,7 +7329,7 @@
7329 7329 "errno",
7330 7330 "libc",
7331 7331 "linux-raw-sys 0.12.1",
7332 - "windows-sys 0.52.0",
7332 + "windows-sys 0.59.0",
7333 7333 ]
7334 7334
7335 7335 [[package]]
@@ -7409,7 +7409,7 @@
7409 7409 "security-framework 3.7.0",
7410 7410 "security-framework-sys",
7411 7411 "webpki-root-certs",
7412 - "windows-sys 0.52.0",
7412 + "windows-sys 0.59.0",
7413 7413 ]
7414 7414
7415 7415 [[package]]
@@ -8535,7 +8535,7 @@
8535 8535 "getrandom 0.4.3",
8536 8536 "once_cell",
8537 8537 "rustix 1.1.4",
8538 - "windows-sys 0.52.0",
8538 + "windows-sys 0.59.0",
8539 8539 ]
8540 8540
8541 8541 [[package]]
@@ -10751,11 +10751,11 @@
10751 10751
10752 10752 [[patch.unused]]
10753 10753 name = "quasi-immediate"
10754 - version = "0.59.0"
10754 + version = "0.61.1"
10755 10755
10756 10756 [[patch.unused]]
10757 10757 name = "quasi-notifs"
10758 - version = "0.59.0"
10758 + version = "0.61.1"
10759 10759
10760 10760 [[patch.unused]]
10761 10761 name = "quasi-store"
@@ -10763,15 +10763,7 @@
10763 10763
10764 10764 [[patch.unused]]
10765 10765 name = "quasi-tauri"
10766 - version = "0.59.0"
10767 -
10768 - [[patch.unused]]
10769 - name = "synckit-client"
10770 - version = "0.9.1"
10771 -
10772 - [[patch.unused]]
10773 - name = "synckit-config"
10774 - version = "0.2.0"
10766 + version = "0.61.1"
10775 10767
10776 10768 [[patch.unused]]
10777 10769 name = "kberg"
@@ -10784,3 +10776,11 @@
10784 10776 [[patch.unused]]
10785 10777 name = "painhours"
10786 10778 version = "0.1.0"
10779 +
10780 + [[patch.unused]]
10781 + name = "synckit-client"
10782 + version = "0.9.1"
10783 +
10784 + [[patch.unused]]
10785 + name = "synckit-config"
10786 + version = "0.2.0"
@@ -87,49 +87,3 @@
87 87 }
88 88 });
89 89 })();
90 -
91 - // Search suggestions autocomplete
92 - (function() {
93 - var input = document.getElementById('search-input');
94 - var box = document.getElementById('search-suggestions');
95 - var timer = null;
96 - var selectedIdx = -1;
97 -
98 - input.addEventListener('input', function() {
99 - clearTimeout(timer);
100 - var q = input.value.trim();
101 - if (q.length < 2) { box.innerHTML = ''; box.style.display = 'none'; return; }
102 - timer = setTimeout(function() {
103 - fetch('/discover/suggestions?q=' + encodeURIComponent(q))
104 - .then(function(r) { return r.json(); })
105 - .then(function(items) {
106 - if (items.length === 0) { box.innerHTML = ''; box.style.display = 'none'; return; }
107 - selectedIdx = -1;
108 - box.innerHTML = items.map(function(s, i) {
109 - return '<a href="' + escapeHtml(s.url) + '" class="suggestion-item" data-idx="' + i + '">'
110 - + '<span class="suggestion-label">' + escapeHtml(s.label) + '</span>'
111 - + '<span class="suggestion-category">' + escapeHtml(s.category) + '</span></a>';
112 - }).join('');
113 - box.style.display = 'block';
114 - });
115 - }, 200);
116 - });
117 -
118 - input.addEventListener('keydown', function(e) {
119 - var items = box.querySelectorAll('.suggestion-item');
120 - if (!items.length) return;
121 - if (e.key === 'ArrowDown') { e.preventDefault(); selectedIdx = Math.min(selectedIdx + 1, items.length - 1); updateHighlight(items); }
122 - else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIdx = Math.max(selectedIdx - 1, -1); updateHighlight(items); }
123 - else if (e.key === 'Enter' && selectedIdx >= 0) { e.preventDefault(); items[selectedIdx].click(); }
124 - else if (e.key === 'Escape') { box.style.display = 'none'; }
125 - });
126 -
127 - function updateHighlight(items) {
128 - items.forEach(function(el, i) { el.classList.toggle('highlighted', i === selectedIdx); });
129 - }
130 -
131 -
132 - document.addEventListener('click', function(e) {
133 - if (!box.contains(e.target) && e.target !== input) { box.style.display = 'none'; }
134 - });
135 - })();
@@ -7928,7 +7928,7 @@
7928 7928 Tags are the sidebar's structure, not one facet among several, so the spine
7929 7929 reads as the primary surface and .filter-refine below it is subordinate.
7930 7930 Everything here composes existing primitives: .filter-item carries the row
7931 - and its .is-selected recipe, .search-suggestions carries the dropdown shell,
7931 + and its .is-selected recipe, the described field draws its own list, and
7932 7932 .breadcrumb carries the crumb idiom. */
7933 7933 .tag-spine { padding-bottom: var(--gap-section); border-bottom: 1px solid var(--border); }
7934 7934
@@ -7943,9 +7943,9 @@
7943 7943 .tag-chip-clear a { text-decoration: none; color: var(--content-secondary); font-size: var(--text-fine); }
7944 7944 .tag-chip-clear a:hover { color: var(--content); }
7945 7945
7946 - /* Typeahead. Shares the .search-suggestions shell; it toggles with the
7947 - `hidden` attribute rather than an inline display, so the shared rule's
7948 - display:none has to be overridden when open. */
7946 + /* Typeahead. The box, the list and everything that opens and closes it are
7947 + the described field's, so all this carries is the positioning context the
7948 + list is drawn against. */
7949 7949 .tag-combobox { position: relative; margin-bottom: var(--gap-section); }
7950 7950
7951 7951 /* Drill-down. A rung either selects (assignable leaf) or navigates (category);
@@ -7986,9 +7986,6 @@
7986 7986 .toggle-btn:hover { opacity: 0.7; }
7987 7987 .toggle-btn.is-selected { background: var(--primary-dark); color: var(--primary-light); }
7988 7988 .table-controls { background: var(--surface-sunken); display: flex; align-items: center; gap: var(--gap-section); padding: var(--gap-peer) var(--gap-section); }
7989 - .search-field { flex: 1 1 0; background: var(--surface-page); border: none; padding: var(--gap-peer) var(--gap-section); font-family: inherit; font-size: var(--text-note); color: var(--content); min-width: 0; }
7990 - .search-field:focus { outline: 2px solid var(--action); }
7991 - .search-field::placeholder { opacity: 0.5; }
7992 7989 .table-meta { font-size: var(--text-fine); opacity: 0.6; white-space: nowrap; flex-shrink: 0; }
7993 7990 .sort-select { background: var(--surface-page); border: none; padding: var(--gap-bound) var(--gap-peer); font-size: var(--text-fine); font-family: inherit; cursor: pointer; width: auto; flex-shrink: 0; }
7994 7991 .table-header { display: grid; grid-template-columns: 50px 1fr 100px 70px 70px; gap: var(--gap-peer); padding: var(--gap-peer) var(--gap-section); background: var(--surface-overlay); font-size: var(--text-fine); opacity: 0.7; text-transform: uppercase; letter-spacing: 0.03em; }
@@ -8087,7 +8084,6 @@
8087 8084 .table-header.projects-header, .table-row.project-row { grid-template-columns: 1fr; }
8088 8085 .table-header.projects-header span:nth-child(2) { display: none; }
8089 8086 .row-price { font-size: var(--text-fine); }
8090 - .search-field { min-width: 0; width: 100%; }
8091 8087 .table-controls { flex-wrap: wrap; }
8092 8088 .mode-toggle { flex-wrap: wrap; }
8093 8089 .table-footer { flex-direction: column; gap: var(--gap-peer); align-items: flex-start; }
@@ -8336,22 +8332,14 @@
8336 8332 button.saved { border-color: var(--action); color: var(--action); }
8337 8333
8338 8334 /* ===========================================
8339 - SEARCH SUGGESTIONS
8340 - =========================================== */
8335 + SEARCH BOX
8336 + ===========================================
8337 + The box and its suggestion list are a described Field (N8), which draws its
8338 + own control and its own list. What is left here is where the toolbar puts
8339 + it: a flex child that takes the rest of the row, and the positioning context
8340 + the list is absolutely placed against. */
8341 8341
8342 8342 .search-wrapper { position: relative; flex: 1; min-width: 0; }
8343 - .search-suggestions {
8344 - display: none; position: absolute; left: 0; right: 0; top: 100%; z-index: var(--z-picker);
8345 - background: var(--surface-page); border: 1px solid var(--border); border-top: none;
8346 - box-shadow: var(--elevation-overlay); max-height: 280px; overflow-y: auto;
8347 - }
8348 - .suggestion-item {
8349 - display: flex; justify-content: space-between; align-items: center;
8350 - padding: var(--gap-peer) var(--gap-section); text-decoration: none; color: var(--content);
8351 - font-size: var(--text-note); cursor: pointer;
8352 - }
8353 - .suggestion-item:hover, .suggestion-item.highlighted { background: var(--surface-sunken); }
8354 - .suggestion-category { font-size: var(--text-fine); opacity: 0.5; text-transform: uppercase; letter-spacing: 0.05em; }
8355 8343
8356 8344 /* ===========================================
8357 8345 DOC UI EXAMPLES (embedded live UI in docs)
@@ -33,6 +33,7 @@
33 33
34 34 pub mod blog_delete_act;
35 35 pub mod buyer_contacts;
36 + pub mod discover_search;
36 37 pub mod discover_typeahead;
37 38 pub mod embeds;
38 39 pub mod export_act;
@@ -35,31 +35,35 @@
35 35
36 36 {# `tag-added` is fired by the typeahead once it appends a hidden
37 37 input, so picking a suggestion refreshes results through the same
38 - path as every other filter control. #}
38 + path as every other filter control.
39 +
40 + `#q` beside `.discover-filter` in every include on this screen:
41 + the search box is a described Field now and carries the classes
42 + its renderer writes, not one this repo chose. Its id is the
43 + field's own name, which is the handle the description already
44 + had. Every control that re-reads the results needs the search
45 + value along, and a class on markup nobody here writes is not
46 + something to reach for. #}
39 47 <form id="discover-form" action="/discover" method="get"
40 48 hx-get="/discover/results"
41 49 hx-target="#results-container" hx-swap="outerHTML"
42 50 hx-indicator="#search-spinner"
43 - hx-include=".discover-filter"
51 + hx-include=".discover-filter, #q"
44 52 hx-trigger="tag-added">
45 53 <input type="hidden" id="mode-input" name="mode" value="{{ mode }}" class="discover-filter">
46 54 <div class="table-controls">
47 - <label for="search-input" class="sr-only">Search {% if mode == "projects" %}projects{% else %}items{% endif %}</label>
55 + {# Described (N8): the box, the list it owns, the route it
56 + asks for candidates, the two waits, the floor, the
57 + filters that ride with the results question and the page
58 + picking a row goes to all come out of crate::quasi. What
59 + stood here was a bare input and an empty list pointed at
60 + each other by id, with the rest in page-discover.js.
61 +
62 + The wrapper stays: it is the positioning context the
63 + list is drawn against, and the flex child the toolbar
64 + row sizes. #}
48 65 <div class="search-wrapper">
49 - <input type="text"
50 - id="search-input"
51 - class="search-field discover-filter"
52 - placeholder="Search {% if mode == "projects" %}projects{% else %}items{% endif %}..."
53 - name="q"
54 - value="{{ search_query }}"
55 - autocomplete="off"
56 - aria-label="Search {% if mode == "projects" %}projects{% else %}items{% endif %}"
57 - hx-get="/discover/results"
58 - hx-trigger="input changed delay:150ms, search"
59 - hx-target="#results-container" hx-swap="outerHTML"
60 - hx-indicator="#search-spinner"
61 - hx-include=".discover-filter">
62 - <div id="search-suggestions" class="search-suggestions"></div>
66 + {{ crate::quasi::discover_search::search_box(mode, search_query)|safe }}
63 67 </div>
64 68 <span id="search-spinner" class="htmx-indicator" aria-live="polite">Searching...</span>
65 69 <span class="table-meta" id="total-count">{{ count_label }}</span>
@@ -72,7 +76,7 @@
72 76 hx-trigger="change"
73 77 hx-target="#results-container" hx-swap="outerHTML"
74 78 hx-indicator="#search-spinner"
75 - hx-include=".discover-filter">
79 + hx-include=".discover-filter, #q">
76 80 {% if mode == "projects" %}
77 81 <option value="newest"{% if sort_by == "newest" || sort_by == "" %} selected{% endif %}>Newest</option>
78 82 <option value="most_sold"{% if sort_by == "most_sold" %} selected{% endif %}>Most items</option>
@@ -114,5 +118,5 @@
114 118
115 119 {% block scripts %}
116 120 <script src="/static/collections.js?v=0514"></script>
117 - <script src="/static/page-discover.js?v=0623" defer></script>
121 + <script src="/static/page-discover.js?v=0624" defer></script>
118 122 {% endblock %}
@@ -196,27 +196,27 @@
196 196 <button {% if current_page <= 1 %}disabled{% endif %}
197 197 hx-get="/discover/results"
198 198 hx-target="#results-container" hx-swap="outerHTML"
199 - hx-include=".discover-filter"
199 + hx-include=".discover-filter, #q"
200 200 hx-vals='{"page": {{ current_page - 1 }}}'
201 201 {% if current_page <= 1 %}hx-disable="this"{% endif %}>Prev</button>
202 202 {% for p in pagination_range %}
203 203 <button {% if *p == current_page %}class="is-selected"{% endif %}
204 204 hx-get="/discover/results"
205 205 hx-target="#results-container" hx-swap="outerHTML"
206 - hx-include=".discover-filter"
206 + hx-include=".discover-filter, #q"
207 207 hx-vals='{"page": {{ p }}}'>{{ p }}</button>
208 208 {% endfor %}
209 209 {% if total_pages > 5 %}
210 210 <button disabled>...</button>
211 211 <button hx-get="/discover/results"
212 212 hx-target="#results-container" hx-swap="outerHTML"
213 - hx-include=".discover-filter"
213 + hx-include=".discover-filter, #q"
214 214 hx-vals='{"page": {{ total_pages }}}'>{{ total_pages }}</button>
215 215 {% endif %}
216 216 <button {% if current_page >= total_pages || total_pages == 0 %}disabled{% endif %}
217 217 hx-get="/discover/results"
218 218 hx-target="#results-container" hx-swap="outerHTML"
219 - hx-include=".discover-filter"
219 + hx-include=".discover-filter, #q"
220 220 hx-vals='{"page": {{ current_page + 1 }}}'
221 221 {% if current_page >= total_pages || total_pages == 0 %}hx-disable="this"{% endif %}>Next</button>
222 222 {% endif %}
@@ -41,7 +41,7 @@
41 41 hx-get="/discover/results"
42 42 hx-target="#results-container" hx-swap="outerHTML"
43 43 hx-indicator="#search-spinner"
44 - hx-include=".discover-filter"
44 + hx-include=".discover-filter, #q"
45 45 hx-trigger="change">
46 46 <span>{{ cf.name }}</span>
47 47 <span class="count" aria-label="{{ cf.count }} projects">{{ cf.count }}</span>
@@ -61,7 +61,7 @@
61 61 hx-trigger="change"
62 62 hx-target="#results-container" hx-swap="outerHTML"
63 63 hx-indicator="#search-spinner"
64 - hx-include=".discover-filter">
64 + hx-include=".discover-filter, #q">
65 65 Has source code
66 66 </label>
67 67 </div>
@@ -132,7 +132,7 @@
132 132 hx-get="/discover/results"
133 133 hx-target="#results-container" hx-swap="outerHTML"
134 134 hx-indicator="#search-spinner"
135 - hx-include=".discover-filter"
135 + hx-include=".discover-filter, #q"
136 136 hx-trigger="change">
137 137 <span>{{ row.label }}</span>
138 138 <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span>
@@ -199,7 +199,7 @@
199 199 hx-get="/discover/results"
200 200 hx-target="#results-container" hx-swap="outerHTML"
201 201 hx-indicator="#search-spinner"
202 - hx-include=".discover-filter"
202 + hx-include=".discover-filter, #q"
203 203 hx-trigger="change">
204 204 <span>{{ tf.name }}</span>
205 205 <span class="count" aria-label="{{ tf.count }} items">{{ tf.count }}</span>
@@ -230,7 +230,7 @@
230 230 hx-trigger="change delay:500ms"
231 231 hx-target="#results-container" hx-swap="outerHTML"
232 232 hx-indicator="#search-spinner"
233 - hx-include=".discover-filter">
233 + hx-include=".discover-filter, #q">
234 234 <span aria-hidden="true">to</span>
235 235 <label for="max-price" class="sr-only">Maximum price in dollars</label>
236 236 <input type="number" id="max-price" name="max_price" placeholder="Any" min="0" step="0.01"
@@ -241,7 +241,7 @@
241 241 hx-trigger="change delay:500ms"
242 242 hx-target="#results-container" hx-swap="outerHTML"
243 243 hx-indicator="#search-spinner"
244 - hx-include=".discover-filter">
244 + hx-include=".discover-filter, #q">
245 245 </div>
246 246 {# Buckets are links, not form state, so they cannot disagree
247 247 with the number inputs above and they work without JS. #}
@@ -275,7 +275,7 @@
275 275 hx-get="/discover/results"
276 276 hx-target="#results-container" hx-swap="outerHTML"
277 277 hx-indicator="#search-spinner"
278 - hx-include=".discover-filter"
278 + hx-include=".discover-filter, #q"
279 279 hx-trigger="change">
280 280 <span>{{ af.name }}</span>
281 281 <span class="count" aria-label="{{ af.count }} items">{{ af.count }}</span>
@@ -3,7 +3,7 @@
3 3 //! Covers the four discover endpoints:
4 4 //! - GET /discover (full page, faceted)
5 5 //! - GET /discover/results (HTMX results partial)
6 - //! - GET /discover/suggestions (JSON search suggestions)
6 + //! - GET /discover/suggestions (the search box's described candidate list)
7 7 //! - GET /discover/tags (tag tree partial)
8 8 //!
9 9 //! Privacy invariants that must hold across all of these (verified per-test):
@@ -371,23 +371,80 @@
371 371 );
372 372 }
373 373
374 + /// The route answers about the value it was given. It answered a JSON array
375 + /// until `e3615a0d`; what it answers now is the described suggestion list, and
376 + /// what is being asserted either way is that a typed value comes back as
377 + /// something to offer.
374 378 #[tokio::test]
375 - async fn suggestions_endpoint_returns_json() {
379 + async fn the_suggestions_endpoint_answers_about_what_was_typed() {
376 380 let mut h = TestHarness::new().await;
377 381 make_discoverable_item(&mut h, "suggcreator", "Suggestion Probe", "digital").await;
378 382
379 - let resp = h.client.get("/discover/suggestions?q=Suggestion").await;
383 + // By the creator's name: the route offers tags, projects and creators, and
384 + // the harness names the creator but not the project it makes.
385 + let resp = h.client.get("/discover/suggestions?q=suggcreator").await;
380 386 assert_eq!(
381 387 resp.status, 200,
382 388 "GET suggestions: {} {}",
383 389 resp.status, resp.text
384 390 );
385 - // Response is `Vec<SearchSuggestion>`, must parse as JSON array.
386 - let parsed: serde_json::Value = resp.json();
387 391 assert!(
388 - parsed.is_array(),
389 - "Suggestions response must be a JSON array"
392 + resp.text.contains(r#"role="option""#),
393 + "rows are options in the list the field owns: {}",
394 + resp.text
390 395 );
396 + assert!(
397 + resp.text.contains("suggcreator"),
398 + "the creator it was asked about is offered: {}",
399 + resp.text
400 + );
401 + }
402 +
403 + /// N8's second site. Each candidate is a page, so picking one replaces the
404 + /// document: the row is a link the browser follows and htmx is not involved.
405 + /// An `hx-get` here would land a whole page inside the suggestion row, which
406 + /// is the failure `00ee7af5` was ruled on and the reason this waited.
407 + #[tokio::test]
408 + async fn a_suggested_page_is_a_link_the_browser_follows() {
409 + let mut h = TestHarness::new().await;
410 + make_discoverable_item(&mut h, "navcreator", "Navigating Probe", "digital").await;
411 +
412 + let resp = h.client.get("/discover/suggestions?q=navcreator").await;
413 + assert_eq!(resp.status, 200, "{}", resp.text);
414 + assert!(
415 + resp.text.contains("<a class=\"form-suggestion\""),
416 + "a pick that navigates is an anchor: {}",
417 + resp.text
418 + );
419 + assert!(
420 + resp.text.contains("href=\"/u/navcreator\""),
421 + "and it carries the page it goes to: {}",
422 + resp.text
423 + );
424 + assert!(
425 + !resp.text.contains("hx-get"),
426 + "no htmx on a navigation: {}",
427 + resp.text
428 + );
429 + // What kind of page it is, which is what tells two rows reading alike
430 + // apart, and it is its own element rather than part of the label.
431 + assert!(
432 + resp.text.contains("form-suggestion-detail"),
433 + "{}",
434 + resp.text
435 + );
436 + }
437 +
438 + /// Below the floor the box does not ask at all, and asked with nothing the
439 + /// route answers nothing rather than an empty document: the answer replaces
440 + /// the list the field owns.
441 + #[tokio::test]
442 + async fn an_empty_question_offers_nothing() {
443 + let mut h = TestHarness::new().await;
444 +
445 + let resp = h.client.get("/discover/suggestions?q=%20").await;
446 + assert_eq!(resp.status, 200, "{}", resp.text);
447 + assert!(resp.text.trim().is_empty(), "{}", resp.text);
391 448 }
392 449
393 450 #[tokio::test]
@@ -203,13 +203,17 @@
203 203 let mut h = TestHarness::new().await;
204 204
205 205 // The search box is debounced, so pushing would leave an entry per keystroke pause.
206 + //
207 + // `div` and not `input#search-input`: the box is a described Field now, and
208 + // a consult hangs its route on a wrapper around the control, which carries
209 + // no id of its own. See `discover_results`.
206 210 let resp = h
207 211 .client
208 212 .request_with_headers(
209 213 "GET",
210 214 "/discover/results?mode=items&q=ambient",
211 215 None,
212 - &[("HX-Request", "true"), ("HX-Source", "input#search-input")],
216 + &[("HX-Request", "true"), ("HX-Source", "div")],
213 217 )
214 218 .await;
215 219 assert_eq!(resp.status, 200);
@@ -265,14 +269,16 @@
265 269 "AI tier should be radios: its three tiers are nested ranges, not independent values"
266 270 );
267 271
268 - // The one listbox on the page is the tag typeahead, and it must declare the
269 - // wiring it actually implements. This assertion previously banned listbox
270 - // roles outright, because the only one on the page was a fake.
271 - assert!(
272 - resp.text.contains(r#"role="combobox""#),
273 - "the tag typeahead input should declare combobox"
272 + // Two comboboxes on the page and a listbox each: the sidebar's tag
273 + // typeahead and the search box beside the results, both described (N8).
274 + // These assertions previously banned listbox roles outright, because the
275 + // only one on the page was a fake.
276 + assert_eq!(
277 + resp.text.matches(r#"role="combobox""#).count(),
278 + 2,
279 + "the tag typeahead and the search box both declare combobox"
274 280 );
275 - // Derived from the field's name rather than authored, which is what the
281 + // Derived from each field's name rather than authored, which is what the
276 282 // field owning its list buys: the box's id is the name and the list's is
277 283 // that plus a suffix, so the two cannot come apart. (N8.)
278 284 assert!(
@@ -280,10 +286,14 @@
280 286 .contains(r#"aria-controls="tag-search-suggestions""#),
281 287 "the combobox must point at the listbox it controls"
282 288 );
289 + assert!(
290 + resp.text.contains(r#"aria-controls="q-suggestions""#),
291 + "and so must the search box"
292 + );
283 293 assert_eq!(
284 294 resp.text.matches(r#"role="listbox""#).count(),
285 - 1,
286 - "the typeahead should be the only listbox; filter lists must not claim the role"
295 + 2,
296 + "one listbox per described box; filter lists must not claim the role"
287 297 );
288 298 assert!(
289 299 !resp.text.contains(r#"role="option""#),