Skip to main content

max / makenotwork

Fix five discover sidebar bugs found by driving the page All five survived 1166 passing tests. Each needed a browser to see. Selections were silently dropped. The hidden inputs carrying off-screen selections lived in the page form, which the out-of-band swap never replaces, so a tag picked from one drill rung was lost the moment the drill navigated away from its checkbox. The carriers move into the sidebar, where the swap keeps them in step with the controls. The drill cursor reset to the tag roots on every filter click. `browse` was a URL parameter only, so a request rebuilt from .discover-filter carried no cursor. It is now a filter input too, which is what makes picking a second tag from the same rung possible at all. The total count went stale. #total-count sits in the page form, outside #results-container, so it read the previous filter's total: the header said "13 items" beside a list showing 3. It now swaps out of band with the sidebar. Zero-count drill rungs rendered identically to populated ones. They are links, not inputs, so the :has(input:disabled) rule never reached them; "Software 0" invited a click that led nowhere. They carry .is-empty now. Focus was lost on every filter change. htmx does restore focus after a swap, but only around the main swap target, and the sidebar is replaced out of band, so a focused control was destroyed outside that window and focus fell to <body>. An earlier commit claimed this worked from reading htmx's source; it does not. Restored explicitly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 21:47 UTC
Commit: fa17e7e6dc4c0b14d687dcaf2795790abd09032b
Parent: 9b6104d
8 files changed, +131 insertions, -13 deletions
@@ -356,6 +356,7 @@ async fn build_sidebar(
356 356 has_source: has_source_code,
357 357 active_filter_count,
358 358 viewer_authenticated: viewer_id.is_some(),
359 + browse_cursor: query.browse.clone().unwrap_or_default(),
359 360 })
360 361 }
361 362
@@ -151,4 +151,7 @@ pub struct SidebarView {
151 151 pub active_filter_count: u32,
152 152 /// Follow controls are only meaningful for a signed-in viewer.
153 153 pub viewer_authenticated: bool,
154 + /// The drill cursor, carried as a hidden input so it survives a filter
155 + /// interaction rather than snapping back to the tag roots.
156 + pub browse_cursor: String,
154 157 }
@@ -1,3 +1,26 @@
1 + // Restore focus across the out-of-band sidebar swap.
2 + //
3 + // htmx does restore focus after a swap, but only for the element it saved
4 + // around the MAIN swap target. The sidebar is replaced out-of-band, so a
5 + // focused filter control is destroyed outside that window and focus falls
6 + // to <body> — which drops a keyboard user out of the sidebar on every
7 + // single filter change. Verified in the browser, not assumed.
8 + (function () {
9 + var pending = null;
10 + document.body.addEventListener('htmx:beforeRequest', function () {
11 + var el = document.activeElement;
12 + pending = el && el.id && el.closest('#discover-sidebar') ? el.id : null;
13 + });
14 + document.body.addEventListener('htmx:afterSettle', function () {
15 + if (!pending) return;
16 + var el = document.getElementById(pending);
17 + pending = null;
18 + // Only if it actually went away and came back; a surviving element
19 + // kept its focus already.
20 + if (el && el !== document.activeElement) el.focus({ preventScroll: true });
21 + });
22 + })();
23 +
1 24 // Tag typeahead. Backed by tagtree's TagIndex server-side, so a tag is
2 25 // reachable by name from any depth without knowing where it sits.
3 26 (function() {
@@ -7519,8 +7519,10 @@ textarea:focus-visible {
7519 7519
7520 7520 /* A zero-count facet stays visible but inert, so the shape of the catalog
7521 7521 stays legible as filters narrow. No existing rule covers a disabled input
7522 - inside a label, so this is the checkbox counterpart of the button rule. */
7523 - .filter-item:has(input:disabled) { opacity: 0.5; }
7522 + inside a label, so this is the checkbox counterpart of the button rule.
7523 + `.is-empty` covers the drill rungs, which are links and carry no input at
7524 + all — they were rendering identically to rungs with items. */
7525 + .filter-item:has(input:disabled), .filter-item.is-empty { opacity: 0.5; }
7524 7526 .filter-item:has(input:disabled) .filter-check { cursor: not-allowed; }
7525 7527
7526 7528 /* Price buckets are links carrying a range, not form state. */
@@ -83,16 +83,6 @@
83 83 <option value="price_desc"{% if sort_by == "price_desc" %} selected{% endif %}>Price desc</option>
84 84 {% endif %}
85 85 </select>
86 - {# Only selections with no visible control on screen. The drill-down
87 - shows one rung at a time, so a tag chosen elsewhere in the tree
88 - has no checkbox to carry it. Anything that DOES have a checked
89 - checkbox is deliberately absent here, or it would submit twice. #}
90 - {% for value in sidebar.hidden_types %}
91 - <input type="hidden" name="item_type" value="{{ value }}" class="discover-filter" data-facet="item_type">
92 - {% endfor %}
93 - {% for value in sidebar.hidden_tags %}
94 - <input type="hidden" name="tag" value="{{ value }}" class="discover-filter" data-facet="tag">
95 - {% endfor %}
96 86 </div>
97 87 </form>
98 88
@@ -11,6 +11,12 @@
11 11 Guarded on `oob_sidebar`: the full page includes this same partial inside
12 12 #results-container, and rendering the sidebar there too would emit it twice
13 13 with duplicate ids. Only the standalone /discover/results response sets it. #}
14 + {# #total-count sits in the page form, outside #results-container, so a plain
15 + swap leaves it reading the previous filter's total. It said "13 items" beside
16 + a list showing 3. #}
17 + {% if oob_sidebar %}
18 + <span class="table-meta" id="total-count" hx-swap-oob="true">{{ total_items }} {% if mode == "projects" %}projects{% else %}items{% endif %}</span>
19 + {% endif %}
14 20 {% if oob_sidebar && mode == "items" %}
15 21 <div hx-swap-oob="outerHTML:#discover-sidebar">
16 22 {% include "partials/discover_sidebar.html" %}
@@ -2,6 +2,25 @@
2 2 swap, by partials/discover_results.html. Both templates expose the same
3 3 `sidebar` field so this include works unchanged in either context. #}
4 4 <aside class="discover-sidebar" id="discover-sidebar" aria-label="Filters">
5 + {# State carriers live INSIDE the sidebar so the out-of-band swap keeps
6 + them in step with the controls. They used to sit in the page form,
7 + which the swap never replaces: a tag selected from one drill rung was
8 + silently dropped as soon as the drill navigated away from its
9 + checkbox, because nothing carried it any more.
10 +
11 + Only selections with no visible control are listed; anything with a
12 + checked checkbox on screen is absent, or it would submit twice. #}
13 + {% for value in sidebar.hidden_types %}
14 + <input type="hidden" name="item_type" value="{{ value }}" class="discover-filter" data-facet="item_type">
15 + {% endfor %}
16 + {% for value in sidebar.hidden_tags %}
17 + <input type="hidden" name="tag" value="{{ value }}" class="discover-filter" data-facet="tag">
18 + {% endfor %}
19 + {# The drill cursor. Without this every filter interaction rebuilt the
20 + request from .discover-filter alone, which carries no cursor, so the
21 + sidebar snapped back to the tag roots on every click. #}
22 + <input type="hidden" name="browse" value="{{ sidebar.browse_cursor }}" class="discover-filter">
23 +
5 24 {% if mode == "projects" %}
6 25 <div class="filter-section">
7 26 <fieldset class="filter-fieldset">
@@ -100,7 +119,7 @@
100 119 {% endif %}
101 120 <ul class="filter-list">
102 121 {% for row in sidebar.tag_drill %}
103 - <li class="filter-item{% if row.selected %} is-selected{% endif %}">
122 + <li class="filter-item{% if row.selected %} is-selected{% endif %}{% if row.count == 0 %} is-empty{% endif %}">
104 123 {% if row.assignable %}
105 124 {# A real checkbox: multi-select is the point, and it
106 125 is keyboard-operable without any hx-trigger work. #}
@@ -519,6 +519,80 @@ async fn following_a_tag_is_reflected_in_the_sidebar() {
519 519 }
520 520
521 521 // ---------------------------------------------------------------------------
522 + // State survives the out-of-band swap
523 + //
524 + // All three were found by driving the page in a browser, not by the suite. The
525 + // sidebar is replaced wholesale on every filter change, so anything carrying
526 + // state has to live inside it; anything left outside silently stops tracking.
527 + // ---------------------------------------------------------------------------
528 +
529 + /// The state carriers live inside the swapped element.
530 + ///
531 + /// They used to sit in the page form, which the out-of-band swap never
532 + /// replaces. A tag selected from one drill rung was dropped as soon as the
533 + /// drill navigated away from its checkbox, because the hidden input that should
534 + /// have carried it was never re-rendered.
535 + #[tokio::test]
536 + async fn state_carriers_live_inside_the_swapped_sidebar() {
537 + let mut h = TestHarness::new().await;
538 + let (_, item) = make_discoverable_item(&mut h, "carrier", "Carried", "audio").await;
539 + tag_item(&h, &item, "audio.genre.electronic").await;
540 +
541 + // Browsing elsewhere while a tag is selected: the tag has no checkbox on
542 + // screen, so a hidden input must carry it, and it must be in the sidebar.
543 + let resp = h
544 + .client
545 + .get("/discover?mode=items&tag=audio.genre.electronic&browse=writing")
546 + .await;
547 + assert!(resp.status.is_success(), "{}", resp.status);
548 +
549 + let sidebar_start = resp.text.find(r#"id="discover-sidebar""#).expect("sidebar renders");
550 + let sidebar_end = resp.text[sidebar_start..].find("</aside>").expect("sidebar closes") + sidebar_start;
551 + let sidebar = &resp.text[sidebar_start..sidebar_end];
552 +
553 + assert!(
554 + sidebar.contains(r#"name="tag" value="audio.genre.electronic""#),
555 + "the off-screen tag must be carried by a hidden input inside the sidebar"
556 + );
557 + assert!(
558 + sidebar.contains(r#"name="browse""#),
559 + "the drill cursor must be carried inside the sidebar too"
560 + );
561 + }
562 +
563 + /// The drill cursor survives a filter interaction.
564 + ///
565 + /// Without a `browse` input in the `.discover-filter` set, every filter click
566 + /// rebuilt the request without a cursor and the sidebar snapped back to the tag
567 + /// roots, making the drill-down unusable for picking more than one tag.
568 + #[tokio::test]
569 + async fn drill_cursor_is_carried_as_a_filter_input() {
570 + let mut h = TestHarness::new().await;
571 + let resp = h.client.get("/discover?mode=items&browse=audio.genre").await;
572 + assert!(resp.status.is_success(), "{}", resp.status);
573 + assert!(
574 + resp.text.contains(r#"name="browse" value="audio.genre""#),
575 + "the cursor must round-trip as a filter input, not only as a URL param"
576 + );
577 + }
578 +
579 + /// The results partial refreshes the total count out of band.
580 + ///
581 + /// `#total-count` sits in the page form, outside `#results-container`, so a
582 + /// plain swap left it reading the previous filter's total: the header said
583 + /// "13 items" beside a list showing 3.
584 + #[tokio::test]
585 + async fn results_partial_refreshes_the_total_count() {
586 + let mut h = TestHarness::new().await;
587 + let resp = h.client.get("/discover/results?mode=items").await;
588 + assert!(resp.status.is_success(), "{}", resp.status);
589 + assert!(
590 + resp.text.contains(r#"id="total-count" hx-swap-oob="true""#),
591 + "the total count must be swapped out of band or it goes stale"
592 + );
593 + }
594 +
595 + // ---------------------------------------------------------------------------
522 596 // Markup/stylesheet contract.
523 597 // ---------------------------------------------------------------------------
524 598