Skip to main content

max / makenotwork

Rebuild the discover sidebar around a tag spine Tags become the structure of the sidebar rather than one facet among five, and everything else demotes to a refine block beneath them. Spine: - Tag typeahead over tagtree's TagIndex, cached five minutes and rebuilt wholesale rather than mutated (TagIndex::remove leaves orphaned segments behind, which would degrade the segment-prefix gate over time). Prefix matching first, fuzzy only when prefix underfills. Depth-3+ only, since a category cannot be assigned to an item and so cannot filter. - Selected tags render as removable chips, which also serve as the active-filter summary and clear-all. Each dismiss is a plain link carrying the remaining selection, so it works without JS. - Drill-down with subtree counts. Items carry only depth-3+ leaves, so counting direct assignments would show 0 against every category; the count is a correlated subquery so the visibility predicate and the cross-facet filters stay plain inner joins on i/p/u. Empty rungs render a zero rather than disappearing. `browse` moves the cursor, `tag` selects, and the two are independent. Refine: - Type becomes checkboxes, AI tier radios (its three tiers are nested ranges, so OR-ing any two only widens back to the looser one), price buckets links carrying their own range. Bucket bounds mirror the FILTER clauses in get_price_range_counts and must move together. - Zero-count types render disabled rather than vanishing, so the shape of the catalog stays legible as filters change. Fixes a live bug in passing: the price number inputs rendered no value, so hx-include resent them blank on the next request and the filter silently disappeared the moment any other control was touched. Every item-mode control is now a real form input. That is what makes multi-select expressible, and it makes the sidebar keyboard-operable without the hx-trigger workaround the .filter-btn elements needed. The typeahead's listbox now sets aria-activedescendant, so the role it declares is one it actually honours. Hidden inputs are rendered only for selections with no visible control (the drill-down shows one rung, so a tag chosen elsewhere has no checkbox on screen); anything with a checked box is omitted or it would submit twice.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 17:49 UTC
Signed with PGP, not checked
Commit: 0f776315a2d08e590013330f525b3f05ebb31431
Parent: edfc01a
10 files changed, +995 insertions, -153 deletions
@@ -1,28 +1,120 @@
1 - // Replace the hidden inputs carrying a multi-valued facet.
2 - //
3 - // These facets are one input per selected value rather than one delimited
4 - // input, so the form can express a multi-select without a delimiter
5 - // convention that would break on a value containing the delimiter. The
6 - // visible controls are still single-select, so `values` currently holds at
7 - // most one entry; the DOM shape is what lets the server-side multi-select
8 - // round-trip, and what the checkbox UI will write into directly.
9 - function setFacetValues(facet, values) {
10 - var form = document.getElementById('discover-form');
11 - if (!form) return;
12 - form.querySelectorAll('input[data-facet="' + facet + '"]').forEach(function(el) {
13 - el.remove();
1 + // Tag typeahead. Backed by tagtree's TagIndex server-side, so a tag is
2 + // reachable by name from any depth without knowing where it sits.
3 + (function() {
4 + var input = document.getElementById('tag-search');
5 + var list = document.getElementById('tag-suggest-list');
6 + if (!input || !list) return;
7 + var timer = null;
8 + var active = -1;
9 +
10 + function close() {
11 + list.hidden = true;
12 + list.innerHTML = '';
13 + input.setAttribute('aria-expanded', 'false');
14 + input.removeAttribute('aria-activedescendant');
15 + active = -1;
16 + }
17 +
18 + // Focus stays in the input for a combobox; aria-activedescendant is what
19 + // tells assistive tech which option is current. Without it the listbox
20 + // role is a claim the widget does not honour.
21 + function setActive(items, index) {
22 + active = index;
23 + items.forEach(function(el, i) {
24 + var on = i === index;
25 + el.classList.toggle('is-active', on);
26 + el.setAttribute('aria-selected', on ? 'true' : 'false');
27 + });
28 + if (index >= 0 && items[index]) {
29 + input.setAttribute('aria-activedescendant', items[index].id);
30 + } else {
31 + input.removeAttribute('aria-activedescendant');
32 + }
33 + }
34 +
35 + function choose(slug) {
36 + // Adding a tag is the same operation the drill-down checkbox performs,
37 + // so route it through the form rather than a bespoke request.
38 + var form = document.getElementById('discover-form');
39 + if (!form) return;
40 + var existing = form.querySelector('input[data-facet="tag"][value="' + CSS.escape(slug) + '"]');
41 + if (!existing) {
42 + var el = document.createElement('input');
43 + el.type = 'hidden';
44 + el.name = 'tag';
45 + el.value = slug;
46 + el.className = 'discover-filter';
47 + el.dataset.facet = 'tag';
48 + form.appendChild(el);
49 + }
50 + input.value = '';
51 + close();
52 + htmx.trigger(form, 'tag-added');
53 + }
54 +
55 + function render(items) {
56 + if (!items.length) { close(); return; }
57 + list.innerHTML = '';
58 + items.forEach(function(item, i) {
59 + var li = document.createElement('li');
60 + li.id = 'tag-suggest-option-' + i;
61 + li.setAttribute('role', 'option');
62 + li.setAttribute('aria-selected', i === active ? 'true' : 'false');
63 + li.className = 'tag-suggest-item' + (i === active ? ' is-active' : '');
64 + li.dataset.slug = item.slug;
65 + var label = document.createElement('span');
66 + label.className = 'tag-suggest-label';
67 + label.textContent = item.label;
68 + li.appendChild(label);
69 + if (item.context) {
70 + var ctx = document.createElement('span');
71 + ctx.className = 'tag-suggest-context';
72 + ctx.textContent = item.context;
73 + li.appendChild(ctx);
74 + }
75 + li.addEventListener('mousedown', function(e) {
76 + e.preventDefault();
77 + choose(item.slug);
78 + });
79 + list.appendChild(li);
80 + });
81 + list.hidden = false;
82 + input.setAttribute('aria-expanded', 'true');
83 + }
84 +
85 + input.addEventListener('input', function() {
86 + var q = input.value.trim();
87 + clearTimeout(timer);
88 + if (q.length < 2) { close(); return; }
89 + timer = setTimeout(function() {
90 + fetch('/discover/tag-suggest?q=' + encodeURIComponent(q))
91 + .then(function(r) { return r.ok ? r.json() : []; })
92 + .then(function(items) { active = -1; render(items); })
93 + .catch(function() { close(); });
94 + }, 150);
14 95 });
15 - values.forEach(function(value) {
16 - if (!value) return;
17 - var input = document.createElement('input');
18 - input.type = 'hidden';
19 - input.name = facet;
20 - input.value = value;
21 - input.className = 'discover-filter';
22 - input.dataset.facet = facet;
23 - form.appendChild(input);
96 +
97 + input.addEventListener('keydown', function(e) {
98 + var items = list.querySelectorAll('.tag-suggest-item');
99 + if (e.key === 'Escape') { close(); return; }
100 + if (!items.length) return;
101 + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
102 + e.preventDefault();
103 + var next = active + (e.key === 'ArrowDown' ? 1 : -1);
104 + if (next < 0) next = items.length - 1;
105 + if (next >= items.length) next = 0;
106 + setActive(items, next);
107 + } else if (e.key === 'Enter') {
108 + e.preventDefault();
109 + var pick = active >= 0 ? items[active] : items[0];
110 + if (pick) choose(pick.dataset.slug);
111 + }
24 112 });
25 - }
113 +
114 + document.addEventListener('click', function(e) {
115 + if (!list.contains(e.target) && e.target !== input) close();
116 + });
117 + })();
26 118
27 119 // Sync filter UI state on filter click: update active highlight and write
28 120 // the selected filter value into the hidden form inputs so subsequent
@@ -44,21 +136,13 @@
44 136 evt.detail.elt.closest('.filter-item').classList.add('is-selected');
45 137 evt.detail.elt.setAttribute('aria-pressed', 'true');
46 138
139 + // Only the projects-mode category facet still uses .filter-btn.
140 + // Type, tag, and AI tier are real checkboxes and radios now, so
141 + // they carry their own state and need no mirroring.
47 142 var hxVals = JSON.parse(evt.detail.elt.getAttribute('hx-vals') || '{}');
48 - if ('item_type' in hxVals) {
49 - setFacetValues('item_type', hxVals.item_type ? [hxVals.item_type] : []);
50 - }
51 - if ('tag' in hxVals) {
52 - setFacetValues('tag', hxVals.tag ? [hxVals.tag] : []);
53 - }
54 143 if ('category' in hxVals) {
55 - document.getElementById('category-input').value = hxVals.category || '';
56 - }
57 - if ('label' in hxVals) {
58 - document.getElementById('label-input').value = hxVals.label || '';
59 - }
60 - if ('ai_tier' in hxVals) {
61 - document.getElementById('ai-tier-input').value = hxVals.ai_tier || '';
144 + var categoryInput = document.getElementById('category-input');
145 + if (categoryInput) categoryInput.value = hxVals.category || '';
62 146 }
63 147 }
64 148 });
@@ -205,6 +205,123 @@
205 205 Ok(counts)
206 206 }
207 207
208 + /// Every tag slug, for building a [`tagtree::TagIndex`].
209 + ///
210 + /// Ordering is irrelevant to the index (it sorts internally) but is stable here
211 + /// so the query plan and any logging stay predictable.
212 + #[tracing::instrument(skip_all)]
213 + pub async fn all_tag_slugs(pool: &PgPool) -> Result<Vec<String>> {
214 + let slugs = sqlx::query_scalar::<_, String>("SELECT slug FROM tags ORDER BY slug")
215 + .fetch_all(pool)
216 + .await?;
217 + Ok(slugs)
218 + }
219 +
220 + /// Display names for a set of slugs, for rendering selected-tag chips.
221 + #[tracing::instrument(skip_all)]
222 + pub async fn tag_names_for_slugs(pool: &PgPool, slugs: &[String]) -> Result<Vec<(String, String)>> {
223 + if slugs.is_empty() {
224 + return Ok(Vec::new());
225 + }
226 + let rows = sqlx::query_as::<_, (String, String)>(
227 + "SELECT slug, name FROM tags WHERE slug = ANY($1)",
228 + )
229 + .bind(slugs)
230 + .fetch_all(pool)
231 + .await?;
232 + Ok(rows)
233 + }
234 +
235 + /// The immediate children of `prefix` (or the depth-1 roots when `None`), each
236 + /// with the number of matching items anywhere in its subtree.
237 + ///
238 + /// This is the drill-down rung of the discover sidebar. The subtree rollup is
239 + /// what makes it useful: items carry only depth-3+ leaves, so counting direct
240 + /// assignments would show 0 against every category.
241 + ///
242 + /// Every filter *except* the tag facet applies, for the same reason the other
243 + /// facet counts exclude their own axis (see [`super::discover::FacetAxis`]):
244 + /// these counts answer "what would I get if I drilled in here".
245 + #[tracing::instrument(skip_all)]
246 + pub async fn tag_children_with_counts(
247 + pool: &PgPool,
248 + prefix: Option<&str>,
249 + filters: &super::discover::DiscoverFilters<'_>,
250 + ) -> Result<Vec<DbTagChild>> {
251 + use super::discover::{FacetAxis, append_facet_filters, is_short_query, normalize_search};
252 +
253 + let search_term = normalize_search(filters.search);
254 + let has_search = search_term.is_some();
255 + let short_query = search_term.as_deref().is_some_and(is_short_query);
256 +
257 + // The count is a correlated scalar subquery rather than a LEFT JOIN + GROUP BY
258 + // so the visibility predicate and the cross-facet filters can be plain inner
259 + // joins referencing i/p/u, exactly as every other discover query writes them.
260 + // A category with no matching items still returns a row, with 0.
261 + let mut count_subquery = String::from(
262 + r#"(SELECT COUNT(DISTINCT i.id)
263 + FROM item_tags it
264 + JOIN tags d ON d.id = it.tag_id
265 + JOIN items i ON i.id = it.item_id
266 + JOIN projects p ON i.project_id = p.id
267 + JOIN users u ON p.user_id = u.id
268 + WHERE (d.id = c.id OR d.path LIKE c.path || '.%')"#,
269 + );
270 + count_subquery.push_str(&super::discover::ITEM_VISIBILITY_WHERE.replace(" WHERE ", " AND "));
271 + append_facet_filters(
272 + &mut count_subquery,
273 + filters,
274 + FacetAxis::Tag,
275 + has_search,
276 + short_query,
277 + );
278 + count_subquery.push(')');
279 +
280 + // $7 is the browse cursor. `c.parent_id IS NULL` selects the type roots.
281 + let mut query = format!(
282 + r#"
283 + SELECT
284 + c.name AS tag_name,
285 + c.slug AS tag_slug,
286 + {count_subquery} AS count,
287 + (array_length(string_to_array(c.slug, '.'), 1) >= 3) AS assignable,
288 + EXISTS (SELECT 1 FROM tags k WHERE k.parent_id = c.id) AS has_children
289 + FROM tags c
290 + "#
291 + );
292 + query.push_str(if prefix.is_some() {
293 + " WHERE c.parent_id = (SELECT id FROM tags WHERE slug = $7)"
294 + } else {
295 + " WHERE c.parent_id IS NULL"
296 + });
297 + query.push_str(" ORDER BY c.sort_order, c.name");
298 +
299 + let rows = sqlx::query_as::<_, DbTagChild>(&query)
300 + .bind(search_term.as_deref().unwrap_or(""))
301 + .bind(
302 + filters
303 + .item_types
304 + .iter()
305 + .map(|t| t.to_string())
306 + .collect::<Vec<_>>(),
307 + )
308 + .bind(filters.min_price.unwrap_or(0))
309 + .bind(filters.max_price.unwrap_or(i32::MAX))
310 + .bind(filters.tags.to_vec())
311 + .bind(
312 + filters
313 + .tags
314 + .iter()
315 + .map(|s| tagtree::like_descendant_pattern(s))
316 + .collect::<Vec<_>>(),
317 + )
318 + .bind(prefix.unwrap_or(""))
319 + .fetch_all(pool)
320 + .await?;
321 +
322 + Ok(rows)
323 + }
324 +
208 325 /// Get a tag by its URL slug (dot-notation, e.g. `audio.genre.electronic`).
209 326 ///
210 327 /// Takes `&str` rather than `&Slug` because tag slugs use dot-notation
@@ -59,6 +59,38 @@
59 59 pub child_count: usize,
60 60 }
61 61
62 + /// A selected tag, rendered as a removable chip above the sidebar's drill-down.
63 + ///
64 + /// The chip row doubles as the active-filter summary and the clear-all
65 + /// affordance, neither of which the pre-multi-select sidebar had.
66 + pub struct TagChip {
67 + pub slug: String,
68 + /// Human-readable name (`tags.name`), falling back to the de-slugified leaf.
69 + pub label: String,
70 + /// The parent path, to disambiguate leaves that share a name across types.
71 + pub context: String,
72 + /// The `?tag=` list with this chip removed, for its dismiss control.
73 + pub remove_query: String,
74 + }
75 +
76 + /// One rung of the tag drill-down.
77 + pub struct TagDrillRow {
78 + pub slug: String,
79 + pub label: String,
80 + pub count: u32,
81 + /// Depth >= 3, so selecting it filters. Shallower rows only navigate.
82 + pub assignable: bool,
83 + pub has_children: bool,
84 + /// Already in the active selection, so the control renders as selected.
85 + pub selected: bool,
86 + }
87 +
88 + /// A rung of the drill-down's breadcrumb trail, for navigating back up.
89 + pub struct TagCrumb {
90 + pub slug: String,
91 + pub label: String,
92 + }
93 +
62 94 /// A breadcrumb in the tag tree hierarchy.
63 95 #[derive(Clone)]
64 96 pub struct TagBreadcrumb {
@@ -68,6 +100,20 @@
68 100
69 101 /// Price filter option
70 102 #[derive(Clone)]
103 + /// A price bucket in the refine block.
104 + ///
105 + /// Buckets are navigation, not form state: each renders as a link carrying the
106 + /// `min_price`/`max_price` it represents, so they work without JS and cannot
107 + /// disagree with the number inputs, which stay the source of truth.
108 + pub struct PriceBucket {
109 + pub label: String,
110 + pub count: u32,
111 + /// `?min_price=&max_price=` pair for this bucket, with the rest of the
112 + /// filter selection preserved.
113 + pub url: String,
114 + pub active: bool,
115 + }
116 +
71 117 pub struct PriceFilter {
72 118 pub label: String,
73 119 pub count: u32,
@@ -45,107 +45,195 @@
45 45 </div>
46 46 {% endif %}
47 47 {% if mode == "items" %}
48 - <div class="filter-section">
49 - <div class="filter-title" id="type-label">Type</div>
50 - <ul class="filter-list" aria-labelledby="type-label">
51 - {% for tf in type_filters %}
52 - <li class="filter-item{% if tf.active %} is-selected{% endif %}">
53 - <button type="button" class="filter-btn"
54 - aria-pressed="{% if tf.active %}true{% else %}false{% endif %}"
55 - hx-get="/discover/results"
56 - hx-target="#results-container"
57 - hx-indicator="#search-spinner"
58 - hx-include=".discover-filter"
59 - hx-vals='{"item_type": "{{ tf.value }}"}'>
60 - <span>{{ tf.name }}</span> <span class="count" aria-label="{{ tf.count }} items">{{ tf.count }}</span>
61 - </button>
48 + {# ---- Tag spine -------------------------------------------------
49 + Tags are the structure of this sidebar, not one facet among
50 + several: a typeahead to reach any tag at any depth, chips for
51 + what is selected, and a drill-down to browse by hand.
52 + Everything below the spine refines it. ---- #}
53 + <div class="filter-section tag-spine">
54 + <div class="filter-title" id="tag-label">Tags</div>
55 +
56 + {# Selected tags. Also the active-filter summary and clear-all.
57 + Each dismiss is a plain link carrying the remaining selection,
58 + so removal works with JS off. #}
59 + {% if !tag_chips.is_empty() %}
60 + <ul class="tag-chips" aria-label="Selected tags">
61 + {% for chip in tag_chips %}
62 + <li class="tag-chip">
63 + <span class="tag-chip-label" {% if !chip.context.is_empty() %}title="{{ chip.context }}"{% endif %}>{{ chip.label }}</span>
64 + <a class="tag-chip-remove"
65 + href="/discover?mode=items{% if !chip.remove_query.is_empty() %}&{{ chip.remove_query|safe }}{% endif %}"
66 + aria-label="Remove tag {{ chip.label }}">&times;</a>
62 67 </li>
63 68 {% endfor %}
64 - </ul>
65 - </div>
66 -
67 - <div class="filter-section">
68 - <div class="filter-title" id="tag-label">Tags <a href="/discover/tags" class="filter-browse-link">Browse all</a></div>
69 - <ul class="filter-list" aria-labelledby="tag-label">
70 - {% for tg in tag_filters %}
71 - <li class="filter-item{% if tg.active %} is-selected{% endif %}">
72 - <button type="button" class="filter-btn"
73 - aria-pressed="{% if tg.active %}true{% else %}false{% endif %}"
74 - hx-get="/discover/results"
75 - hx-target="#results-container"
76 - hx-indicator="#search-spinner"
77 - hx-include=".discover-filter"
78 - hx-vals='{"tag": "{{ tg.value }}"}'>
79 - <span>{{ tg.name }}</span>
80 - <span class="count" aria-label="{{ tg.count }} items">{{ tg.count }}</span>
81 - </button>
82 - {# Follow is a sibling of the filter, not a child: an interactive
83 - control nested inside another button is invalid and unreachable. #}
84 - {% if session_user.is_some() && !tg.id.is_empty() %}
85 - {% if tg.following %}
86 - <button class="tag-follow-btn is-selected" hx-delete="/api/follow/tag/{{ tg.id }}" hx-swap="outerHTML" title="Unfollow" data-action="noop" data-stop>Following</button>
87 - {% else %}
88 - <button class="tag-follow-btn" hx-post="/api/follow/tag/{{ tg.id }}" hx-swap="outerHTML" title="Follow" data-action="noop" data-stop>Follow</button>
89 - {% endif %}
90 - {% endif %}
69 + <li class="tag-chip tag-chip-clear">
70 + <a href="/discover?mode=items">Clear all</a>
91 71 </li>
92 - {% endfor %}
93 72 </ul>
94 - </div>
73 + {% endif %}
95 74
96 - <div class="filter-section">
97 - <div class="filter-title" id="price-label">Price</div>
98 - <div class="price-inputs" role="group" aria-labelledby="price-label">
99 - <label for="min-price" class="sr-only">Minimum price</label>
100 - <input type="number" id="min-price" name="min_price" placeholder="0" min="0"
101 - class="discover-filter"
102 - aria-label="Minimum price"
103 - hx-get="/discover/results"
104 - hx-trigger="change delay:500ms"
105 - hx-target="#results-container"
106 - hx-indicator="#search-spinner"
107 - hx-include=".discover-filter">
108 - <span aria-hidden="true">to</span>
109 - <label for="max-price" class="sr-only">Maximum price</label>
110 - <input type="number" id="max-price" name="max_price" placeholder="Any"
111 - class="discover-filter"
112 - aria-label="Maximum price"
113 - hx-get="/discover/results"
114 - hx-trigger="change delay:500ms"
115 - hx-target="#results-container"
116 - hx-indicator="#search-spinner"
117 - hx-include=".discover-filter">
75 + {# Typeahead. Reaches a tag at any depth by name, which is what
76 + makes a taxonomy of arbitrary size navigable and what the old
77 + capped ten-tag list plus "Browse all" could not do. #}
78 + <div class="tag-combobox">
79 + <label class="visually-hidden" for="tag-search">Find a tag</label>
80 + <input type="search" id="tag-search" class="tag-combobox-input"
81 + placeholder="Find a tag" autocomplete="off"
82 + role="combobox" aria-expanded="false"
83 + aria-controls="tag-suggest-list" aria-autocomplete="list">
84 + <ul id="tag-suggest-list" class="tag-suggest-list" role="listbox" hidden></ul>
118 85 </div>
119 - <ul class="filter-list price-distribution" role="list" aria-label="Price distribution">
120 - {% for pf in price_filters %}
121 - <li class="price-distribution-item">
122 - {{ pf.label }}: {{ pf.count }}
123 - </li>
124 - {% endfor %}
125 - </ul>
86 +
87 + {# Drill-down. `browse` moves the cursor, `tag` selects; a
88 + category navigates, a depth-3+ leaf filters. #}
89 + <nav class="tag-drill" aria-labelledby="tag-label">
90 + {% if !tag_crumbs.is_empty() %}
91 + <ol class="tag-crumbs">
92 + <li><a href="{{ browse_url_root }}">All</a></li>
93 + {% for crumb in tag_crumbs %}
94 + <li><a href="{{ browse_url_prefix }}{{ crumb.slug }}">{{ crumb.label }}</a></li>
95 + {% endfor %}
96 + </ol>
97 + {% endif %}
98 + <ul class="filter-list">
99 + {% for row in tag_drill %}
100 + <li class="filter-item{% if row.selected %} is-selected{% endif %}">
101 + {% if row.assignable %}
102 + {# A real checkbox: multi-select is the point, and it
103 + is keyboard-operable without any hx-trigger work. #}
104 + <label class="tag-drill-select">
105 + <input type="checkbox" name="tag" value="{{ row.slug }}"
106 + class="discover-filter" data-facet="tag"
107 + {% if row.selected %}checked{% endif %}
108 + hx-get="/discover/results"
109 + hx-target="#results-container"
110 + hx-indicator="#search-spinner"
111 + hx-include=".discover-filter"
112 + hx-trigger="change">
113 + <span>{{ row.label }}</span>
114 + <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span>
115 + </label>
116 + {% endif %}
117 + {% if row.has_children %}
118 + <a class="tag-drill-into" href="{{ browse_url_prefix }}{{ row.slug }}"
119 + aria-label="Browse inside {{ row.label }}">
120 + {% if !row.assignable %}<span>{{ row.label }}</span>
121 + <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span>{% endif %}
122 + <span class="tag-drill-chevron" aria-hidden="true">&rsaquo;</span>
123 + </a>
124 + {% endif %}
125 + </li>
126 + {% endfor %}
127 + </ul>
128 + </nav>
126 129 </div>
127 130
128 - {% if !ai_tier_filters.is_empty() %}
129 - <div class="filter-section">
130 - <div class="filter-title" id="ai-tier-label">AI Disclosure</div>
131 - <ul class="filter-list" aria-labelledby="ai-tier-label">
132 - {% for af in ai_tier_filters %}
133 - <li class="filter-item{% if af.active %} is-selected{% endif %}">
134 - <button type="button" class="filter-btn"
135 - aria-pressed="{% if af.active %}true{% else %}false{% endif %}"
136 - hx-get="/discover/results"
137 - hx-target="#results-container"
138 - hx-indicator="#search-spinner"
139 - hx-include=".discover-filter"
140 - hx-vals='{"ai_tier": "{{ af.value }}"}'>
141 - <span>{{ af.name }}</span> <span class="count" aria-label="{{ af.count }} items">{{ af.count }}</span>
142 - </button>
143 - </li>
144 - {% endfor %}
145 - </ul>
131 + {# ---- Refine ----------------------------------------------------
132 + Everything below the spine narrows what the tags selected. Same
133 + multi-select treatment, deliberately subordinate. Every control
134 + here is a real form input: multi-select needs one, and it makes
135 + the whole sidebar keyboard-operable without any hx-trigger work
136 + (the .filter-btn elements these replace were not). ---- #}
137 + <div class="filter-refine">
138 + <div class="filter-section">
139 + <fieldset class="filter-fieldset">
140 + <legend class="filter-title">Type</legend>
141 + <ul class="filter-list">
142 + {% for tf in type_filters %}
143 + {% if !tf.value.is_empty() %}
144 + <li class="filter-item{% if tf.active %} is-selected{% endif %}">
145 + <label class="filter-check">
146 + <input type="checkbox" name="item_type" value="{{ tf.value }}"
147 + class="discover-filter" data-facet="item_type"
148 + {% if tf.active %}checked{% endif %}
149 + {% if tf.count == 0 && !tf.active %}disabled{% endif %}
150 + hx-get="/discover/results"
151 + hx-target="#results-container"
152 + hx-indicator="#search-spinner"
153 + hx-include=".discover-filter"
154 + hx-trigger="change">
155 + <span>{{ tf.name }}</span>
156 + <span class="count" aria-label="{{ tf.count }} items">{{ tf.count }}</span>
157 + </label>
158 + </li>
159 + {% endif %}
160 + {% endfor %}
161 + </ul>
162 + </fieldset>
163 + </div>
164 +
165 + <div class="filter-section">
166 + <div class="filter-title" id="price-label">Price</div>
167 + <div class="price-inputs" role="group" aria-labelledby="price-label">
168 + <label for="min-price" class="sr-only">Minimum price</label>
169 + {# The applied bound is rendered back in: hx-include resends
170 + these on every request, so a blank input would silently
171 + clear the filter on the next interaction. #}
172 + <input type="number" id="min-price" name="min_price" placeholder="0" min="0"
173 + value="{{ current_min_price }}"
174 + class="discover-filter"
175 + aria-label="Minimum price"
176 + hx-get="/discover/results"
177 + hx-trigger="change delay:500ms"
178 + hx-target="#results-container"
179 + hx-indicator="#search-spinner"
180 + hx-include=".discover-filter">
181 + <span aria-hidden="true">to</span>
182 + <label for="max-price" class="sr-only">Maximum price</label>
183 + <input type="number" id="max-price" name="max_price" placeholder="Any"
184 + value="{{ current_max_price }}"
185 + class="discover-filter"
186 + aria-label="Maximum price"
187 + hx-get="/discover/results"
188 + hx-trigger="change delay:500ms"
189 + hx-target="#results-container"
190 + hx-indicator="#search-spinner"
191 + hx-include=".discover-filter">
192 + </div>
193 + {# Buckets are links, not form state, so they cannot disagree
194 + with the number inputs above and they work without JS. #}
195 + <ul class="filter-list price-buckets" aria-label="Price ranges">
196 + {% for pb in price_buckets %}
197 + <li class="filter-item{% if pb.active %} is-selected{% endif %}">
198 + <a class="price-bucket" href="{{ pb.url }}"
199 + {% if pb.active %}aria-current="true"{% endif %}>
200 + <span>{{ pb.label }}</span>
201 + <span class="count" aria-label="{{ pb.count }} items">{{ pb.count }}</span>
202 + </a>
203 + </li>
204 + {% endfor %}
205 + </ul>
206 + </div>
207 +
208 + {% if !ai_tier_filters.is_empty() %}
209 + <div class="filter-section">
210 + <fieldset class="filter-fieldset">
211 + {# Radios, not checkboxes: the three tiers are nested
212 + ranges, so OR-ing any two only widens to the looser. #}
213 + <legend class="filter-title">AI Disclosure</legend>
214 + <ul class="filter-list">
215 + {% for af in ai_tier_filters %}
216 + <li class="filter-item{% if af.active %} is-selected{% endif %}">
217 + <label class="filter-check">
218 + <input type="radio" name="ai_tier" value="{{ af.value }}"
219 + class="discover-filter"
220 + {% if af.active %}checked{% endif %}
221 + hx-get="/discover/results"
222 + hx-target="#results-container"
223 + hx-indicator="#search-spinner"
224 + hx-include=".discover-filter"
225 + hx-trigger="change">
226 + <span>{{ af.name }}</span>
227 + <span class="count" aria-label="{{ af.count }} items">{{ af.count }}</span>
228 + </label>
229 + </li>
230 + {% endfor %}
231 + </ul>
232 + </fieldset>
233 + </div>
234 + {% endif %}
146 235 </div>
147 236 {% endif %}
148 - {% endif %}
149 237 </aside>
150 238
151 239 <button type="button" class="discover-filter-toggle" data-action="toggleDiscoverFilters" aria-label="Toggle filters">
@@ -170,7 +258,15 @@
170 258 </div>
171 259 </div>
172 260
173 - <form id="discover-form" action="/discover" method="get">
261 + {# `tag-added` is fired by the typeahead once it appends a hidden
262 + input, so picking a suggestion refreshes results through the same
263 + path as every other filter control. #}
264 + <form id="discover-form" action="/discover" method="get"
265 + hx-get="/discover/results"
266 + hx-target="#results-container"
267 + hx-indicator="#search-spinner"
268 + hx-include=".discover-filter"
269 + hx-trigger="tag-added">
174 270 <input type="hidden" id="mode-input" name="mode" value="{{ mode }}" class="discover-filter">
175 271 <div class="table-controls">
176 272 <label for="search-input" class="sr-only">Search {% if mode == "projects" %}projects{% else %}items{% endif %}</label>
@@ -212,14 +308,14 @@
212 308 <option value="price_desc"{% if sort_by == "price_desc" %} selected{% endif %}>Price desc</option>
213 309 {% endif %}
214 310 </select>
215 - {# Multi-valued facets carry one hidden input per selection, so a
216 - selection of several tags round-trips through hx-include without
217 - inventing a delimiter. page-discover.js rewrites the whole group
218 - for a facet on each click (see setFacetValues). #}
219 - {% for value in current_types %}
311 + {# Only selections with no visible control on screen. The drill-down
312 + shows one rung at a time, so a tag chosen elsewhere in the tree
313 + has no checkbox to carry it. Anything that DOES have a checked
314 + checkbox is deliberately absent here, or it would submit twice. #}
315 + {% for value in hidden_types %}
220 316 <input type="hidden" name="item_type" value="{{ value }}" class="discover-filter" data-facet="item_type">
221 317 {% endfor %}
222 - {% for value in current_tags %}
318 + {% for value in hidden_tags %}
223 319 <input type="hidden" name="tag" value="{{ value }}" class="discover-filter" data-facet="tag">
224 320 {% endfor %}
225 321 <input type="hidden" id="category-input" name="category" value="{{ current_category }}" class="discover-filter">
@@ -414,6 +414,126 @@
414 414 );
415 415 }
416 416
417 + /// The price filter survives a second interaction.
418 + ///
419 + /// The number inputs carry `class="discover-filter"`, so `hx-include` resends
420 + /// them on every subsequent request. Without a rendered `value`, they come back
421 + /// empty, `empty_string_as_none` maps that to "unfiltered", and the price filter
422 + /// silently disappears the moment the user touches any other control.
423 + #[tokio::test]
424 + async fn price_filter_round_trips_into_its_inputs() {
425 + let mut h = TestHarness::new().await;
426 + make_discoverable_item(&mut h, "priceround", "Priced Thing", "audio").await;
427 +
428 + let resp = h.client.get("/discover?mode=items&min_price=2500&max_price=7500").await;
429 + assert!(resp.status.is_success(), "{}", resp.status);
430 + assert!(
431 + resp.text.contains(r#"id="min-price""#),
432 + "price inputs must render"
433 + );
434 + assert!(
435 + resp.text.contains(r#"value="2500""#),
436 + "min_price must round-trip into its input or hx-include drops it"
437 + );
438 + assert!(
439 + resp.text.contains(r#"value="7500""#),
440 + "max_price must round-trip into its input or hx-include drops it"
441 + );
442 + }
443 +
444 + // ---------------------------------------------------------------------------
445 + // Tag spine: drill-down and typeahead.
446 + // ---------------------------------------------------------------------------
447 +
448 + /// Drill-down counts roll up the whole subtree, not direct assignments.
449 + ///
450 + /// Items may only carry depth-3+ leaves, so a category's direct count is always
451 + /// zero. If the drill-down counted direct assignments every category would read
452 + /// 0 and the sidebar would be useless.
453 + #[tokio::test]
454 + async fn drill_down_counts_roll_up_the_subtree() {
455 + let mut h = TestHarness::new().await;
456 + let (_, item) = make_discoverable_item(&mut h, "drillroll", "Deep Item", "audio").await;
457 + tag_item(&h, &item, "audio.genre.electronic").await;
458 +
459 + // At the root, the `audio` type must report the leaf two levels below it.
460 + let roots = makenotwork::db::tags::tag_children_with_counts(&h.db, None, &filters_for(&[]))
461 + .await
462 + .expect("root drill rows");
463 + let audio = roots
464 + .iter()
465 + .find(|r| r.tag_slug == "audio")
466 + .expect("audio root present");
467 + assert_eq!(audio.count, 1, "root count must roll up the whole subtree");
468 + assert!(!audio.assignable, "depth-1 type roots are not assignable");
469 + assert!(audio.has_children, "audio has categories beneath it");
470 + }
471 +
472 + /// A category with no matching items is still listed, with a zero.
473 + ///
474 + /// Dropping empty rungs would make the taxonomy's shape flicker as filters
475 + /// change; showing a 0 tells the user the branch exists and is empty.
476 + #[tokio::test]
477 + async fn drill_down_keeps_empty_children_as_zero() {
478 + let h = TestHarness::new().await;
479 + // No items at all.
480 + let roots = makenotwork::db::tags::tag_children_with_counts(&h.db, None, &filters_for(&[]))
481 + .await
482 + .expect("root drill rows");
483 + assert!(!roots.is_empty(), "type roots must render even with an empty catalog");
484 + assert!(
485 + roots.iter().all(|r| r.count == 0),
486 + "an empty catalog means every rung reads zero, not missing"
487 + );
488 + }
489 +
490 + /// Drilling in returns the cursor's immediate children only.
491 + #[tokio::test]
492 + async fn drill_down_returns_immediate_children_of_the_cursor() {
493 + let h = TestHarness::new().await;
494 + let rows = makenotwork::db::tags::tag_children_with_counts(
495 + &h.db,
496 + Some("audio"),
497 + &filters_for(&[]),
498 + )
499 + .await
500 + .expect("audio children");
501 + assert!(!rows.is_empty(), "audio has categories");
502 + assert!(
503 + rows.iter().all(|r| r.tag_slug.starts_with("audio.")
504 + && r.tag_slug.matches('.').count() == 1),
505 + "only depth-2 children of audio, got {:?}",
506 + rows.iter().map(|r| &r.tag_slug).collect::<Vec<_>>()
507 + );
508 + }
509 +
510 + /// The typeahead finds a leaf from a partial name, at any depth.
511 + #[tokio::test]
512 + async fn tag_typeahead_finds_a_leaf_by_partial_name() {
513 + let mut h = TestHarness::new().await;
514 + let resp = h.client.get("/discover/tag-suggest?q=electr").await;
515 + assert!(resp.status.is_success(), "{}", resp.status);
516 + assert!(
517 + resp.text.contains("audio.genre.electronic"),
518 + "prefix search must reach a depth-3 leaf, got {}",
519 + resp.text
520 + );
521 + }
522 +
523 + /// The typeahead never offers a tag that cannot filter.
524 + #[tokio::test]
525 + async fn tag_typeahead_omits_unassignable_categories() {
526 + let mut h = TestHarness::new().await;
527 + // "audio" and "audio.genre" both prefix-match, but neither is assignable.
528 + let resp = h.client.get("/discover/tag-suggest?q=audio.genre").await;
529 + assert!(resp.status.is_success(), "{}", resp.status);
530 + assert!(
531 + !resp.text.contains(r#""slug":"audio.genre""#),
532 + "a depth-2 category must not be offered as a filter: {}",
533 + resp.text
534 + );
535 + }
536 +
417 537 // ---------------------------------------------------------------------------
418 538 // Faceted multi-select: OR within a facet, AND across facets.
419 539 // ---------------------------------------------------------------------------
@@ -181,24 +181,64 @@
181 181 }
182 182
183 183 #[tokio::test]
184 - async fn discover_filters_are_buttons_not_fake_options() {
184 + async fn discover_filters_are_real_form_controls() {
185 185 let mut h = TestHarness::new().await;
186 186
187 + // The type facet renders one control per type that has items, so an empty
188 + // catalog would render no checkboxes and the assertions below would pass
189 + // vacuously or fail confusingly. Seed one discoverable item first.
190 + let setup = h.create_creator_with_item("formctl", "audio", 1000).await;
191 + sqlx::query(
192 + "UPDATE items SET is_public = true, listed = true, scan_status = 'clean', \
193 + deleted_at = NULL WHERE id = $1::uuid",
194 + )
195 + .bind(&setup.item_id)
196 + .execute(&h.db)
197 + .await
198 + .expect("publish item");
199 + sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
200 + .bind(&setup.project_id)
201 + .execute(&h.db)
202 + .await
203 + .expect("publish project");
204 +
187 205 let resp = h.client.get("/discover?mode=items").await;
188 206 assert_eq!(resp.status, 200);
189 - // Real buttons activate on Enter/Space with no scripting. The old
190 - // li[role=option][tabindex=0] took focus but could not be activated at all.
207 +
208 + // The item-mode filters are now checkboxes and radios rather than buttons
209 + // carrying hx-vals. Native controls are what make multi-select expressible
210 + // at all, and they are keyboard-operable with no scripting: the earlier
211 + // li[role=option][tabindex=0] took focus but could not be activated, and
212 + // the .filter-btn that replaced it still needed JS to mirror its value into
213 + // a hidden input.
191 214 assert!(
192 - resp.text.contains("class=\"filter-btn\""),
193 - "Filters should render as real buttons"
215 + resp.text.contains(r#"type="checkbox" name="item_type""#),
216 + "type facet should be checkboxes (multi-select)"
194 217 );
195 218 assert!(
196 - !resp.text.contains("role=\"option\""),
197 - "Filters should not claim listbox semantics they don't implement"
219 + resp.text.contains(r#"type="radio" name="ai_tier""#),
220 + "AI tier should be radios: its three tiers are nested ranges, not independent values"
221 + );
222 +
223 + // The one listbox on the page is the tag typeahead, and it must declare the
224 + // wiring it actually implements. This assertion previously banned listbox
225 + // roles outright, because the only one on the page was a fake.
226 + assert!(
227 + resp.text.contains(r#"role="combobox""#),
228 + "the tag typeahead input should declare combobox"
198 229 );
199 230 assert!(
200 - !resp.text.contains("role=\"listbox\""),
201 - "Filter lists should not claim listbox semantics they don't implement"
231 + resp.text.contains(r#"aria-controls="tag-suggest-list""#),
232 + "the combobox must point at the listbox it controls"
233 + );
234 + assert_eq!(
235 + resp.text.matches(r#"role="listbox""#).count(),
236 + 1,
237 + "the typeahead should be the only listbox; filter lists must not claim the role"
238 + );
239 + assert!(
240 + !resp.text.contains(r#"role="option""#),
241 + "options are rendered by the typeahead at runtime, never server-side"
202 242 );
203 243 }
204 244