Skip to main content

max / makenotwork

Describe the discover tag typeahead, and delete the JS that drew it N8 (makeover-layout 1503db12), the consumer half. The vocabulary landed in three pieces and nothing outside quasi's own tests had ever taken them: a field that owns a suggestion list and asks a route as it is typed, a candidate carrying the second line that tells it from a row reading alike, and a pick that performs a call instead of writing a value. The sidebar's box is now a described Field. What it says out loud, all of which was a literal in static/page-discover.js: where the list comes from, that the value must stand still 150ms, that there must be two characters before the route is asked at all, which controls' values ride with the question, and that picking a row calls /discover/results with the whole current filter set plus the tag. 118 lines of hand-written renderer go -- the fetch, the row markup, aria-expanded, aria-activedescendant, arrow-key movement, and a choose() that built a hidden input by hand. /discover/tag-suggest answers that list rather than JSON. Which tags and in what order is unchanged; who draws them is not. The typed value arrives under the field's own name, and only the eleven filter names the description says ride are read back, so nothing a caller appends reaches the markup. The results region moved with it. A described control emits hx-swap="outerMorph" unconditionally, so a pick aimed at the old wrapper would have replaced it and taken #results-container's id away on the first use. The id is on the results partial's own root now -- the answer to /discover/results IS the region -- and the 14 controls that re-read it swap it whole. The out-of-band elements stay outside it, since htmx honours hx-swap-oob only at the top level of a response. Two things the vocabulary cannot say yet, both filed rather than faked: picking a candidate that navigates the whole document (quasicoherent 3cf246ec), which is why the search box beside this one is still hand-written, and a half-typed value surviving a swap (a135f898), which is the hx-preserve the box used to carry.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 04:15 UTC
Signed with PGP, not checked
Commit: 1ea614c8a5cbd6755f4c92e4c4e6383bbc504461
Parent: 3290602
11 files changed, +479 insertions, -184 deletions
M server/build.rs +8 -1
@@ -459,7 +459,14 @@
459 459 /// One class the templates had no word for is written by the row they became.
460 460 /// Measured rather than predicted; the check asked for it on the build that
461 461 /// converted them.
462 - const DEAD_VOCABULARY_HIGH_WATER: usize = 36;
462 + /// 36 to 34, 2026-08-21: the tag typeahead ported (N8, `1503db12`), which is
463 + /// the tightening the suggestion-list paragraph above said to wait for. Two of
464 + /// its three classes are written now — `.form-suggestions` by the list the
465 + /// field owns and `.form-suggestion` by each candidate. `.form-suggestion-why`
466 + /// is not among them: it came out of makeover-webview 0.57.0 when a candidate
467 + /// grew a second line (`1fcf2e9b`), so it is gone rather than dead. The search
468 + /// box is still hand-written and is the site that would tighten this again.
469 + const DEAD_VOCABULARY_HIGH_WATER: usize = 34;
463 470
464 471 /// Every file that can carry a class name.
465 472 ///
@@ -29,124 +29,6 @@
29 29 });
30 30 })();
31 31
32 - // Tag typeahead. Backed by tagtree's TagIndex server-side, so a tag is
33 - // reachable by name from any depth without knowing where it sits.
34 - (function() {
35 - var input = document.getElementById('tag-search');
36 - var list = document.getElementById('tag-suggest-list');
37 - if (!input || !list) return;
38 - var timer = null;
39 - var active = -1;
40 -
41 - function close() {
42 - list.hidden = true;
43 - list.innerHTML = '';
44 - input.setAttribute('aria-expanded', 'false');
45 - input.removeAttribute('aria-activedescendant');
46 - active = -1;
47 - }
48 -
49 - // Focus stays in the input for a combobox; aria-activedescendant is what
50 - // tells assistive tech which option is current. Without it the listbox
51 - // role is a claim the widget does not honour.
52 - function setActive(items, index) {
53 - active = index;
54 - items.forEach(function(el, i) {
55 - var on = i === index;
56 - el.classList.toggle('highlighted', on);
57 - el.setAttribute('aria-selected', on ? 'true' : 'false');
58 - });
59 - if (index >= 0 && items[index]) {
60 - input.setAttribute('aria-activedescendant', items[index].id);
61 - } else {
62 - input.removeAttribute('aria-activedescendant');
63 - }
64 - }
65 -
66 - function choose(slug) {
67 - // Adding a tag is the same operation the drill-down checkbox performs,
68 - // so route it through the form rather than a bespoke request.
69 - var form = document.getElementById('discover-form');
70 - if (!form) return;
71 - var existing = form.querySelector('input[data-facet="tag"][value="' + CSS.escape(slug) + '"]');
72 - if (!existing) {
73 - var el = document.createElement('input');
74 - el.type = 'hidden';
75 - el.name = 'tag';
76 - el.value = slug;
77 - el.className = 'discover-filter';
78 - el.dataset.facet = 'tag';
79 - form.appendChild(el);
80 - }
81 - input.value = '';
82 - close();
83 - htmx.trigger(form, 'tag-added');
84 - }
85 -
86 - function render(items) {
87 - if (!items.length) { close(); return; }
88 - list.innerHTML = '';
89 - items.forEach(function(item, i) {
90 - var li = document.createElement('li');
91 - li.id = 'tag-suggest-option-' + i;
92 - li.setAttribute('role', 'option');
93 - li.setAttribute('aria-selected', i === active ? 'true' : 'false');
94 - li.className = 'suggestion-item tag-suggest-item' + (i === active ? ' highlighted' : '');
95 - li.dataset.slug = item.slug;
96 - var label = document.createElement('span');
97 - label.className = 'tag-suggest-label';
98 - label.textContent = item.label;
99 - li.appendChild(label);
100 - if (item.context) {
101 - var ctx = document.createElement('span');
102 - ctx.className = 'suggestion-category tag-suggest-context';
103 - ctx.textContent = item.context;
104 - li.appendChild(ctx);
105 - }
106 - li.addEventListener('mousedown', function(e) {
107 - e.preventDefault();
108 - choose(item.slug);
109 - });
110 - list.appendChild(li);
111 - });
112 - list.hidden = false;
113 - input.setAttribute('aria-expanded', 'true');
114 - }
115 -
116 - input.addEventListener('input', function() {
117 - var q = input.value.trim();
118 - clearTimeout(timer);
119 - if (q.length < 2) { close(); return; }
120 - timer = setTimeout(function() {
121 - fetch('/discover/tag-suggest?q=' + encodeURIComponent(q))
122 - .then(function(r) { return r.ok ? r.json() : []; })
123 - .then(function(items) { active = -1; render(items); })
124 - .catch(function() { close(); });
125 - }, 150);
126 - });
127 -
128 - input.addEventListener('keydown', function(e) {
129 - var items = list.querySelectorAll('.tag-suggest-item');
130 - if (e.key === 'Escape') { close(); return; }
131 - if (!items.length) return;
132 - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
133 - e.preventDefault();
134 - var next = active + (e.key === 'ArrowDown' ? 1 : -1);
135 - if (next < 0) next = items.length - 1;
136 - if (next >= items.length) next = 0;
137 - setActive(items, next);
138 - } else if (e.key === 'Enter') {
139 - e.preventDefault();
140 - var pick = active >= 0 ? items[active] : items[0];
141 - if (pick) choose(pick.dataset.slug);
142 - }
143 - });
144 -
145 - document.addEventListener('click', function(e) {
146 - if (!list.contains(e.target) && e.target !== input) close();
147 - });
148 - })();
149 -
150 32 // View toggle (list vs grid) with localStorage persistence.
151 33 // Re-applied after HTMX swaps because new content replaces the container.
152 34 (function() {
@@ -185,10 +67,21 @@
185 67 // Settle rather than swap: htmx 4 fires `htmx:after:swap` on the
186 68 // element that made the request, and a sidebar filter link is inside
187 69 // the region the out-of-band sidebar swap replaces, so the event has
188 - // nothing to bubble through. Settle fires on the swap target, which
189 - // survives by definition.
70 + // nothing to bubble through.
71 + //
72 + // Asked as "did the results region just arrive" rather than "is the
73 + // settled element #results-container". The region is swapped whole now
74 + // (hx-swap="outerHTML", which is what a described control emits), so
75 + // the element the event names is not necessarily the one that was
76 + // targeted, and the id test silently stopped firing under an outer
77 + // swap: the grid preference reverted to list on every filter click.
190 78 document.body.addEventListener('htmx:after:settle', function(evt) {
191 - if (evt.target.id === 'results-container') {
79 + var el = evt.target;
80 + if (!el || !el.querySelector) return;
81 + var arrived = el.id === 'results-container'
82 + || el.id === 'results-container-inner'
83 + || el.querySelector('#results-container-inner');
84 + if (arrived) {
192 85 var saved = safeStorageGet('discoverViewPref') || 'grid';
193 86 applyView(saved);
194 87 }
@@ -7946,10 +7946,6 @@
7946 7946 `hidden` attribute rather than an inline display, so the shared rule's
7947 7947 display:none has to be overridden when open. */
7948 7948 .tag-combobox { position: relative; margin-bottom: var(--gap-section); }
7949 - .tag-combobox-input { width: 100%; padding: var(--gap-bound); font-size: var(--text-fine); background: var(--surface-sunken); border: none; }
7950 - .tag-suggest-list { list-style: none; }
7951 - .tag-suggest-list:not([hidden]) { display: block; }
7952 - .tag-suggest-item { gap: var(--gap-peer); }
7953 7949
7954 7950 /* Drill-down. A rung either selects (assignable leaf) or navigates (category);
7955 7951 rows with both put the chevron after the label. */
@@ -8199,8 +8195,7 @@
8199 8195 display: block;
8200 8196 }
8201 8197
8202 - .suggestion-item,
8203 - .tag-suggest-item {
8198 + .suggestion-item {
8204 8199 padding: var(--gap-peer) var(--gap-section);
8205 8200 cursor: pointer;
8206 8201 font-size: var(--text-note);
@@ -8344,8 +8339,7 @@
8344 8339 =========================================== */
8345 8340
8346 8341 .search-wrapper { position: relative; flex: 1; min-width: 0; }
8347 - .search-suggestions,
8348 - .tag-suggest-list {
8342 + .search-suggestions {
8349 8343 display: none; position: absolute; left: 0; right: 0; top: 100%; z-index: var(--z-picker);
8350 8344 background: var(--surface-page); border: 1px solid var(--border); border-top: none;
8351 8345 box-shadow: var(--elevation-overlay); max-height: 280px; overflow-y: auto;
@@ -8355,8 +8349,7 @@
8355 8349 padding: var(--gap-peer) var(--gap-section); text-decoration: none; color: var(--content);
8356 8350 font-size: var(--text-note); cursor: pointer;
8357 8351 }
8358 - .suggestion-item:hover, .suggestion-item.highlighted,
8359 - .tag-suggest-item:hover, .tag-suggest-item.highlighted { background: var(--surface-sunken); }
8352 + .suggestion-item:hover, .suggestion-item.highlighted { background: var(--surface-sunken); }
8360 8353 .suggestion-category { font-size: var(--text-fine); opacity: 0.5; text-transform: uppercase; letter-spacing: 0.05em; }
8361 8354
8362 8355 /* ===========================================
@@ -32,6 +32,7 @@
32 32 use crate::auth::SessionUser;
33 33
34 34 pub mod buyer_contacts;
35 + pub mod discover_typeahead;
35 36 pub mod embeds;
36 37 pub mod forum_memberships;
37 38 pub mod item_tabs;
@@ -38,7 +38,7 @@
38 38 path as every other filter control. #}
39 39 <form id="discover-form" action="/discover" method="get"
40 40 hx-get="/discover/results"
41 - hx-target="#results-container"
41 + hx-target="#results-container" hx-swap="outerHTML"
42 42 hx-indicator="#search-spinner"
43 43 hx-include=".discover-filter"
44 44 hx-trigger="tag-added">
@@ -56,7 +56,7 @@
56 56 aria-label="Search {% if mode == "projects" %}projects{% else %}items{% endif %}"
57 57 hx-get="/discover/results"
58 58 hx-trigger="input changed delay:150ms, search"
59 - hx-target="#results-container"
59 + hx-target="#results-container" hx-swap="outerHTML"
60 60 hx-indicator="#search-spinner"
61 61 hx-include=".discover-filter">
62 62 <div id="search-suggestions" class="search-suggestions"></div>
@@ -70,7 +70,7 @@
70 70 aria-label="Sort results by"
71 71 hx-get="/discover/results"
72 72 hx-trigger="change"
73 - hx-target="#results-container"
73 + hx-target="#results-container" hx-swap="outerHTML"
74 74 hx-indicator="#search-spinner"
75 75 hx-include=".discover-filter">
76 76 {% if mode == "projects" %}
@@ -103,9 +103,11 @@
103 103 </div>
104 104 {% endif %}
105 105
106 - <div id="results-container">
107 - {% include "partials/discover_results.html" %}
108 - </div>
106 + {# The region is the partial's own root now, not a wrapper here:
107 + every control that re-reads it swaps the element whole
108 + (hx-swap="outerHTML"), which is what a described control emits and
109 + what stops the region losing its id on the first pick. #}
110 + {% include "partials/discover_results.html" %}
109 111 </main>
110 112 </div>
111 113 {% endblock %}
@@ -41,6 +41,12 @@
41 41 </div>
42 42 {% endif %}
43 43
44 + {# The region a filter change replaces, whole. Its id is here rather than on a
45 + wrapper in the page, so the answer to /discover/results IS the region and an
46 + outer swap keeps the element every control targets. The out-of-band elements
47 + above stay outside it: htmx only honours hx-swap-oob at the top level of a
48 + response. #}
49 + <div id="results-container">
44 50 <div class="results-container results-list" id="results-container-inner">
45 51 <!-- List View -->
46 52 <div class="results-table well" id="results-table">
@@ -189,30 +195,31 @@
189 195 {% if total_pages > 0 %}
190 196 <button {% if current_page <= 1 %}disabled{% endif %}
191 197 hx-get="/discover/results"
192 - hx-target="#results-container"
198 + hx-target="#results-container" hx-swap="outerHTML"
193 199 hx-include=".discover-filter"
194 200 hx-vals='{"page": {{ current_page - 1 }}}'
195 201 {% if current_page <= 1 %}hx-disable="this"{% endif %}>Prev</button>
196 202 {% for p in pagination_range %}
197 203 <button {% if *p == current_page %}class="is-selected"{% endif %}
198 204 hx-get="/discover/results"
199 - hx-target="#results-container"
205 + hx-target="#results-container" hx-swap="outerHTML"
200 206 hx-include=".discover-filter"
201 207 hx-vals='{"page": {{ p }}}'>{{ p }}</button>
202 208 {% endfor %}
203 209 {% if total_pages > 5 %}
204 210 <button disabled>...</button>
205 211 <button hx-get="/discover/results"
206 - hx-target="#results-container"
212 + hx-target="#results-container" hx-swap="outerHTML"
207 213 hx-include=".discover-filter"
208 214 hx-vals='{"page": {{ total_pages }}}'>{{ total_pages }}</button>
209 215 {% endif %}
210 216 <button {% if current_page >= total_pages || total_pages == 0 %}disabled{% endif %}
211 217 hx-get="/discover/results"
212 - hx-target="#results-container"
218 + hx-target="#results-container" hx-swap="outerHTML"
213 219 hx-include=".discover-filter"
214 220 hx-vals='{"page": {{ current_page + 1 }}}'
215 221 {% if current_page >= total_pages || total_pages == 0 %}hx-disable="this"{% endif %}>Next</button>
216 222 {% endif %}
217 223 </div>
218 224 </div>
225 + </div>
@@ -39,7 +39,7 @@
39 39 class="discover-filter"
40 40 {% if cf.active %}checked{% endif %}
41 41 hx-get="/discover/results"
42 - hx-target="#results-container"
42 + hx-target="#results-container" hx-swap="outerHTML"
43 43 hx-indicator="#search-spinner"
44 44 hx-include=".discover-filter"
45 45 hx-trigger="change">
@@ -59,7 +59,7 @@
59 59 {% if sidebar.has_source %}checked{% endif %}
60 60 hx-get="/discover/results"
61 61 hx-trigger="change"
62 - hx-target="#results-container"
62 + hx-target="#results-container" hx-swap="outerHTML"
63 63 hx-indicator="#search-spinner"
64 64 hx-include=".discover-filter">
65 65 Has source code
@@ -96,14 +96,15 @@
96 96
97 97 {# Typeahead. Reaches a tag at any depth by name, which is what
98 98 makes a taxonomy of arbitrary size navigable and what the old
99 - capped ten-tag list plus "Browse all" could not do. #}
99 + capped ten-tag list plus "Browse all" could not do.
100 +
101 + Described (N8): the box, the list it owns, the route it asks,
102 + the wait, the floor, the filters that ride along and what
103 + picking a row does all come out of crate::quasi. What stood
104 + here was the box and an empty list pointed at each other by
105 + id, with the rest in page-discover.js. #}
100 106 <div class="tag-combobox">
101 - <label class="sr-only" for="tag-search">Find a tag</label>
102 - <input type="search" id="tag-search" class="tag-combobox-input" hx-preserve="true"
103 - placeholder="Find a tag" autocomplete="off"
104 - role="combobox" aria-expanded="false"
105 - aria-controls="tag-suggest-list" aria-autocomplete="list">
106 - <ul id="tag-suggest-list" class="search-suggestions tag-suggest-list" role="listbox" hidden></ul>
107 + {{ crate::quasi::discover_typeahead::tag_box()|safe }}
107 108 </div>
108 109
109 110 {# Drill-down. `browse` moves the cursor, `tag` selects; a
@@ -129,7 +130,7 @@
129 130 class="discover-filter" data-facet="tag"
130 131 {% if row.selected %}checked{% endif %}
131 132 hx-get="/discover/results"
132 - hx-target="#results-container"
133 + hx-target="#results-container" hx-swap="outerHTML"
133 134 hx-indicator="#search-spinner"
134 135 hx-include=".discover-filter"
135 136 hx-trigger="change">
@@ -196,7 +197,7 @@
196 197 {% if tf.active %}checked{% endif %}
197 198 {% if tf.count == 0 && !tf.active %}disabled{% endif %}
198 199 hx-get="/discover/results"
199 - hx-target="#results-container"
200 + hx-target="#results-container" hx-swap="outerHTML"
200 201 hx-indicator="#search-spinner"
201 202 hx-include=".discover-filter"
202 203 hx-trigger="change">
@@ -227,7 +228,7 @@
227 228 aria-label="Minimum price in dollars"
228 229 hx-get="/discover/results"
229 230 hx-trigger="change delay:500ms"
230 - hx-target="#results-container"
231 + hx-target="#results-container" hx-swap="outerHTML"
231 232 hx-indicator="#search-spinner"
232 233 hx-include=".discover-filter">
233 234 <span aria-hidden="true">to</span>
@@ -238,7 +239,7 @@
238 239 aria-label="Maximum price in dollars"
239 240 hx-get="/discover/results"
240 241 hx-trigger="change delay:500ms"
241 - hx-target="#results-container"
242 + hx-target="#results-container" hx-swap="outerHTML"
242 243 hx-indicator="#search-spinner"
243 244 hx-include=".discover-filter">
244 245 </div>
@@ -272,7 +273,7 @@
272 273 class="discover-filter"
273 274 {% if af.active %}checked{% endif %}
274 275 hx-get="/discover/results"
275 - hx-target="#results-container"
276 + hx-target="#results-container" hx-swap="outerHTML"
276 277 hx-indicator="#search-spinner"
277 278 hx-include=".discover-filter"
278 279 hx-trigger="change">
@@ -860,10 +860,6 @@
860 860 "tag-chip-remove",
861 861 "tag-chip-clear",
862 862 "tag-combobox",
863 - "tag-combobox-input",
864 - "tag-suggest-list",
865 - "tag-suggest-item",
866 - "tag-suggest-label",
867 863 "tag-crumbs",
868 864 "tag-drill-select",
869 865 "tag-drill-into",
@@ -1142,9 +1138,16 @@
1142 1138 resp.text.contains(r#"id="aitier-"#),
1143 1139 "ai tier radios need ids"
1144 1140 );
1141 + // The typeahead box is described (N8), and it is addressed by the field's
1142 + // own name, so focus restoration still has an id to find it back by.
1143 + //
1144 + // What it no longer carries is `hx-preserve`: nothing in the vocabulary
1145 + // says "this control's value is the reader's, keep it through a swap", so a
1146 + // sidebar swap while a tag name is half-typed now clears the box. Filed
1147 + // against quasicoherent rather than kept by leaving the box hand-written.
1145 1148 assert!(
1146 - resp.text.contains(r#"hx-preserve="true""#),
1147 - "the typeahead input holds unrendered user state and must be preserved"
1149 + resp.text.contains(r#"id="tag-search""#),
1150 + "the typeahead box needs an id for focus restoration"
1148 1151 );
1149 1152 }
1150 1153
@@ -1213,10 +1216,17 @@
1213 1216 }
1214 1217
1215 1218 /// The typeahead finds a leaf from a partial name, at any depth.
1219 + ///
1220 + /// The typed value arrives under the described field's own name, which is what
1221 + /// a consult sends it under. `q` on this route is the search box's value riding
1222 + /// along as one of the filters a pick carries forward.
1216 1223 #[tokio::test]
1217 1224 async fn tag_typeahead_finds_a_leaf_by_partial_name() {
1218 1225 let mut h = TestHarness::new().await;
1219 - let resp = h.client.get("/discover/tag-suggest?q=electr").await;
1226 + let resp = h
1227 + .client
1228 + .get("/discover/tag-suggest?tag-search=electr")
1229 + .await;
1220 1230 assert_eq!(resp.status, 200, "{}", resp.status);
1221 1231 assert!(
1222 1232 resp.text.contains("audio.genre.electronic"),
@@ -1230,15 +1240,86 @@
1230 1240 async fn tag_typeahead_omits_unassignable_categories() {
1231 1241 let mut h = TestHarness::new().await;
1232 1242 // "audio" and "audio.genre" both prefix-match, but neither is assignable.
1233 - let resp = h.client.get("/discover/tag-suggest?q=audio.genre").await;
1243 + let resp = h
1244 + .client
1245 + .get("/discover/tag-suggest?tag-search=audio.genre")
1246 + .await;
1234 1247 assert_eq!(resp.status, 200, "{}", resp.status);
1248 + // Asked of what a pick would add, not of what a row reads: every row here
1249 + // draws `audio.genre` as its second line, which is the parent path doing
1250 + // its job rather than a category being offered.
1235 1251 assert!(
1236 - !resp.text.contains(r#""slug":"audio.genre""#),
1252 + !resp.text.contains("tag=audio.genre\"") && !resp.text.contains("tag=audio.genre&"),
1237 1253 "a depth-2 category must not be offered as a filter: {}",
1238 1254 resp.text
1239 1255 );
1240 1256 }
1241 1257
1258 + /// N8. The dropdown is the described suggestion list, so the route answers the
1259 + /// markup a renderer drew rather than JSON a hand-written renderer parsed.
1260 + #[tokio::test]
1261 + async fn the_typeahead_answers_the_described_list() {
1262 + let mut h = TestHarness::new().await;
1263 + let resp = h
1264 + .client
1265 + .get("/discover/tag-suggest?tag-search=electr")
1266 + .await;
1267 + assert_eq!(resp.status, 200, "{}", resp.status);
1268 + assert!(
1269 + resp.text.contains(r#"role="option""#),
1270 + "rows are options in the list the field owns: {}",
1271 + resp.text
1272 + );
1273 + // The parent path is the second line, which is what tells four rows reading
1274 + // "Format" apart.
1275 + assert!(
1276 + resp.text.contains("form-suggestion-detail"),
1277 + "a candidate carries its second line: {}",
1278 + resp.text
1279 + );
1280 + }
1281 +
1282 + /// Picking a tag is the call the drill-down checkbox makes, under the filters
1283 + /// the box sent with the question. Before N8 this was a `choose()` in
1284 + /// `page-discover.js` that built a hidden input by hand.
1285 + #[tokio::test]
1286 + async fn a_suggested_tag_carries_the_filters_it_is_being_added_to() {
1287 + let mut h = TestHarness::new().await;
1288 + let resp = h
1289 + .client
1290 + .get("/discover/tag-suggest?tag-search=electr&mode=items&sort=newest")
1291 + .await;
1292 + assert_eq!(resp.status, 200, "{}", resp.status);
1293 + assert!(
1294 + resp.text.contains("hx-get=\"/discover/results"),
1295 + "a pick calls the results route: {}",
1296 + resp.text
1297 + );
1298 + assert!(
1299 + resp.text.contains("mode=items") && resp.text.contains("sort=newest"),
1300 + "and carries the screen it was offered under: {}",
1301 + resp.text
1302 + );
1303 + assert!(
1304 + resp.text.contains("tag=audio.genre.electronic"),
1305 + "and the tag it adds: {}",
1306 + resp.text
1307 + );
1308 + }
1309 +
1310 + /// Nothing but the filters travels. A caller appending its own parameters to
1311 + /// the question does not get them echoed into every candidate's address.
1312 + #[tokio::test]
1313 + async fn the_typeahead_carries_only_the_filters_it_says_it_does() {
1314 + let mut h = TestHarness::new().await;
1315 + let resp = h
1316 + .client
1317 + .get("/discover/tag-suggest?tag-search=electr&smuggled=yes")
1318 + .await;
1319 + assert_eq!(resp.status, 200, "{}", resp.status);
1320 + assert!(!resp.text.contains("smuggled"), "{}", resp.text);
1321 + }
1322 +
1242 1323 // Faceted multi-select: OR within a facet, AND across facets.
1243 1324
1244 1325 /// Repeated `tag=` params are OR'd, and each still matches its own subtree.
@@ -272,8 +272,12 @@
272 272 resp.text.contains(r#"role="combobox""#),
273 273 "the tag typeahead input should declare combobox"
274 274 );
275 + // Derived from the field's name rather than authored, which is what the
276 + // field owning its list buys: the box's id is the name and the list's is
277 + // that plus a suffix, so the two cannot come apart. (N8.)
275 278 assert!(
276 - resp.text.contains(r#"aria-controls="tag-suggest-list""#),
279 + resp.text
280 + .contains(r#"aria-controls="tag-search-suggestions""#),
277 281 "the combobox must point at the listbox it controls"
278 282 );
279 283 assert_eq!(