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
Commit: 0f776315a2d08e590013330f525b3f05ebb31431
Parent: edfc01a
10 files changed, +995 insertions, -153 deletions
@@ -65,3 +65,23 @@ pub struct DbTagCount {
65 65 pub tag_slug: String,
66 66 pub count: i64,
67 67 }
68 +
69 + /// One rung of the discover sidebar's tag drill-down: an immediate child of the
70 + /// browse cursor, with the number of matching items anywhere in its subtree.
71 + ///
72 + /// `count` is a subtree total, not a direct-assignment total, because the
73 + /// drill-down's promise is "pick this and you get that many". Items are only
74 + /// ever tagged with depth-3+ leaves, so a category's direct count is always
75 + /// zero and would render a sidebar full of noise.
76 + #[derive(Debug, Clone, FromRow)]
77 + pub struct DbTagChild {
78 + pub tag_name: String,
79 + pub tag_slug: String,
80 + pub count: i64,
81 + /// True once this tag is deep enough to be assigned to an item, and
82 + /// therefore to act as a filter rather than only as a navigation rung.
83 + pub assignable: bool,
84 + /// True when this tag has children of its own, so the UI knows whether
85 + /// selecting it should also offer to drill further.
86 + pub has_children: bool,
87 + }
@@ -205,6 +205,123 @@ pub async fn get_tag_counts(
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
@@ -10,7 +10,7 @@ use sqlx::PgPool;
10 10 use tower_sessions::Session;
11 11
12 12 use std::collections::HashMap;
13 - use std::sync::{Mutex, OnceLock};
13 + use std::sync::{Arc, Mutex, OnceLock};
14 14 use std::time::{Duration, Instant};
15 15
16 16 use crate::{
@@ -103,6 +103,95 @@ async fn cached_facets(db: &PgPool, filters: &db::discover::DiscoverFilters<'_>)
103 103 Ok(bundle)
104 104 }
105 105
106 + /// Timestamped index, so the TTL check and the shared handle travel together.
107 + type CachedTagIndex = Option<(Instant, Arc<tagtree::TagIndex>)>;
108 +
109 + /// Memoized [`tagtree::TagIndex`] over every tag slug, backing the sidebar's
110 + /// tag typeahead.
111 + ///
112 + /// The index is memory-only and has to be rebuilt from Postgres; `rebuild`
113 + /// exists for exactly this. It is refreshed wholesale on a TTL rather than
114 + /// mutated, which also sidesteps `TagIndex::remove` leaving orphaned segments
115 + /// behind (tagtree lib.rs:817) and degrading the segment-prefix gate over time.
116 + ///
117 + /// The taxonomy is ~120 tags and changes only by migration, so five minutes of
118 + /// staleness in an autocomplete is not worth invalidation machinery.
119 + static TAG_INDEX: OnceLock<Mutex<CachedTagIndex>> = OnceLock::new();
120 +
121 + const TAG_INDEX_TTL: Duration = Duration::from_secs(300);
122 +
123 + async fn cached_tag_index(db: &PgPool) -> Result<Arc<tagtree::TagIndex>> {
124 + let cell = TAG_INDEX.get_or_init(|| Mutex::new(None));
125 + if let Ok(guard) = cell.lock()
126 + && let Some((at, index)) = guard.as_ref()
127 + && at.elapsed() < TAG_INDEX_TTL
128 + {
129 + return Ok(Arc::clone(index));
130 + }
131 +
132 + let slugs = db::tags::all_tag_slugs(db).await?;
133 + let index = Arc::new(tagtree::TagIndex::new(slugs));
134 +
135 + if let Ok(mut guard) = cell.lock() {
136 + *guard = Some((Instant::now(), Arc::clone(&index)));
137 + }
138 + Ok(index)
139 + }
140 +
141 + /// How many typeahead results to return. Enough to fill the dropdown without
142 + /// turning `suggest_fuzzy`'s full-corpus scan into a page-weight problem.
143 + const TAG_SUGGEST_LIMIT: usize = 8;
144 +
145 + /// Tag typeahead for the discover sidebar.
146 + ///
147 + /// Prefix matching first (path-prefix, then segment-prefix), falling back to
148 + /// fuzzy only when prefix matching underfills. That ordering is tagtree's, and
149 + /// it is why typing "elec" finds `audio.genre.electronic` without the caller
150 + /// knowing which level it lives at.
151 + pub(super) async fn tag_suggestions_handler(
152 + State(db): State<PgPool>,
153 + Query(query): Query<SuggestionsQuery>,
154 + ) -> Result<impl IntoResponse> {
155 + let raw = query.q.unwrap_or_default();
156 + let input = raw.trim();
157 + if input.is_empty() {
158 + return Ok(Json(Vec::<TagSuggestion>::new()));
159 + }
160 +
161 + let index = cached_tag_index(&db).await?;
162 + let (hits, _exact) = index.suggest_with_status(input, TAG_SUGGEST_LIMIT);
163 + let mut slugs: Vec<String> = hits.into_iter().map(str::to_string).collect();
164 + if slugs.is_empty() {
165 + slugs = index
166 + .suggest_fuzzy(input, TAG_SUGGEST_LIMIT)
167 + .into_iter()
168 + .map(str::to_string)
169 + .collect();
170 + }
171 +
172 + // Only depth-3+ tags can be assigned to an item, so only they can filter
173 + // anything; offering a category here would produce an empty result set.
174 + slugs.retain(|s| tagtree::depth(s) >= 3);
175 +
176 + let names: std::collections::HashMap<String, String> =
177 + db::tags::tag_names_for_slugs(&db, &slugs).await?.into_iter().collect();
178 +
179 + let suggestions: Vec<TagSuggestion> = slugs
180 + .into_iter()
181 + .map(|slug| {
182 + let label = names.get(&slug).cloned().unwrap_or_else(|| {
183 + tagtree::leaf(&slug).replace('-', " ")
184 + });
185 + // The parent path orients an otherwise ambiguous leaf: "Format"
186 + // appears under audio, software, writing, and video.
187 + let context = tagtree::parent(&slug).unwrap_or("").to_string();
188 + TagSuggestion { slug, label, context }
189 + })
190 + .collect();
191 +
192 + Ok(Json(suggestions))
193 + }
194 +
106 195 /// Deserialize an empty string as `None` instead of failing to parse.
107 196 ///
108 197 /// HTML form inputs send `field=` (empty string) when blank, which fails
@@ -144,6 +233,11 @@ pub struct DiscoverQuery {
144 233 pub mode: Option<String>, // "items" (default) or "projects"
145 234 pub ai_tier: Option<String>,
146 235 pub has_source: Option<String>,
236 + /// Drill-down cursor: which tag's children the sidebar is showing. Distinct
237 + /// from `tag`, which is the selection. Browsing into a category does not
238 + /// filter, and selecting a leaf does not move the cursor, so the two can be
239 + /// operated independently.
240 + pub browse: Option<String>,
147 241 }
148 242
149 243 impl DiscoverQuery {
@@ -156,6 +250,61 @@ impl DiscoverQuery {
156 250 /// address bar. Prices are sanitized so the URL states what was actually
157 251 /// applied, and `page=1` is left implicit.
158 252 fn to_page_url(&self) -> String {
253 + let mut parts = self.filter_params();
254 + if let Some(b) = self.browse.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) {
255 + parts.push(format!("browse={}", urlencoding::encode(b)));
256 + }
257 + if let Some(p) = self.page.filter(|&p| p > 1) {
258 + parts.push(format!("page={p}"));
259 + }
260 +
261 + if parts.is_empty() {
262 + "/discover".to_string()
263 + } else {
264 + format!("/discover?{}", parts.join("&"))
265 + }
266 + }
267 +
268 + /// Base URL for moving the drill-down cursor, ending ready for a `browse=`
269 + /// value to be appended.
270 + ///
271 + /// Navigating the tree preserves the current selection and drops `page`,
272 + /// since a cursor move lands you on a different list and page 4 of the old
273 + /// one is meaningless.
274 + fn browse_base_url(&self) -> String {
275 + let parts = self.filter_params();
276 + if parts.is_empty() {
277 + "/discover?browse=".to_string()
278 + } else {
279 + format!("/discover?{}&browse=", parts.join("&"))
280 + }
281 + }
282 +
283 + /// The URL that clears the cursor back to the tag roots, selection intact.
284 + fn browse_root_url(&self) -> String {
285 + let parts = self.filter_params();
286 + if parts.is_empty() {
287 + "/discover".to_string()
288 + } else {
289 + format!("/discover?{}", parts.join("&"))
290 + }
291 + }
292 +
293 + /// Filter params with the price range omitted, for building the price
294 + /// bucket links: a bucket replaces the range rather than adding to it.
295 + fn params_without_price(&self) -> Vec<String> {
296 + let (min, max) = sanitize_price_range(self.min_price, self.max_price);
297 + self.filter_params()
298 + .into_iter()
299 + .filter(|p| {
300 + !(min.is_some_and(|v| *p == format!("min_price={v}"))
301 + || max.is_some_and(|v| *p == format!("max_price={v}")))
302 + })
303 + .collect()
304 + }
305 +
306 + /// Every filter param except the cursor and the page, in canonical order.
307 + fn filter_params(&self) -> Vec<String> {
159 308 fn push_str(parts: &mut Vec<String>, key: &str, value: Option<&String>) {
160 309 if let Some(v) = value.map(|s| s.trim()).filter(|s| !s.is_empty()) {
161 310 parts.push(format!("{key}={}", urlencoding::encode(v)));
@@ -187,15 +336,8 @@ impl DiscoverQuery {
187 336 if let Some(v) = max_price {
188 337 parts.push(format!("max_price={v}"));
189 338 }
190 - if let Some(p) = self.page.filter(|&p| p > 1) {
191 - parts.push(format!("page={p}"));
192 - }
193 339
194 - if parts.is_empty() {
195 - "/discover".to_string()
196 - } else {
197 - format!("/discover?{}", parts.join("&"))
198 - }
340 + parts
199 341 }
200 342 }
201 343
@@ -259,6 +401,7 @@ mod page_url_tests {
259 401 mode: None,
260 402 ai_tier: None,
261 403 has_source: None,
404 + browse: None,
262 405 }
263 406 }
264 407
@@ -618,6 +761,21 @@ pub(super) async fn discover(
618 761 vec![]
619 762 };
620 763
764 + // The applied bounds, rendered back into the number inputs. Without this the
765 + // inputs come back blank, hx-include resends them empty, and the price filter
766 + // silently disappears on the next interaction.
767 + let (shown_min, shown_max) = sanitize_price_range(query.min_price, query.max_price);
768 + let current_min_price = shown_min.map(|v| v.to_string()).unwrap_or_default();
769 + let current_max_price = shown_max.map(|v| v.to_string()).unwrap_or_default();
770 +
771 + // Built before the template literal partially moves `query`.
772 + let browse_url_prefix = query.browse_base_url();
773 + let browse_url_root = query.browse_root_url();
774 +
775 + let mut tag_chips: Vec<TagChip> = Vec::new();
776 + let mut tag_drill: Vec<TagDrillRow> = Vec::new();
777 + let mut tag_crumbs: Vec<TagCrumb> = Vec::new();
778 +
621 779 let (type_filters, tag_filters, ai_tier_filters, price_counts) = if data.mode == "items" {
622 780 // The four facet counts are viewer-independent -> served from a short-TTL
623 781 // memo (ultra-fuzz Run 12 Performance). Only `followed_tag_ids` is
@@ -634,6 +792,68 @@ pub(super) async fn discover(
634 792 };
635 793 let (type_counts, tag_counts, ai_counts, price_counts) =
636 794 cached_facets(&db, &facet_filters).await?;
795 +
796 + // The drill-down cursor. Not cached alongside the facets: it varies by
797 + // an axis the facet key does not carry, and it is one cheap indexed
798 + // query over a ~120-row table.
799 + let browse_cursor = query.browse.as_deref().filter(|s| !s.is_empty());
800 + let drill_rows =
801 + db::tags::tag_children_with_counts(&db, browse_cursor, &facet_filters).await?;
802 + tag_drill = drill_rows
803 + .into_iter()
804 + .map(|r| TagDrillRow {
805 + selected: tag_filter.iter().any(|t| t == &r.tag_slug),
806 + slug: r.tag_slug,
807 + label: r.tag_name,
808 + count: r.count as u32,
809 + assignable: r.assignable,
810 + has_children: r.has_children,
811 + })
812 + .collect();
813 +
814 + // Breadcrumbs are derived from the cursor's own dot-path rather than
815 + // queried: tagtree::ancestors is exactly this, and every ancestor of a
816 + // valid slug is itself a valid slug.
817 + tag_crumbs = browse_cursor
818 + .map(|cursor| {
819 + tagtree::ancestors(cursor)
820 + .into_iter()
821 + .chain(std::iter::once(cursor))
822 + .map(|slug| TagCrumb {
823 + slug: slug.to_string(),
824 + label: tagtree::leaf(slug).replace('-', " "),
825 + })
826 + .collect()
827 + })
828 + .unwrap_or_default();
829 +
830 + // Chips carry a precomputed dismiss query so the template stays free of
831 + // list manipulation, and so removal is a plain link that works without JS.
832 + let chip_names: std::collections::HashMap<String, String> =
833 + db::tags::tag_names_for_slugs(&db, &tag_filter)
834 + .await?
835 + .into_iter()
836 + .collect();
837 + tag_chips = tag_filter
838 + .iter()
839 + .map(|slug| {
840 + let remove_query = tag_filter
841 + .iter()
842 + .filter(|other| *other != slug)
843 + .map(|other| format!("tag={}", urlencoding::encode(other)))
844 + .collect::<Vec<_>>()
845 + .join("&");
846 + TagChip {
847 + label: chip_names
848 + .get(slug)
849 + .cloned()
850 + .unwrap_or_else(|| tagtree::leaf(slug).replace('-', " ")),
851 + context: tagtree::parent(slug).unwrap_or("").to_string(),
852 + slug: slug.clone(),
853 + remove_query,
854 + }
855 + })
856 + .collect();
637 857 // Only the first ~10 tag facets are rendered (see `.take(10)` below), so
638 858 // test follow-membership against just those rather than fetching the
639 859 // viewer's entire followed-tag set (fuzz 2026-07-06 C5-1).
@@ -738,6 +958,40 @@ pub(super) async fn discover(
738 958 PriceFilter { label: "$100+".to_string(), count: price_counts.over_100 as u32 },
739 959 ];
740 960
961 + // The same five buckets as clickable ranges. Bounds mirror the FILTER
962 + // clauses in db::discover::get_price_range_counts exactly; if one moves the
963 + // other must, or a bucket will report a count its own link cannot reproduce.
964 + let price_base_params = query.params_without_price();
965 + let (applied_min, applied_max) = sanitize_price_range(query.min_price, query.max_price);
966 + let price_buckets: Vec<PriceBucket> = [
967 + ("Free", price_counts.free, Some(0), Some(0)),
968 + ("Under $25", price_counts.under_25, Some(1), Some(2499)),
969 + ("$25-50", price_counts.range_25_50, Some(2500), Some(4999)),
970 + ("$50-100", price_counts.range_50_100, Some(5000), Some(9999)),
971 + ("$100+", price_counts.over_100, Some(10000), None),
972 + ]
973 + .into_iter()
974 + .map(|(label, count, min, max)| {
975 + let mut parts = price_base_params.clone();
976 + if let Some(v) = min {
977 + parts.push(format!("min_price={v}"));
978 + }
979 + if let Some(v) = max {
980 + parts.push(format!("max_price={v}"));
981 + }
982 + PriceBucket {
983 + label: label.to_string(),
984 + count: count as u32,
985 + url: if parts.is_empty() {
986 + "/discover".to_string()
987 + } else {
988 + format!("/discover?{}", parts.join("&"))
989 + },
990 + active: applied_min == min && applied_max == max,
991 + }
992 + })
993 + .collect();
994 +
741 995 // The mobile filter badge counts *groups* with an active selection, not
742 996 // individual values: picking three tags is still one filter in use.
743 997 let current_types: Vec<String> =
@@ -747,6 +1001,27 @@ pub(super) async fn discover(
747 1001 let current_category = query.category.unwrap_or_default();
748 1002 let current_ai_tier = query.ai_tier.unwrap_or_default();
749 1003
1004 + // A selection is only carried by a hidden input when no visible control
1005 + // represents it. The drill-down shows one rung at a time, so a tag chosen
1006 + // from elsewhere in the tree has no checkbox on screen; likewise a type
1007 + // whose count dropped out of the facet list. Rendering a hidden input for a
1008 + // value that also has a checked checkbox would submit it twice.
1009 + let visible_tag_slugs: std::collections::HashSet<&str> =
1010 + tag_drill.iter().map(|r| r.slug.as_str()).collect();
1011 + let hidden_tags: Vec<String> = current_tags
1012 + .iter()
1013 + .filter(|t| !visible_tag_slugs.contains(t.as_str()))
1014 + .cloned()
1015 + .collect();
1016 + let visible_type_values: std::collections::HashSet<&str> =
1017 + type_filters.iter().map(|t| t.value.as_str()).collect();
1018 + let hidden_types: Vec<String> = current_types
1019 + .iter()
1020 + .filter(|t| !visible_type_values.contains(t.as_str()))
1021 + .cloned()
1022 + .collect();
1023 +
1024 +
750 1025 let active_filter_count = [
751 1026 !current_types.is_empty(),
752 1027 !current_tags.is_empty(),
@@ -776,6 +1051,16 @@ pub(super) async fn discover(
776 1051 sort_by: query.sort.unwrap_or_default(),
777 1052 current_types,
778 1053 current_tags,
1054 + hidden_tags,
1055 + hidden_types,
1056 + tag_chips,
1057 + tag_drill,
1058 + tag_crumbs,
1059 + browse_url_prefix,
1060 + browse_url_root,
1061 + price_buckets,
1062 + current_min_price,
1063 + current_max_price,
779 1064 current_category,
780 1065 pagination_range: data.pagination_range,
781 1066 showing_start: data.showing_start,
@@ -830,6 +1115,17 @@ pub struct SuggestionsQuery {
830 1115 pub q: Option<String>,
831 1116 }
832 1117
1118 + /// JSON response for a tag typeahead hit.
1119 + ///
1120 + /// `context` is the parent path, which the dropdown needs in order to
1121 + /// disambiguate: "Format" exists under audio, software, writing, and video.
1122 + #[derive(Debug, Serialize)]
1123 + pub struct TagSuggestion {
1124 + pub slug: String,
1125 + pub label: String,
1126 + pub context: String,
1127 + }
1128 +
833 1129 /// JSON response for a search suggestion.
834 1130 #[derive(Debug, Serialize)]
835 1131 pub struct SearchSuggestion {
@@ -86,7 +86,8 @@ pub fn public_routes() -> CsrfRouter<AppState> {
86 86 .route_get("/discover", get(discover::discover))
87 87 .route_get("/discover/results", get(discover::discover_results).layer(GovernorLayer { config: search_rate_limit.clone() }))
88 88 .route_get("/discover/suggestions", get(discover::search_suggestions_handler).layer(GovernorLayer { config: search_rate_limit.clone() }))
89 - .route_get("/discover/tags", get(discover::tag_tree).layer(GovernorLayer { config: search_rate_limit }))
89 + .route_get("/discover/tags", get(discover::tag_tree).layer(GovernorLayer { config: search_rate_limit.clone() }))
90 + .route_get("/discover/tag-suggest", get(discover::tag_suggestions_handler).layer(GovernorLayer { config: search_rate_limit }))
90 91 .route_get("/feed", get(feed::feed_page))
91 92 .route_get("/u/{username}", get(content::user_page))
92 93 .route_get("/c/{username}/{slug}", get(content::collection_page))
@@ -621,6 +621,28 @@ pub struct DiscoverTemplate {
621 621 /// Currently selected tag slug filter, or empty for "All".
622 622 /// One entry per selected tag; see `current_types`.
623 623 pub current_tags: Vec<String>,
624 + /// Selected tags with no visible control in the current view, carried as
625 + /// hidden inputs. Excludes anything already rendered as a checkbox, which
626 + /// would otherwise submit the value twice.
627 + pub hidden_tags: Vec<String>,
628 + /// Selected item types with no visible checkbox; see `hidden_tags`.
629 + pub hidden_types: Vec<String>,
630 + /// Selected tags as removable chips (also the active-filter summary).
631 + pub tag_chips: Vec<TagChip>,
632 + /// Immediate children of the drill-down cursor, with subtree counts.
633 + pub tag_drill: Vec<TagDrillRow>,
634 + /// Trail back up from the drill-down cursor; empty at the root.
635 + pub tag_crumbs: Vec<TagCrumb>,
636 + /// Cursor-move URL with the current selection preserved, ready for a slug.
637 + pub browse_url_prefix: String,
638 + /// Cursor-clear URL, selection preserved.
639 + pub browse_url_root: String,
640 + /// The five price ranges as clickable links.
641 + pub price_buckets: Vec<PriceBucket>,
642 + /// Applied price bounds, rendered back into the number inputs so
643 + /// `hx-include` cannot resend them blank and drop the filter.
644 + pub current_min_price: String,
645 + pub current_max_price: String,
624 646 /// Currently selected category slug filter, or empty for "All".
625 647 pub current_category: String,
626 648 /// Page numbers to render in the pagination bar.
@@ -59,6 +59,38 @@ pub struct TagTreeNode {
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 @@ pub struct TagBreadcrumb {
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,
@@ -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);
95 + });
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 + }
14 112 });
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);
113 +
114 + document.addEventListener('click', function(e) {
115 + if (!list.contains(e.target) && e.target !== input) close();
24 116 });
25 - }
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 });
@@ -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>
62 - </li>
63 - {% endfor %}
64 - </ul>
65 - </div>
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>
66 55
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 %}
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>
91 67 </li>
92 68 {% endfor %}
69 + <li class="tag-chip tag-chip-clear">
70 + <a href="/discover?mode=items">Clear all</a>
71 + </li>
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 @@ async fn facet_counts_apply_the_ai_tier_filter() {
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 @@ async fn discover_search_replaces_rather_than_pushes_history() {
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"
229 + );
230 + assert!(
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"
198 238 );
199 239 assert!(
200 - !resp.text.contains("role=\"listbox\""),
201 - "Filter lists should not claim listbox semantics they don't implement"
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