Skip to main content

max / makenotwork

Refresh the discover sidebar out-of-band The sidebar's counts describe the current filter, but results swapped into #results-container while the sidebar sat untouched until a full page load. Faceted browsing is steered by those counts, so from the first filter click onward the user was navigating by numbers that no longer matched the list beside them. Extract the sidebar into partials/discover_sidebar.html, shared by the page and the results partial, and give /discover/results the same view model via a new build_sidebar(). The partial emits the sidebar with hx-swap-oob="outerHTML:#discover-sidebar". Guarded on an oob_sidebar flag: the full page includes the results partial inside #results-container, so without it the sidebar rendered twice with duplicate element ids. Focus survives the swap. htmx 2.0.4 saves document.activeElement and, when it has an id, refocuses the replacement after settle and restores the selection range, so every control now carries a stable one. The typeahead input additionally uses hx-preserve, since its value is user state the server never renders and would otherwise be wiped mid-typing. Also key the facet memo on the database name. FACET_CACHE is a process-global keyed only on filter values, with no identity for what it cached from. Production has one database so this never misfired there, but under test every case gets its own template clone and the memo was serving one test's facet counts to another, which is how a missing type facet surfaced here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 18:40 UTC
Commit: 0ecc387415ce594e058f8708a86d32df74682893
Parent: 0f77631
8 files changed, +723 insertions, -401 deletions
@@ -23,6 +23,354 @@ use crate::{
23 23 types::*,
24 24 };
25 25
26 + /// Build the sidebar view model.
27 + ///
28 + /// Shared by the full page and the `/discover/results` partial. The partial
29 + /// needs it because the sidebar is swapped out-of-band on every filter change:
30 + /// its counts describe the current filter, so leaving them behind would show
31 + /// numbers that no longer match the results beside them.
32 + async fn build_sidebar(
33 + db: &PgPool,
34 + query: &DiscoverQuery,
35 + data: &DiscoverData,
36 + viewer_id: Option<db::UserId>,
37 + ) -> Result<SidebarView> {
38 + let f = query.filter_selection();
39 + let search_filter = f.search;
40 + let tag_filter = f.tags;
41 + let item_type_filter = f.item_types;
42 + let has_source_code = f.has_source_code;
43 +
44 + // Build type and tag filters (items mode only)
45 + let category_filter = f.category;
46 +
47 + // Build category filters (projects mode only)
48 + let category_filters = if data.mode == "projects" {
49 + let cat_counts = db::categories::get_category_counts(
50 + db,
51 + search_filter,
52 + )
53 + .await?;
54 +
55 + let mut filters: Vec<FilterCategory> = vec![FilterCategory {
56 + name: "All".to_string(),
57 + value: String::new(),
58 + count: data.total_count,
59 + active: category_filter.is_none(),
60 + id: String::new(),
61 + following: false,
62 + }];
63 + for cc in cat_counts {
64 + filters.push(FilterCategory {
65 + name: cc.name,
66 + value: cc.slug.to_string(),
67 + count: cc.count as u32,
68 + active: category_filter == Some(cc.slug.as_str()),
69 + id: String::new(),
70 + following: false,
71 + });
72 + }
73 + filters
74 + } else {
75 + vec![]
76 + };
77 +
78 + // The applied bounds, rendered back into the number inputs. Without this the
79 + // inputs come back blank, hx-include resends them empty, and the price filter
80 + // silently disappears on the next interaction.
81 + let (shown_min, shown_max) = sanitize_price_range(query.min_price, query.max_price);
82 + let current_min_price = shown_min.map(|v| v.to_string()).unwrap_or_default();
83 + let current_max_price = shown_max.map(|v| v.to_string()).unwrap_or_default();
84 +
85 + // Built before the template literal partially moves `query`.
86 + let browse_url_prefix = query.browse_base_url();
87 + let browse_url_root = query.browse_root_url();
88 +
89 + let mut tag_chips: Vec<TagChip> = Vec::new();
90 + let mut tag_drill: Vec<TagDrillRow> = Vec::new();
91 + let mut tag_crumbs: Vec<TagCrumb> = Vec::new();
92 +
93 + let (type_filters, tag_filters, ai_tier_filters, price_counts) = if data.mode == "items" {
94 + // The four facet counts are viewer-independent -> served from a short-TTL
95 + // memo (ultra-fuzz Run 12 Performance). Only `followed_tag_ids` is
96 + // per-viewer, so it is computed fresh (and only when logged in).
97 + let facet_filters = DiscoverFilters {
98 + search: search_filter,
99 + item_types: &item_type_filter,
100 + tags: &tag_filter,
101 + min_price: query.min_price,
102 + max_price: query.max_price,
103 + sort_by: None,
104 + ai_tier: f.ai_tier,
105 + };
106 + let (type_counts, tag_counts, ai_counts, price_counts) =
107 + cached_facets(db, &facet_filters).await?;
108 +
109 + // The drill-down cursor. Not cached alongside the facets: it varies by
110 + // an axis the facet key does not carry, and it is one cheap indexed
111 + // query over a ~120-row table.
112 + let browse_cursor = query.browse.as_deref().filter(|s| !s.is_empty());
113 + let drill_rows =
114 + db::tags::tag_children_with_counts(db, browse_cursor, &facet_filters).await?;
115 + tag_drill = drill_rows
116 + .into_iter()
117 + .map(|r| TagDrillRow {
118 + selected: tag_filter.iter().any(|t| t == &r.tag_slug),
119 + slug: r.tag_slug,
120 + label: r.tag_name,
121 + count: r.count as u32,
122 + assignable: r.assignable,
123 + has_children: r.has_children,
124 + })
125 + .collect();
126 +
127 + // Breadcrumbs are derived from the cursor's own dot-path rather than
128 + // queried: tagtree::ancestors is exactly this, and every ancestor of a
129 + // valid slug is itself a valid slug.
130 + tag_crumbs = browse_cursor
131 + .map(|cursor| {
132 + tagtree::ancestors(cursor)
133 + .into_iter()
134 + .chain(std::iter::once(cursor))
135 + .map(|slug| TagCrumb {
136 + slug: slug.to_string(),
137 + label: tagtree::leaf(slug).replace('-', " "),
138 + })
139 + .collect()
140 + })
141 + .unwrap_or_default();
142 +
143 + // Chips carry a precomputed dismiss query so the template stays free of
144 + // list manipulation, and so removal is a plain link that works without JS.
145 + let chip_names: std::collections::HashMap<String, String> =
146 + db::tags::tag_names_for_slugs(db, &tag_filter)
147 + .await?
148 + .into_iter()
149 + .collect();
150 + tag_chips = tag_filter
151 + .iter()
152 + .map(|slug| {
153 + let remove_query = tag_filter
154 + .iter()
155 + .filter(|other| *other != slug)
156 + .map(|other| format!("tag={}", urlencoding::encode(other)))
157 + .collect::<Vec<_>>()
158 + .join("&");
159 + TagChip {
160 + label: chip_names
161 + .get(slug)
162 + .cloned()
163 + .unwrap_or_else(|| tagtree::leaf(slug).replace('-', " ")),
164 + context: tagtree::parent(slug).unwrap_or("").to_string(),
165 + slug: slug.clone(),
166 + remove_query,
167 + }
168 + })
169 + .collect();
170 + // Only the first ~10 tag facets are rendered (see `.take(10)` below), so
171 + // test follow-membership against just those rather than fetching the
172 + // viewer's entire followed-tag set (fuzz 2026-07-06 C5-1).
173 + let followed_tag_ids = if let Some(uid) = viewer_id {
174 + let rendered_tag_ids: Vec<_> =
175 + tag_counts.iter().take(10).map(|tc| tc.tag_id).collect();
176 + db::follows::following_subset(db, uid, &rendered_tag_ids).await?
177 + } else {
178 + std::collections::HashSet::new()
179 + };
180 +
181 + let mut type_filters: Vec<FilterCategory> = vec![FilterCategory {
182 + name: "All".to_string(),
183 + value: String::new(),
184 + count: data.total_count,
185 + active: item_type_filter.is_empty(),
186 + id: String::new(),
187 + following: false,
188 + }];
189 + for tc in type_counts {
190 + let active = item_type_filter.iter().any(|t| t.to_string() == tc.category);
191 + type_filters.push(FilterCategory {
192 + value: tc.category.clone(),
193 + name: tc.category,
194 + count: tc.count as u32,
195 + active,
196 + id: String::new(),
197 + following: false,
198 + });
199 + }
200 +
201 + let mut tag_filters: Vec<FilterCategory> = vec![FilterCategory {
202 + name: "All".to_string(),
203 + value: String::new(),
204 + count: data.total_count,
205 + active: tag_filter.is_empty(),
206 + id: String::new(),
207 + following: false,
208 + }];
209 + for tc in tag_counts.iter().take(10) {
210 + tag_filters.push(FilterCategory {
211 + name: tc.tag_name.clone(),
212 + value: tc.tag_slug.to_string(),
213 + count: tc.count as u32,
214 + active: tag_filter.iter().any(|t| t == &tc.tag_slug),
215 + id: tc.tag_id.to_string(),
216 + following: followed_tag_ids.contains(&tc.tag_id),
217 + });
218 + }
219 +
220 + // Per `about/generative-ai.md` § "How Fans Use This", the three
221 + // filter options are "Everything" / "Human-led" (Handmade ∪
222 + // Assisted) / "Handmade only". Aggregate the per-tier counts
223 + // into option-sized counts before passing to the template.
224 + let mut handmade_count: u32 = 0;
225 + let mut assisted_count: u32 = 0;
226 + for ac in &ai_counts {
227 + match ac.category.as_str() {
228 + "handmade" => handmade_count = ac.count as u32,
229 + "assisted" => assisted_count = ac.count as u32,
230 + _ => {}
231 + }
232 + }
233 + let ai_tier_filter_str = query.ai_tier.as_deref().filter(|s| !s.is_empty());
234 + let ai_tier_filters: Vec<FilterCategory> = vec![
235 + FilterCategory {
236 + name: "Everything".to_string(),
237 + value: String::new(),
238 + count: data.total_count,
239 + active: ai_tier_filter_str.is_none(),
240 + id: String::new(),
241 + following: false,
242 + },
243 + FilterCategory {
244 + name: db::AiTierFilter::HumanLed.label().to_string(),
245 + value: db::AiTierFilter::HumanLed.to_string(),
246 + count: handmade_count + assisted_count,
247 + active: ai_tier_filter_str == Some(db::AiTierFilter::HumanLed.to_string().as_str()),
248 + id: String::new(),
249 + following: false,
250 + },
251 + FilterCategory {
252 + name: db::AiTierFilter::HandmadeOnly.label().to_string(),
253 + value: db::AiTierFilter::HandmadeOnly.to_string(),
254 + count: handmade_count,
255 + active: ai_tier_filter_str == Some(db::AiTierFilter::HandmadeOnly.to_string().as_str()),
256 + id: String::new(),
257 + following: false,
258 + },
259 + ];
260 +
261 + (type_filters, tag_filters, ai_tier_filters, price_counts)
262 + } else {
263 + (vec![], vec![], vec![], db::DbPriceRangeCounts::default())
264 + };
265 +
266 + let price_filters = vec![
267 + PriceFilter { label: "Free".to_string(), count: price_counts.free as u32 },
268 + PriceFilter { label: "Under $25".to_string(), count: price_counts.under_25 as u32 },
269 + PriceFilter { label: "$25-50".to_string(), count: price_counts.range_25_50 as u32 },
270 + PriceFilter { label: "$50-100".to_string(), count: price_counts.range_50_100 as u32 },
271 + PriceFilter { label: "$100+".to_string(), count: price_counts.over_100 as u32 },
272 + ];
273 +
274 + // The same five buckets as clickable ranges. Bounds mirror the FILTER
275 + // clauses in db::discover::get_price_range_counts exactly; if one moves the
276 + // other must, or a bucket will report a count its own link cannot reproduce.
277 + let price_base_params = query.params_without_price();
278 + let (applied_min, applied_max) = sanitize_price_range(query.min_price, query.max_price);
279 + let price_buckets: Vec<PriceBucket> = [
280 + ("Free", price_counts.free, Some(0), Some(0)),
281 + ("Under $25", price_counts.under_25, Some(1), Some(2499)),
282 + ("$25-50", price_counts.range_25_50, Some(2500), Some(4999)),
283 + ("$50-100", price_counts.range_50_100, Some(5000), Some(9999)),
284 + ("$100+", price_counts.over_100, Some(10000), None),
285 + ]
286 + .into_iter()
287 + .map(|(label, count, min, max)| {
288 + let mut parts = price_base_params.clone();
289 + if let Some(v) = min {
290 + parts.push(format!("min_price={v}"));
291 + }
292 + if let Some(v) = max {
293 + parts.push(format!("max_price={v}"));
294 + }
295 + PriceBucket {
296 + label: label.to_string(),
297 + count: count as u32,
298 + url: if parts.is_empty() {
299 + "/discover".to_string()
300 + } else {
301 + format!("/discover?{}", parts.join("&"))
302 + },
303 + active: applied_min == min && applied_max == max,
304 + }
305 + })
306 + .collect();
307 +
308 + // The mobile filter badge counts *groups* with an active selection, not
309 + // individual values: picking three tags is still one filter in use.
310 + let current_types: Vec<String> =
311 + dedup_nonempty(&query.item_type).into_iter().map(str::to_string).collect();
312 + let current_tags: Vec<String> =
313 + dedup_nonempty(&query.tag).into_iter().map(str::to_string).collect();
314 + let current_category = query.category.clone().unwrap_or_default();
315 + let current_ai_tier = query.ai_tier.clone().unwrap_or_default();
316 +
317 + // A selection is only carried by a hidden input when no visible control
318 + // represents it. The drill-down shows one rung at a time, so a tag chosen
319 + // from elsewhere in the tree has no checkbox on screen; likewise a type
320 + // whose count dropped out of the facet list. Rendering a hidden input for a
321 + // value that also has a checked checkbox would submit it twice.
322 + let visible_tag_slugs: std::collections::HashSet<&str> =
323 + tag_drill.iter().map(|r| r.slug.as_str()).collect();
324 + let hidden_tags: Vec<String> = current_tags
325 + .iter()
326 + .filter(|t| !visible_tag_slugs.contains(t.as_str()))
327 + .cloned()
328 + .collect();
329 + let visible_type_values: std::collections::HashSet<&str> =
330 + type_filters.iter().map(|t| t.value.as_str()).collect();
331 + let hidden_types: Vec<String> = current_types
332 + .iter()
333 + .filter(|t| !visible_type_values.contains(t.as_str()))
334 + .cloned()
335 + .collect();
336 +
337 +
338 + let active_filter_count = [
339 + !current_types.is_empty(),
340 + !current_tags.is_empty(),
341 + !current_category.is_empty(),
342 + !current_ai_tier.is_empty(),
343 + has_source_code,
344 + query.min_price.is_some(),
345 + query.max_price.is_some(),
346 + ].iter().filter(|&&v| v).count() as u32;
347 +
348 +
349 + Ok(SidebarView {
350 + type_filters,
351 + tag_filters,
352 + category_filters,
353 + price_filters,
354 + price_buckets,
355 + ai_tier_filters,
356 + tag_chips,
357 + tag_drill,
358 + tag_crumbs,
359 + hidden_tags,
360 + hidden_types,
361 + current_types,
362 + current_tags,
363 + current_min_price,
364 + current_max_price,
365 + current_category,
366 + current_ai_tier,
367 + browse_url_prefix,
368 + browse_url_root,
369 + has_source: has_source_code,
370 + active_filter_count,
371 + })
372 + }
373 +
26 374 /// The four viewer-independent discover facet results (item-type, tag, ai-tier, and
27 375 /// price-range counts). `followed_tag_ids` is deliberately excluded — it is
28 376 /// per-viewer and always computed fresh.
@@ -40,7 +388,13 @@ type FacetBundle = (
40 388 /// AI-tier filter, which made it harmless; the moment the facets started
41 389 /// cross-applying it, leaving it out would have served one tier's counts to
42 390 /// another for up to `FACET_CACHE_TTL`.
391 + /// The leading `String` is the database name. The memo is a process-global, so
392 + /// without it two pools pointed at different databases would share counts. In
393 + /// production there is one database and this is a constant; under test every
394 + /// case gets its own template clone, and omitting it silently served one test's
395 + /// facet counts to another.
43 396 type FacetKey = (
397 + String,
44 398 Option<String>,
45 399 Vec<ItemType>,
46 400 Vec<String>,
@@ -67,6 +421,7 @@ static FACET_CACHE: OnceLock<Mutex<HashMap<FacetKey, (Instant, FacetBundle)>>> =
67 421 /// instead of five full-catalog aggregate scans holding five pool connections.
68 422 async fn cached_facets(db: &PgPool, filters: &db::discover::DiscoverFilters<'_>) -> Result<FacetBundle> {
69 423 let key: FacetKey = (
424 + db.connect_options().get_database().unwrap_or_default().to_string(),
70 425 filters.search.map(str::to_string),
71 426 filters.item_types.to_vec(),
72 427 filters.tags.to_vec(),
@@ -510,528 +865,218 @@ impl DiscoverQuery {
510 865 item_types: dedup_nonempty(&self.item_type)
511 866 .into_iter()
512 867 .filter_map(|s| s.parse().ok())
513 - .collect(),
514 - tags: dedup_nonempty(&self.tag)
515 - .into_iter()
516 - .map(str::to_string)
517 - .collect(),
518 - search: self.q.as_deref().filter(|s| !s.trim().is_empty()),
519 - category: self.category.as_deref().filter(|s| !s.is_empty()),
520 - ai_tier: self.ai_tier.as_deref().filter(|s| !s.is_empty()).and_then(|s| s.parse().ok()),
521 - has_source_code: self.has_source.as_deref() == Some("1"),
522 - }
523 - }
524 - }
525 -
526 - /// Drop blank entries and duplicates while preserving order.
527 - ///
528 - /// Duplicates are dropped so a doubled `?tag=x&tag=x` cannot inflate the bind
529 - /// arrays; order is preserved so the pushed URL is stable across a round-trip
530 - /// and doesn't churn browser history.
531 - fn dedup_nonempty(values: &[String]) -> Vec<&str> {
532 - let mut seen = std::collections::HashSet::new();
533 - values
534 - .iter()
535 - .map(|s| s.trim())
536 - .filter(|s| !s.is_empty())
537 - .filter(|s| seen.insert(*s))
538 - .collect()
539 - }
540 -
541 - async fn fetch_discover_data(pool: &PgPool, query: &DiscoverQuery) -> Result<DiscoverData> {
542 - // Clamp the upper bound too (UX MINOR, Run #23): an unbounded page yields a
543 - // giant OFFSET = one expensive deep scan per request. Matches the git/admin
544 - // list handlers.
545 - let page = query.page.unwrap_or(1).clamp(1, 1_000_000_000);
546 - let limit = constants::DISCOVER_PAGE_SIZE as i64;
547 - let offset = ((page - 1) as i64) * limit;
548 - let mode = query.mode.as_deref().unwrap_or("projects");
549 -
550 - let f = query.filter_selection();
551 - let item_type_filter = f.item_types;
552 - let tag_filter = f.tags;
553 - let search_filter = f.search;
554 - let category_filter = f.category;
555 - let ai_tier_filter = f.ai_tier;
556 - let has_source_code = f.has_source_code;
557 -
558 - let (items, projects, total_count) = if mode == "projects" {
559 - let sort_filter: Option<DiscoverSort> = query.sort.as_deref()
560 - .filter(|s| !s.is_empty())
561 - .and_then(|s| s.parse().ok());
562 -
563 - let db_projects = db::discover::discover_projects(
564 - pool,
565 - search_filter,
566 - category_filter,
567 - sort_filter,
568 - has_source_code,
569 - limit,
570 - offset,
571 - )
572 - .await?;
573 -
574 - let total = db::discover::count_discover_projects(
575 - pool,
576 - search_filter,
577 - category_filter,
578 - has_source_code,
579 - )
580 - .await?;
581 -
582 - let projects: Vec<DiscoverProject> = db_projects.into_iter().map(DiscoverProject::from).collect();
583 - (vec![], projects, total as u32)
584 - } else {
585 - let sort_filter: Option<DiscoverSort> = query.sort.as_deref()
586 - .filter(|s| !s.is_empty())
587 - .and_then(|s| s.parse().ok());
588 -
589 - let (min_price, max_price) = sanitize_price_range(query.min_price, query.max_price);
590 - let filters = DiscoverFilters {
591 - search: search_filter,
592 - item_types: &item_type_filter,
593 - tags: &tag_filter,
594 - min_price,
595 - max_price,
596 - sort_by: sort_filter,
597 - ai_tier: ai_tier_filter,
598 - };
599 -
600 - let db_items = db::discover::discover_items(pool, &filters, limit, offset).await?;
601 - let total = db::discover::count_discover_items(pool, &filters).await?;
602 -
603 - let items: Vec<DiscoverItem> = db_items.into_iter().map(DiscoverItem::from).collect();
604 - (items, vec![], total as u32)
605 - };
606 -
607 - let total_pages = ((total_count as f64) / (limit as f64)).ceil() as u32;
608 - let pagination_range = super::pagination::build_pagination_range(page, total_pages);
609 - let result_count = if mode == "projects" {
610 - projects.len() as u32
611 - } else {
612 - items.len() as u32
613 - };
614 - // Reuse the i64 `offset` (computed overflow-safe above) for the "showing
615 - // X–Y" labels and saturate into u32, rather than recomputing
616 - // `(page - 1) * DISCOVER_PAGE_SIZE` in u32 — which overflows for a large `?page=`.
617 - let showing_start = if result_count == 0 {
618 - 0
619 - } else {
620 - offset.saturating_add(1).clamp(0, u32::MAX as i64) as u32
621 - };
622 - let showing_end = offset
623 - .saturating_add(result_count as i64)
624 - .clamp(0, u32::MAX as i64) as u32;
625 -
626 - Ok(DiscoverData {
627 - items,
628 - projects,
629 - mode: mode.to_string(),
630 - total_count,
631 - current_page: page,
632 - total_pages,
633 - pagination_range,
634 - showing_start,
635 - showing_end,
Lines truncated
@@ -30,6 +30,14 @@ pub struct DiscoverResultsTemplate {
30 30 pub current_category: String,
31 31 /// Whether the current user is authenticated (for collection save buttons).
32 32 pub is_authenticated: bool,
33 + /// The sidebar, re-rendered for an out-of-band swap. Its counts describe the
34 + /// filter that produced these results, so leaving the previous sidebar in
35 + /// place would show numbers that disagree with the list beside them.
36 + pub sidebar: SidebarView,
37 + /// Emit the sidebar as an out-of-band swap. False when the full page
38 + /// includes this partial, which renders the sidebar itself; true only for
39 + /// the standalone `/discover/results` response.
40 + pub oob_sidebar: bool,
33 41 }
34 42
35 43 /// HTMX partial: dismissible alert/notification banner.
@@ -601,64 +601,24 @@ pub struct DiscoverTemplate {
601 601 pub projects: Vec<DiscoverProject>,
602 602 /// Active browse mode: `"items"` or `"projects"`.
603 603 pub mode: String,
604 - /// Item type facets for sidebar (e.g. text, audio, download).
605 - pub type_filters: Vec<FilterCategory>,
606 - /// Tag facets for sidebar (top tags by count).
607 - pub tag_filters: Vec<FilterCategory>,
608 - /// Project category facets for sidebar (projects mode only).
609 - pub category_filters: Vec<FilterCategory>,
610 - pub price_filters: Vec<PriceFilter>,
611 604 pub total_items: u32,
612 605 pub current_page: u32,
613 606 pub total_pages: u32,
614 607 pub search_query: String,
615 608 /// Active sort key (e.g. `"most_sold"`, `"newest"`, `"price_asc"`).
616 609 pub sort_by: String,
617 - /// Currently selected item_type filter, or empty for "All".
618 - /// One entry per selected item type; rendered as one hidden input each so a
619 - /// multi-select round-trips through the form without a delimiter convention.
620 - pub current_types: Vec<String>,
621 - /// Currently selected tag slug filter, or empty for "All".
622 - /// One entry per selected tag; see `current_types`.
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,
646 - /// Currently selected category slug filter, or empty for "All".
647 - pub current_category: String,
648 610 /// Page numbers to render in the pagination bar.
649 611 pub pagination_range: Vec<u32>,
650 612 pub showing_start: u32,
651 613 pub showing_end: u32,
652 - /// AI tier facets for sidebar (items mode only).
653 - pub ai_tier_filters: Vec<FilterCategory>,
654 - /// Currently selected AI tier filter slug, or empty for "All".
655 - pub current_ai_tier: String,
656 - /// Whether the "has source code" filter is active (projects mode).
657 - pub has_source: bool,
658 - /// Number of active filters (for mobile filter toggle badge).
659 - pub active_filter_count: u32,
614 + /// Everything the sidebar renders. Grouped so the results partial can carry
615 + /// the identical set for its out-of-band swap.
616 + pub sidebar: SidebarView,
660 617 /// Whether the current user is authenticated (for collection save buttons in results).
661 618 pub is_authenticated: bool,
619 + /// Always false here: the page renders the sidebar directly, so the
620 + /// included results partial must not emit a second out-of-band copy.
621 + pub oob_sidebar: bool,
662 622 }
663 623
664 624 /// Tag tree browser with breadcrumb navigation.
@@ -118,3 +118,33 @@ pub struct PriceFilter {
118 118 pub label: String,
119 119 pub count: u32,
120 120 }
121 +
122 + /// Everything the discover sidebar renders.
123 + ///
124 + /// Grouped rather than flattened onto the templates because both the full page
125 + /// and the `/discover/results` partial need the whole set: the sidebar is
126 + /// swapped out-of-band on every filter change, so its counts and drill-down
127 + /// have to be recomputed alongside the results they describe.
128 + pub struct SidebarView {
129 + pub type_filters: Vec<FilterCategory>,
130 + pub tag_filters: Vec<FilterCategory>,
131 + pub category_filters: Vec<FilterCategory>,
132 + pub price_filters: Vec<PriceFilter>,
133 + pub price_buckets: Vec<PriceBucket>,
134 + pub ai_tier_filters: Vec<FilterCategory>,
135 + pub tag_chips: Vec<TagChip>,
136 + pub tag_drill: Vec<TagDrillRow>,
137 + pub tag_crumbs: Vec<TagCrumb>,
138 + pub hidden_tags: Vec<String>,
139 + pub hidden_types: Vec<String>,
140 + pub current_types: Vec<String>,
141 + pub current_tags: Vec<String>,
142 + pub current_min_price: String,
143 + pub current_max_price: String,
144 + pub current_category: String,
145 + pub current_ai_tier: String,
146 + pub browse_url_prefix: String,
147 + pub browse_url_root: String,
148 + pub has_source: bool,
149 + pub active_filter_count: u32,
150 + }
@@ -8,236 +8,11 @@
8 8
9 9 <h1 class="page-title">Discover</h1>
10 10
11 - <div class="discover-layout{% if mode == "projects" && category_filters.is_empty() %} no-sidebar{% endif %}">
12 - <aside class="discover-sidebar" aria-label="Filters">
13 - {% if mode == "projects" %}
14 - <div class="filter-section">
15 - <div class="filter-title" id="category-label">Category</div>
16 - <ul class="filter-list" aria-labelledby="category-label">
17 - {% for cf in category_filters %}
18 - <li class="filter-item{% if cf.active %} is-selected{% endif %}">
19 - <button type="button" class="filter-btn"
20 - aria-pressed="{% if cf.active %}true{% else %}false{% endif %}"
21 - hx-get="/discover/results"
22 - hx-target="#results-container"
23 - hx-indicator="#search-spinner"
24 - hx-include=".discover-filter"
25 - hx-vals='{"category": "{{ cf.value }}"}'>
26 - <span>{{ cf.name }}</span> <span class="count" aria-label="{{ cf.count }} projects">{{ cf.count }}</span>
27 - </button>
28 - </li>
29 - {% endfor %}
30 - </ul>
31 - </div>
32 - <div class="filter-section">
33 - <label class="filter-checkbox discover-filter-checkbox">
34 - <input type="checkbox" id="has-source-input" name="has_source"
35 - class="discover-filter"
36 - value="1"
37 - {% if has_source %}checked{% endif %}
38 - hx-get="/discover/results"
39 - hx-trigger="change"
40 - hx-target="#results-container"
41 - hx-indicator="#search-spinner"
42 - hx-include=".discover-filter">
43 - Has source code
44 - </label>
45 - </div>
46 - {% endif %}
47 - {% if mode == "items" %}
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>
67 - </li>
68 - {% endfor %}
69 - <li class="tag-chip tag-chip-clear">
70 - <a href="/discover?mode=items">Clear all</a>
71 - </li>
72 - </ul>
73 - {% endif %}
74 -
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>
85 - </div>
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>
129 - </div>
130 -
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 %}
235 - </div>
236 - {% endif %}
237 - </aside>
11 + <div class="discover-layout{% if mode == "projects" && sidebar.category_filters.is_empty() %} no-sidebar{% endif %}">
12 + {% include "partials/discover_sidebar.html" %}
238 13
239 14 <button type="button" class="discover-filter-toggle" data-action="toggleDiscoverFilters" aria-label="Toggle filters">
240 - Filters{% if active_filter_count > 0 %} <span class="filter-count">{{ active_filter_count }}</span>{% endif %}
15 + Filters{% if sidebar.active_filter_count > 0 %} <span class="filter-count">{{ sidebar.active_filter_count }}</span>{% endif %}
241 16 </button>
242 17
243 18 <main class="discover-main">
@@ -312,14 +87,14 @@
312 87 shows one rung at a time, so a tag chosen elsewhere in the tree
313 88 has no checkbox to carry it. Anything that DOES have a checked
314 89 checkbox is deliberately absent here, or it would submit twice. #}
315 - {% for value in hidden_types %}
90 + {% for value in sidebar.hidden_types %}
316 91 <input type="hidden" name="item_type" value="{{ value }}" class="discover-filter" data-facet="item_type">
317 92 {% endfor %}
318 - {% for value in hidden_tags %}
93 + {% for value in sidebar.hidden_tags %}
319 94 <input type="hidden" name="tag" value="{{ value }}" class="discover-filter" data-facet="tag">
320 95 {% endfor %}
321 - <input type="hidden" id="category-input" name="category" value="{{ current_category }}" class="discover-filter">
322 - <input type="hidden" id="ai-tier-input" name="ai_tier" value="{{ current_ai_tier }}" class="discover-filter">
96 + <input type="hidden" id="category-input" name="category" value="{{ sidebar.current_category }}" class="discover-filter">
97 + <input type="hidden" id="ai-tier-input" name="ai_tier" value="{{ sidebar.current_ai_tier }}" class="discover-filter">
323 98 </div>
324 99 </form>
325 100
@@ -1,3 +1,22 @@
1 + {# The sidebar rides along on every results swap, out-of-band, so its counts
2 + and drill-down always describe the results below. Without this the sidebar
3 + is stale from the first filter click until a full page load, which is fatal
4 + for faceted browsing: the numbers are what the user steers by.
5 +
6 + htmx restores focus after a swap to an element with the same id, so every
7 + control in the sidebar carries a stable one and keyboard position survives.
8 + The typeahead input additionally uses hx-preserve, since its value is user
9 + state the server does not render.
10 +
11 + Guarded on `oob_sidebar`: the full page includes this same partial inside
12 + #results-container, and rendering the sidebar there too would emit it twice
13 + with duplicate ids. Only the standalone /discover/results response sets it. #}
14 + {% if oob_sidebar && mode == "items" %}
15 + <div hx-swap-oob="outerHTML:#discover-sidebar">
16 + {% include "partials/discover_sidebar.html" %}
17 + </div>
18 + {% endif %}
19 +
1 20 <div class="results-container results-list" id="results-container-inner">
2 21 <!-- List View -->
3 22 <div class="results-table" id="results-table">
@@ -0,0 +1,232 @@
1 + {# Discover sidebar. Rendered by the full page and, for the out-of-band
2 + swap, by partials/discover_results.html. Both templates expose the same
3 + `sidebar` field so this include works unchanged in either context. #}
4 + <aside class="discover-sidebar" id="discover-sidebar" aria-label="Filters">
5 + {% if mode == "projects" %}
6 + <div class="filter-section">
7 + <div class="filter-title" id="category-label">Category</div>
8 + <ul class="filter-list" aria-labelledby="category-label">
9 + {% for cf in sidebar.category_filters %}
10 + <li class="filter-item{% if cf.active %} is-selected{% endif %}">
11 + <button type="button" class="filter-btn"
12 + aria-pressed="{% if cf.active %}true{% else %}false{% endif %}"
13 + hx-get="/discover/results"
14 + hx-target="#results-container"
15 + hx-indicator="#search-spinner"
16 + hx-include=".discover-filter"
17 + hx-vals='{"category": "{{ cf.value }}"}'>
18 + <span>{{ cf.name }}</span> <span class="count" aria-label="{{ cf.count }} projects">{{ cf.count }}</span>
19 + </button>
20 + </li>
21 + {% endfor %}
22 + </ul>
23 + </div>
24 + <div class="filter-section">
25 + <label class="filter-checkbox discover-filter-checkbox">
26 + <input type="checkbox" id="has-source-input" name="sidebar.has_source"
27 + class="discover-filter"
28 + value="1"
29 + {% if sidebar.has_source %}checked{% endif %}
30 + hx-get="/discover/results"
31 + hx-trigger="change"
32 + hx-target="#results-container"
33 + hx-indicator="#search-spinner"
34 + hx-include=".discover-filter">
35 + Has source code
36 + </label>
37 + </div>
38 + {% endif %}
39 + {% if mode == "items" %}
40 + {# ---- Tag spine -------------------------------------------------
41 + Tags are the structure of this sidebar, not one facet among
42 + several: a typeahead to reach any tag at any depth, chips for
43 + what is selected, and a drill-down to browse by hand.
44 + Everything below the spine refines it. ---- #}
45 + <div class="filter-section tag-spine">
46 + <div class="filter-title" id="tag-label">Tags</div>
47 +
48 + {# Selected tags. Also the active-filter summary and clear-all.
49 + Each dismiss is a plain link carrying the remaining selection,
50 + so removal works with JS off. #}
51 + {% if !sidebar.tag_chips.is_empty() %}
52 + <ul class="tag-chips" aria-label="Selected tags">
53 + {% for chip in sidebar.tag_chips %}
54 + <li class="tag-chip">
55 + <span class="tag-chip-label" {% if !chip.context.is_empty() %}title="{{ chip.context }}"{% endif %}>{{ chip.label }}</span>
56 + <a class="tag-chip-remove"
57 + href="/discover?mode=items{% if !chip.remove_query.is_empty() %}&{{ chip.remove_query|safe }}{% endif %}"
58 + aria-label="Remove tag {{ chip.label }}">&times;</a>
59 + </li>
60 + {% endfor %}
61 + <li class="tag-chip tag-chip-clear">
62 + <a href="/discover?mode=items">Clear all</a>
63 + </li>
64 + </ul>
65 + {% endif %}
66 +
67 + {# Typeahead. Reaches a tag at any depth by name, which is what
68 + makes a taxonomy of arbitrary size navigable and what the old
69 + capped ten-tag list plus "Browse all" could not do. #}
70 + <div class="tag-combobox">
71 + <label class="visually-hidden" for="tag-search">Find a tag</label>
72 + <input type="search" id="tag-search" class="tag-combobox-input" hx-preserve="true"
73 + placeholder="Find a tag" autocomplete="off"
74 + role="combobox" aria-expanded="false"
75 + aria-controls="tag-suggest-list" aria-autocomplete="list">
76 + <ul id="tag-suggest-list" class="tag-suggest-list" role="listbox" hidden></ul>
77 + </div>
78 +
79 + {# Drill-down. `browse` moves the cursor, `tag` selects; a
80 + category navigates, a depth-3+ leaf filters. #}
81 + <nav class="tag-drill" aria-labelledby="tag-label">
82 + {% if !sidebar.tag_crumbs.is_empty() %}
83 + <ol class="tag-crumbs">
84 + <li><a href="{{ sidebar.browse_url_root }}">All</a></li>
85 + {% for crumb in sidebar.tag_crumbs %}
86 + <li><a href="{{ sidebar.browse_url_prefix }}{{ crumb.slug }}">{{ crumb.label }}</a></li>
87 + {% endfor %}
88 + </ol>
89 + {% endif %}
90 + <ul class="filter-list">
91 + {% for row in sidebar.tag_drill %}
92 + <li class="filter-item{% if row.selected %} is-selected{% endif %}">
93 + {% if row.assignable %}
94 + {# A real checkbox: multi-select is the point, and it
95 + is keyboard-operable without any hx-trigger work. #}
96 + <label class="tag-drill-select">
97 + <input type="checkbox" name="tag" value="{{ row.slug }}"
98 + id="tagsel-{{ row.slug }}"
99 + class="discover-filter" data-facet="tag"
100 + {% if row.selected %}checked{% endif %}
101 + hx-get="/discover/results"
102 + hx-target="#results-container"
103 + hx-indicator="#search-spinner"
104 + hx-include=".discover-filter"
105 + hx-trigger="change">
106 + <span>{{ row.label }}</span>
107 + <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span>
108 + </label>
109 + {% endif %}
110 + {% if row.has_children %}
111 + <a class="tag-drill-into" href="{{ sidebar.browse_url_prefix }}{{ row.slug }}"
112 + aria-label="Browse inside {{ row.label }}">
113 + {% if !row.assignable %}<span>{{ row.label }}</span>
114 + <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span>{% endif %}
115 + <span class="tag-drill-chevron" aria-hidden="true">&rsaquo;</span>
116 + </a>
117 + {% endif %}
118 + </li>
119 + {% endfor %}
120 + </ul>
121 + </nav>
122 + </div>
123 +
124 + {# ---- Refine ----------------------------------------------------
125 + Everything below the spine narrows what the tags selected. Same
126 + multi-select treatment, deliberately subordinate. Every control
127 + here is a real form input: multi-select needs one, and it makes
128 + the whole sidebar keyboard-operable without any hx-trigger work
129 + (the .filter-btn elements these replace were not). ---- #}
130 + <div class="filter-refine">
131 + <div class="filter-section">
132 + <fieldset class="filter-fieldset">
133 + <legend class="filter-title">Type</legend>
134 + <ul class="filter-list">
135 + {% for tf in sidebar.type_filters %}
136 + {% if !tf.value.is_empty() %}
137 + <li class="filter-item{% if tf.active %} is-selected{% endif %}">
138 + <label class="filter-check">
139 + <input type="checkbox" name="item_type" value="{{ tf.value }}"
140 + id="typesel-{{ tf.value }}"
141 + class="discover-filter" data-facet="item_type"
142 + {% if tf.active %}checked{% endif %}
143 + {% if tf.count == 0 && !tf.active %}disabled{% endif %}
144 + hx-get="/discover/results"
145 + hx-target="#results-container"
146 + hx-indicator="#search-spinner"
147 + hx-include=".discover-filter"
148 + hx-trigger="change">
149 + <span>{{ tf.name }}</span>
150 + <span class="count" aria-label="{{ tf.count }} items">{{ tf.count }}</span>
151 + </label>
152 + </li>
153 + {% endif %}
154 + {% endfor %}
155 + </ul>
156 + </fieldset>
157 + </div>
158 +
159 + <div class="filter-section">
160 + <div class="filter-title" id="price-label">Price</div>
161 + <div class="price-inputs" role="group" aria-labelledby="price-label">
162 + <label for="min-price" class="sr-only">Minimum price</label>
163 + {# The applied bound is rendered back in: hx-include resends
164 + these on every request, so a blank input would silently
165 + clear the filter on the next interaction. #}
166 + <input type="number" id="min-price" name="min_price" placeholder="0" min="0"
167 + value="{{ sidebar.current_min_price }}"
168 + class="discover-filter"
169 + aria-label="Minimum price"
170 + hx-get="/discover/results"
171 + hx-trigger="change delay:500ms"
172 + hx-target="#results-container"
173 + hx-indicator="#search-spinner"
174 + hx-include=".discover-filter">
175 + <span aria-hidden="true">to</span>
176 + <label for="max-price" class="sr-only">Maximum price</label>
177 + <input type="number" id="max-price" name="max_price" placeholder="Any"
178 + value="{{ sidebar.current_max_price }}"
179 + class="discover-filter"
180 + aria-label="Maximum price"
181 + hx-get="/discover/results"
182 + hx-trigger="change delay:500ms"
183 + hx-target="#results-container"
184 + hx-indicator="#search-spinner"
185 + hx-include=".discover-filter">
186 + </div>
187 + {# Buckets are links, not form state, so they cannot disagree
188 + with the number inputs above and they work without JS. #}
189 + <ul class="filter-list price-buckets" aria-label="Price ranges">
190 + {% for pb in sidebar.price_buckets %}
191 + <li class="filter-item{% if pb.active %} is-selected{% endif %}">
192 + <a class="price-bucket" href="{{ pb.url }}"
193 + {% if pb.active %}aria-current="true"{% endif %}>
194 + <span>{{ pb.label }}</span>
195 + <span class="count" aria-label="{{ pb.count }} items">{{ pb.count }}</span>
196 + </a>
197 + </li>
198 + {% endfor %}
199 + </ul>
200 + </div>
201 +
202 + {% if !sidebar.ai_tier_filters.is_empty() %}
203 + <div class="filter-section">
204 + <fieldset class="filter-fieldset">
205 + {# Radios, not checkboxes: the three tiers are nested
206 + ranges, so OR-ing any two only widens to the looser. #}
207 + <legend class="filter-title">AI Disclosure</legend>
208 + <ul class="filter-list">
209 + {% for af in sidebar.ai_tier_filters %}
210 + <li class="filter-item{% if af.active %} is-selected{% endif %}">
211 + <label class="filter-check">
212 + <input type="radio" name="ai_tier" value="{{ af.value }}"
213 + id="aitier-{{ af.value }}"
214 + class="discover-filter"
215 + {% if af.active %}checked{% endif %}
216 + hx-get="/discover/results"
217 + hx-target="#results-container"
218 + hx-indicator="#search-spinner"
219 + hx-include=".discover-filter"
220 + hx-trigger="change">
221 + <span>{{ af.name }}</span>
222 + <span class="count" aria-label="{{ af.count }} items">{{ af.count }}</span>
223 + </label>
224 + </li>
225 + {% endfor %}
226 + </ul>
227 + </fieldset>
228 + </div>
229 + {% endif %}
230 + </div>
231 + {% endif %}
232 + </aside>
@@ -442,6 +442,72 @@ async fn price_filter_round_trips_into_its_inputs() {
442 442 }
443 443
444 444 // ---------------------------------------------------------------------------
445 + // Out-of-band sidebar refresh.
446 + // ---------------------------------------------------------------------------
447 +
448 + /// The results partial carries the sidebar for an out-of-band swap.
449 + ///
450 + /// Faceted browsing is steered by the counts, so a sidebar that only refreshes
451 + /// on a full page load is showing numbers for a filter the user has already
452 + /// moved past.
453 + #[tokio::test]
454 + async fn results_partial_carries_the_sidebar_out_of_band() {
455 + let mut h = TestHarness::new().await;
456 + make_discoverable_item(&mut h, "oobsidebar", "OOB Item", "audio").await;
457 +
458 + let resp = h.client.get("/discover/results?mode=items").await;
459 + assert!(resp.status.is_success(), "{}", resp.status);
460 + assert!(
461 + resp.text.contains(r#"hx-swap-oob="outerHTML:#discover-sidebar""#),
462 + "results must carry an OOB swap for the sidebar"
463 + );
464 + assert!(
465 + resp.text.contains(r#"id="discover-sidebar""#),
466 + "the OOB payload must include the sidebar element itself"
467 + );
468 + }
469 +
470 + /// The out-of-band sidebar reflects the filter that produced the results.
471 + #[tokio::test]
472 + async fn oob_sidebar_counts_track_the_active_filter() {
473 + let mut h = TestHarness::new().await;
474 + make_discoverable_item(&mut h, "oobaudio", "An Audio", "audio").await;
475 + make_discoverable_item(&mut h, "oobvideo", "A Video", "video").await;
476 +
477 + // Unfiltered: both types present, so the audio checkbox is not checked.
478 + let all = h.client.get("/discover/results?mode=items").await;
479 + assert!(all.text.contains(r#"id="typesel-audio""#), "audio facet should render");
480 + assert!(all.text.contains(r#"id="typesel-video""#), "video facet should render");
481 +
482 + // Filtered to audio: the audio control comes back checked in the OOB payload.
483 + let audio = h.client.get("/discover/results?mode=items&item_type=audio").await;
484 + assert!(
485 + audio.text.contains("checked"),
486 + "the OOB sidebar must reflect the active selection, not the previous one"
487 + );
488 + }
489 +
490 + /// Every sidebar control carries a stable id.
491 + ///
492 + /// htmx restores focus after a swap only to an element that has one. Without
493 + /// ids, replacing the sidebar on every filter change would drop a keyboard
494 + /// user back to the document body each time they tick a box.
495 + #[tokio::test]
496 + async fn sidebar_controls_have_stable_ids_for_focus_restoration() {
497 + let mut h = TestHarness::new().await;
498 + make_discoverable_item(&mut h, "focusid", "Focus Item", "audio").await;
499 +
500 + let resp = h.client.get("/discover?mode=items").await;
501 + assert!(resp.status.is_success(), "{}", resp.status);
502 + assert!(resp.text.contains(r#"id="typesel-audio""#), "type checkboxes need ids");
503 + assert!(resp.text.contains(r#"id="aitier-"#), "ai tier radios need ids");
504 + assert!(
505 + resp.text.contains(r#"hx-preserve="true""#),
506 + "the typeahead input holds unrendered user state and must be preserved"
507 + );
508 + }
509 +
510 + // ---------------------------------------------------------------------------
445 511 // Tag spine: drill-down and typeahead.
446 512 // ---------------------------------------------------------------------------
447 513