max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
11 files changed,
+446 insertions,
-154 deletions
| @@ -2875,7 +2875,7 @@ | |||
| 2875 | 2875 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | |
| 2876 | 2876 | dependencies = [ | |
| 2877 | 2877 | "libc", | |
| 2878 | - | "windows-sys 0.52.0", | |
| 2878 | + | "windows-sys 0.59.0", | |
| 2879 | 2879 | ] | |
| 2880 | 2880 | ||
| 2881 | 2881 | [[package]] | |
| @@ -4811,7 +4811,7 @@ | |||
| 4811 | 4811 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4812 | 4812 | checksum = "82cb6a9f675da968c63b6208c641b9dca58fc0133ae53375736b1767b0cab8bd" | |
| 4813 | 4813 | dependencies = [ | |
| 4814 | - | "windows-sys 0.61.2", | |
| 4814 | + | "windows-sys 0.59.0", | |
| 4815 | 4815 | ] | |
| 4816 | 4816 | ||
| 4817 | 4817 | [[package]] | |
| @@ -5712,7 +5712,7 @@ | |||
| 5712 | 5712 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5713 | 5713 | checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" | |
| 5714 | 5714 | dependencies = [ | |
| 5715 | - | "windows-sys 0.61.2", | |
| 5715 | + | "windows-sys 0.59.0", | |
| 5716 | 5716 | ] | |
| 5717 | 5717 | ||
| 5718 | 5718 | [[package]] | |
| @@ -6626,7 +6626,7 @@ | |||
| 6626 | 6626 | ||
| 6627 | 6627 | [[package]] | |
| 6628 | 6628 | name = "quasi-axum" | |
| 6629 | - | version = "0.59.0" | |
| 6629 | + | version = "0.61.1" | |
| 6630 | 6630 | dependencies = [ | |
| 6631 | 6631 | "axum", | |
| 6632 | 6632 | "http 1.4.2", | |
| @@ -6637,7 +6637,7 @@ | |||
| 6637 | 6637 | ||
| 6638 | 6638 | [[package]] | |
| 6639 | 6639 | name = "quasi-basics" | |
| 6640 | - | version = "0.59.0" | |
| 6640 | + | version = "0.61.1" | |
| 6641 | 6641 | dependencies = [ | |
| 6642 | 6642 | "makeover-layout", | |
| 6643 | 6643 | "quasi-router", | |
| @@ -6645,7 +6645,7 @@ | |||
| 6645 | 6645 | ||
| 6646 | 6646 | [[package]] | |
| 6647 | 6647 | name = "quasi-http" | |
| 6648 | - | version = "0.59.0" | |
| 6648 | + | version = "0.61.1" | |
| 6649 | 6649 | dependencies = [ | |
| 6650 | 6650 | "form_urlencoded", | |
| 6651 | 6651 | "http 1.4.2", | |
| @@ -6654,7 +6654,7 @@ | |||
| 6654 | 6654 | ||
| 6655 | 6655 | [[package]] | |
| 6656 | 6656 | name = "quasi-router" | |
| 6657 | - | version = "0.59.0" | |
| 6657 | + | version = "0.61.1" | |
| 6658 | 6658 | dependencies = [ | |
| 6659 | 6659 | "makeover-layout", | |
| 6660 | 6660 | ] | |
| @@ -6677,7 +6677,7 @@ | |||
| 6677 | 6677 | ||
| 6678 | 6678 | [[package]] | |
| 6679 | 6679 | name = "quasi-webview" | |
| 6680 | - | version = "0.59.0" | |
| 6680 | + | version = "0.61.1" | |
| 6681 | 6681 | dependencies = [ | |
| 6682 | 6682 | "docengine", | |
| 6683 | 6683 | "makeover-layout", | |
| @@ -6753,7 +6753,7 @@ | |||
| 6753 | 6753 | "once_cell", | |
| 6754 | 6754 | "socket2", | |
| 6755 | 6755 | "tracing", | |
| 6756 | - | "windows-sys 0.52.0", | |
| 6756 | + | "windows-sys 0.59.0", | |
| 6757 | 6757 | ] | |
| 6758 | 6758 | ||
| 6759 | 6759 | [[package]] | |
| @@ -7316,7 +7316,7 @@ | |||
| 7316 | 7316 | "errno", | |
| 7317 | 7317 | "libc", | |
| 7318 | 7318 | "linux-raw-sys 0.4.15", | |
| 7319 | - | "windows-sys 0.52.0", | |
| 7319 | + | "windows-sys 0.59.0", | |
| 7320 | 7320 | ] | |
| 7321 | 7321 | ||
| 7322 | 7322 | [[package]] | |
| @@ -7329,7 +7329,7 @@ | |||
| 7329 | 7329 | "errno", | |
| 7330 | 7330 | "libc", | |
| 7331 | 7331 | "linux-raw-sys 0.12.1", | |
| 7332 | - | "windows-sys 0.52.0", | |
| 7332 | + | "windows-sys 0.59.0", | |
| 7333 | 7333 | ] | |
| 7334 | 7334 | ||
| 7335 | 7335 | [[package]] | |
| @@ -7409,7 +7409,7 @@ | |||
| 7409 | 7409 | "security-framework 3.7.0", | |
| 7410 | 7410 | "security-framework-sys", | |
| 7411 | 7411 | "webpki-root-certs", | |
| 7412 | - | "windows-sys 0.52.0", | |
| 7412 | + | "windows-sys 0.59.0", | |
| 7413 | 7413 | ] | |
| 7414 | 7414 | ||
| 7415 | 7415 | [[package]] | |
| @@ -8535,7 +8535,7 @@ | |||
| 8535 | 8535 | "getrandom 0.4.3", | |
| 8536 | 8536 | "once_cell", | |
| 8537 | 8537 | "rustix 1.1.4", | |
| 8538 | - | "windows-sys 0.52.0", | |
| 8538 | + | "windows-sys 0.59.0", | |
| 8539 | 8539 | ] | |
| 8540 | 8540 | ||
| 8541 | 8541 | [[package]] | |
| @@ -10751,11 +10751,11 @@ | |||
| 10751 | 10751 | ||
| 10752 | 10752 | [[patch.unused]] | |
| 10753 | 10753 | name = "quasi-immediate" | |
| 10754 | - | version = "0.59.0" | |
| 10754 | + | version = "0.61.1" | |
| 10755 | 10755 | ||
| 10756 | 10756 | [[patch.unused]] | |
| 10757 | 10757 | name = "quasi-notifs" | |
| 10758 | - | version = "0.59.0" | |
| 10758 | + | version = "0.61.1" | |
| 10759 | 10759 | ||
| 10760 | 10760 | [[patch.unused]] | |
| 10761 | 10761 | name = "quasi-store" | |
| @@ -10763,15 +10763,7 @@ | |||
| 10763 | 10763 | ||
| 10764 | 10764 | [[patch.unused]] | |
| 10765 | 10765 | name = "quasi-tauri" | |
| 10766 | - | version = "0.59.0" | |
| 10767 | - | ||
| 10768 | - | [[patch.unused]] | |
| 10769 | - | name = "synckit-client" | |
| 10770 | - | version = "0.9.1" | |
| 10771 | - | ||
| 10772 | - | [[patch.unused]] | |
| 10773 | - | name = "synckit-config" | |
| 10774 | - | version = "0.2.0" | |
| 10766 | + | version = "0.61.1" | |
| 10775 | 10767 | ||
| 10776 | 10768 | [[patch.unused]] | |
| 10777 | 10769 | name = "kberg" | |
| @@ -10784,3 +10776,11 @@ | |||
| 10784 | 10776 | [[patch.unused]] | |
| 10785 | 10777 | name = "painhours" | |
| 10786 | 10778 | version = "0.1.0" | |
| 10779 | + | ||
| 10780 | + | [[patch.unused]] | |
| 10781 | + | name = "synckit-client" | |
| 10782 | + | version = "0.9.1" | |
| 10783 | + | ||
| 10784 | + | [[patch.unused]] | |
| 10785 | + | name = "synckit-config" | |
| 10786 | + | version = "0.2.0" |
| @@ -87,49 +87,3 @@ | |||
| 87 | 87 | } | |
| 88 | 88 | }); | |
| 89 | 89 | })(); | |
| 90 | - | ||
| 91 | - | // Search suggestions autocomplete | |
| 92 | - | (function() { | |
| 93 | - | var input = document.getElementById('search-input'); | |
| 94 | - | var box = document.getElementById('search-suggestions'); | |
| 95 | - | var timer = null; | |
| 96 | - | var selectedIdx = -1; | |
| 97 | - | ||
| 98 | - | input.addEventListener('input', function() { | |
| 99 | - | clearTimeout(timer); | |
| 100 | - | var q = input.value.trim(); | |
| 101 | - | if (q.length < 2) { box.innerHTML = ''; box.style.display = 'none'; return; } | |
| 102 | - | timer = setTimeout(function() { | |
| 103 | - | fetch('/discover/suggestions?q=' + encodeURIComponent(q)) | |
| 104 | - | .then(function(r) { return r.json(); }) | |
| 105 | - | .then(function(items) { | |
| 106 | - | if (items.length === 0) { box.innerHTML = ''; box.style.display = 'none'; return; } | |
| 107 | - | selectedIdx = -1; | |
| 108 | - | box.innerHTML = items.map(function(s, i) { | |
| 109 | - | return '<a href="' + escapeHtml(s.url) + '" class="suggestion-item" data-idx="' + i + '">' | |
| 110 | - | + '<span class="suggestion-label">' + escapeHtml(s.label) + '</span>' | |
| 111 | - | + '<span class="suggestion-category">' + escapeHtml(s.category) + '</span></a>'; | |
| 112 | - | }).join(''); | |
| 113 | - | box.style.display = 'block'; | |
| 114 | - | }); | |
| 115 | - | }, 200); | |
| 116 | - | }); | |
| 117 | - | ||
| 118 | - | input.addEventListener('keydown', function(e) { | |
| 119 | - | var items = box.querySelectorAll('.suggestion-item'); | |
| 120 | - | if (!items.length) return; | |
| 121 | - | if (e.key === 'ArrowDown') { e.preventDefault(); selectedIdx = Math.min(selectedIdx + 1, items.length - 1); updateHighlight(items); } | |
| 122 | - | else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIdx = Math.max(selectedIdx - 1, -1); updateHighlight(items); } | |
| 123 | - | else if (e.key === 'Enter' && selectedIdx >= 0) { e.preventDefault(); items[selectedIdx].click(); } | |
| 124 | - | else if (e.key === 'Escape') { box.style.display = 'none'; } | |
| 125 | - | }); | |
| 126 | - | ||
| 127 | - | function updateHighlight(items) { | |
| 128 | - | items.forEach(function(el, i) { el.classList.toggle('highlighted', i === selectedIdx); }); | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | ||
| 132 | - | document.addEventListener('click', function(e) { | |
| 133 | - | if (!box.contains(e.target) && e.target !== input) { box.style.display = 'none'; } | |
| 134 | - | }); | |
| 135 | - | })(); |
| @@ -7928,7 +7928,7 @@ | |||
| 7928 | 7928 | Tags are the sidebar's structure, not one facet among several, so the spine | |
| 7929 | 7929 | reads as the primary surface and .filter-refine below it is subordinate. | |
| 7930 | 7930 | Everything here composes existing primitives: .filter-item carries the row | |
| 7931 | - | and its .is-selected recipe, .search-suggestions carries the dropdown shell, | |
| 7931 | + | and its .is-selected recipe, the described field draws its own list, and | |
| 7932 | 7932 | .breadcrumb carries the crumb idiom. */ | |
| 7933 | 7933 | .tag-spine { padding-bottom: var(--gap-section); border-bottom: 1px solid var(--border); } | |
| 7934 | 7934 | ||
| @@ -7943,9 +7943,9 @@ | |||
| 7943 | 7943 | .tag-chip-clear a { text-decoration: none; color: var(--content-secondary); font-size: var(--text-fine); } | |
| 7944 | 7944 | .tag-chip-clear a:hover { color: var(--content); } | |
| 7945 | 7945 | ||
| 7946 | - | /* Typeahead. Shares the .search-suggestions shell; it toggles with the | |
| 7947 | - | `hidden` attribute rather than an inline display, so the shared rule's | |
| 7948 | - | display:none has to be overridden when open. */ | |
| 7946 | + | /* Typeahead. The box, the list and everything that opens and closes it are | |
| 7947 | + | the described field's, so all this carries is the positioning context the | |
| 7948 | + | list is drawn against. */ | |
| 7949 | 7949 | .tag-combobox { position: relative; margin-bottom: var(--gap-section); } | |
| 7950 | 7950 | ||
| 7951 | 7951 | /* Drill-down. A rung either selects (assignable leaf) or navigates (category); | |
| @@ -7986,9 +7986,6 @@ | |||
| 7986 | 7986 | .toggle-btn:hover { opacity: 0.7; } | |
| 7987 | 7987 | .toggle-btn.is-selected { background: var(--primary-dark); color: var(--primary-light); } | |
| 7988 | 7988 | .table-controls { background: var(--surface-sunken); display: flex; align-items: center; gap: var(--gap-section); padding: var(--gap-peer) var(--gap-section); } | |
| 7989 | - | .search-field { flex: 1 1 0; background: var(--surface-page); border: none; padding: var(--gap-peer) var(--gap-section); font-family: inherit; font-size: var(--text-note); color: var(--content); min-width: 0; } | |
| 7990 | - | .search-field:focus { outline: 2px solid var(--action); } | |
| 7991 | - | .search-field::placeholder { opacity: 0.5; } | |
| 7992 | 7989 | .table-meta { font-size: var(--text-fine); opacity: 0.6; white-space: nowrap; flex-shrink: 0; } | |
| 7993 | 7990 | .sort-select { background: var(--surface-page); border: none; padding: var(--gap-bound) var(--gap-peer); font-size: var(--text-fine); font-family: inherit; cursor: pointer; width: auto; flex-shrink: 0; } | |
| 7994 | 7991 | .table-header { display: grid; grid-template-columns: 50px 1fr 100px 70px 70px; gap: var(--gap-peer); padding: var(--gap-peer) var(--gap-section); background: var(--surface-overlay); font-size: var(--text-fine); opacity: 0.7; text-transform: uppercase; letter-spacing: 0.03em; } | |
| @@ -8087,7 +8084,6 @@ | |||
| 8087 | 8084 | .table-header.projects-header, .table-row.project-row { grid-template-columns: 1fr; } | |
| 8088 | 8085 | .table-header.projects-header span:nth-child(2) { display: none; } | |
| 8089 | 8086 | .row-price { font-size: var(--text-fine); } | |
| 8090 | - | .search-field { min-width: 0; width: 100%; } | |
| 8091 | 8087 | .table-controls { flex-wrap: wrap; } | |
| 8092 | 8088 | .mode-toggle { flex-wrap: wrap; } | |
| 8093 | 8089 | .table-footer { flex-direction: column; gap: var(--gap-peer); align-items: flex-start; } | |
| @@ -8336,22 +8332,14 @@ | |||
| 8336 | 8332 | button.saved { border-color: var(--action); color: var(--action); } | |
| 8337 | 8333 | ||
| 8338 | 8334 | /* =========================================== | |
| 8339 | - | SEARCH SUGGESTIONS | |
| 8340 | - | =========================================== */ | |
| 8335 | + | SEARCH BOX | |
| 8336 | + | =========================================== | |
| 8337 | + | The box and its suggestion list are a described Field (N8), which draws its | |
| 8338 | + | own control and its own list. What is left here is where the toolbar puts | |
| 8339 | + | it: a flex child that takes the rest of the row, and the positioning context | |
| 8340 | + | the list is absolutely placed against. */ | |
| 8341 | 8341 | ||
| 8342 | 8342 | .search-wrapper { position: relative; flex: 1; min-width: 0; } | |
| 8343 | - | .search-suggestions { | |
| 8344 | - | display: none; position: absolute; left: 0; right: 0; top: 100%; z-index: var(--z-picker); | |
| 8345 | - | background: var(--surface-page); border: 1px solid var(--border); border-top: none; | |
| 8346 | - | box-shadow: var(--elevation-overlay); max-height: 280px; overflow-y: auto; | |
| 8347 | - | } | |
| 8348 | - | .suggestion-item { | |
| 8349 | - | display: flex; justify-content: space-between; align-items: center; | |
| 8350 | - | padding: var(--gap-peer) var(--gap-section); text-decoration: none; color: var(--content); | |
| 8351 | - | font-size: var(--text-note); cursor: pointer; | |
| 8352 | - | } | |
| 8353 | - | .suggestion-item:hover, .suggestion-item.highlighted { background: var(--surface-sunken); } | |
| 8354 | - | .suggestion-category { font-size: var(--text-fine); opacity: 0.5; text-transform: uppercase; letter-spacing: 0.05em; } | |
| 8355 | 8343 | ||
| 8356 | 8344 | /* =========================================== | |
| 8357 | 8345 | DOC UI EXAMPLES (embedded live UI in docs) |
| @@ -33,6 +33,7 @@ | |||
| 33 | 33 | ||
| 34 | 34 | pub mod blog_delete_act; | |
| 35 | 35 | pub mod buyer_contacts; | |
| 36 | + | pub mod discover_search; | |
| 36 | 37 | pub mod discover_typeahead; | |
| 37 | 38 | pub mod embeds; | |
| 38 | 39 | pub mod export_act; |
| @@ -35,31 +35,35 @@ | |||
| 35 | 35 | ||
| 36 | 36 | {# `tag-added` is fired by the typeahead once it appends a hidden | |
| 37 | 37 | input, so picking a suggestion refreshes results through the same | |
| 38 | - | path as every other filter control. #} | |
| 38 | + | path as every other filter control. | |
| 39 | + | ||
| 40 | + | `#q` beside `.discover-filter` in every include on this screen: | |
| 41 | + | the search box is a described Field now and carries the classes | |
| 42 | + | its renderer writes, not one this repo chose. Its id is the | |
| 43 | + | field's own name, which is the handle the description already | |
| 44 | + | had. Every control that re-reads the results needs the search | |
| 45 | + | value along, and a class on markup nobody here writes is not | |
| 46 | + | something to reach for. #} | |
| 39 | 47 | <form id="discover-form" action="/discover" method="get" | |
| 40 | 48 | hx-get="/discover/results" | |
| 41 | 49 | hx-target="#results-container" hx-swap="outerHTML" | |
| 42 | 50 | hx-indicator="#search-spinner" | |
| 43 | - | hx-include=".discover-filter" | |
| 51 | + | hx-include=".discover-filter, #q" | |
| 44 | 52 | hx-trigger="tag-added"> | |
| 45 | 53 | <input type="hidden" id="mode-input" name="mode" value="{{ mode }}" class="discover-filter"> | |
| 46 | 54 | <div class="table-controls"> | |
| 47 | - | <label for="search-input" class="sr-only">Search {% if mode == "projects" %}projects{% else %}items{% endif %}</label> | |
| 55 | + | {# Described (N8): the box, the list it owns, the route it | |
| 56 | + | asks for candidates, the two waits, the floor, the | |
| 57 | + | filters that ride with the results question and the page | |
| 58 | + | picking a row goes to all come out of crate::quasi. What | |
| 59 | + | stood here was a bare input and an empty list pointed at | |
| 60 | + | each other by id, with the rest in page-discover.js. | |
| 61 | + | ||
| 62 | + | The wrapper stays: it is the positioning context the | |
| 63 | + | list is drawn against, and the flex child the toolbar | |
| 64 | + | row sizes. #} | |
| 48 | 65 | <div class="search-wrapper"> | |
| 49 | - | <input type="text" | |
| 50 | - | id="search-input" | |
| 51 | - | class="search-field discover-filter" | |
| 52 | - | placeholder="Search {% if mode == "projects" %}projects{% else %}items{% endif %}..." | |
| 53 | - | name="q" | |
| 54 | - | value="{{ search_query }}" | |
| 55 | - | autocomplete="off" | |
| 56 | - | aria-label="Search {% if mode == "projects" %}projects{% else %}items{% endif %}" | |
| 57 | - | hx-get="/discover/results" | |
| 58 | - | hx-trigger="input changed delay:150ms, search" | |
| 59 | - | hx-target="#results-container" hx-swap="outerHTML" | |
| 60 | - | hx-indicator="#search-spinner" | |
| 61 | - | hx-include=".discover-filter"> | |
| 62 | - | <div id="search-suggestions" class="search-suggestions"></div> | |
| 66 | + | {{ crate::quasi::discover_search::search_box(mode, search_query)|safe }} | |
| 63 | 67 | </div> | |
| 64 | 68 | <span id="search-spinner" class="htmx-indicator" aria-live="polite">Searching...</span> | |
| 65 | 69 | <span class="table-meta" id="total-count">{{ count_label }}</span> | |
| @@ -72,7 +76,7 @@ | |||
| 72 | 76 | hx-trigger="change" | |
| 73 | 77 | hx-target="#results-container" hx-swap="outerHTML" | |
| 74 | 78 | hx-indicator="#search-spinner" | |
| 75 | - | hx-include=".discover-filter"> | |
| 79 | + | hx-include=".discover-filter, #q"> | |
| 76 | 80 | {% if mode == "projects" %} | |
| 77 | 81 | <option value="newest"{% if sort_by == "newest" || sort_by == "" %} selected{% endif %}>Newest</option> | |
| 78 | 82 | <option value="most_sold"{% if sort_by == "most_sold" %} selected{% endif %}>Most items</option> | |
| @@ -114,5 +118,5 @@ | |||
| 114 | 118 | ||
| 115 | 119 | {% block scripts %} | |
| 116 | 120 | <script src="/static/collections.js?v=0514"></script> | |
| 117 | - | <script src="/static/page-discover.js?v=0623" defer></script> | |
| 121 | + | <script src="/static/page-discover.js?v=0624" defer></script> | |
| 118 | 122 | {% endblock %} |
| @@ -196,27 +196,27 @@ | |||
| 196 | 196 | <button {% if current_page <= 1 %}disabled{% endif %} | |
| 197 | 197 | hx-get="/discover/results" | |
| 198 | 198 | hx-target="#results-container" hx-swap="outerHTML" | |
| 199 | - | hx-include=".discover-filter" | |
| 199 | + | hx-include=".discover-filter, #q" | |
| 200 | 200 | hx-vals='{"page": {{ current_page - 1 }}}' | |
| 201 | 201 | {% if current_page <= 1 %}hx-disable="this"{% endif %}>Prev</button> | |
| 202 | 202 | {% for p in pagination_range %} | |
| 203 | 203 | <button {% if *p == current_page %}class="is-selected"{% endif %} | |
| 204 | 204 | hx-get="/discover/results" | |
| 205 | 205 | hx-target="#results-container" hx-swap="outerHTML" | |
| 206 | - | hx-include=".discover-filter" | |
| 206 | + | hx-include=".discover-filter, #q" | |
| 207 | 207 | hx-vals='{"page": {{ p }}}'>{{ p }}</button> | |
| 208 | 208 | {% endfor %} | |
| 209 | 209 | {% if total_pages > 5 %} | |
| 210 | 210 | <button disabled>...</button> | |
| 211 | 211 | <button hx-get="/discover/results" | |
| 212 | 212 | hx-target="#results-container" hx-swap="outerHTML" | |
| 213 | - | hx-include=".discover-filter" | |
| 213 | + | hx-include=".discover-filter, #q" | |
| 214 | 214 | hx-vals='{"page": {{ total_pages }}}'>{{ total_pages }}</button> | |
| 215 | 215 | {% endif %} | |
| 216 | 216 | <button {% if current_page >= total_pages || total_pages == 0 %}disabled{% endif %} | |
| 217 | 217 | hx-get="/discover/results" | |
| 218 | 218 | hx-target="#results-container" hx-swap="outerHTML" | |
| 219 | - | hx-include=".discover-filter" | |
| 219 | + | hx-include=".discover-filter, #q" | |
| 220 | 220 | hx-vals='{"page": {{ current_page + 1 }}}' | |
| 221 | 221 | {% if current_page >= total_pages || total_pages == 0 %}hx-disable="this"{% endif %}>Next</button> | |
| 222 | 222 | {% endif %} |
| @@ -41,7 +41,7 @@ | |||
| 41 | 41 | hx-get="/discover/results" | |
| 42 | 42 | hx-target="#results-container" hx-swap="outerHTML" | |
| 43 | 43 | hx-indicator="#search-spinner" | |
| 44 | - | hx-include=".discover-filter" | |
| 44 | + | hx-include=".discover-filter, #q" | |
| 45 | 45 | hx-trigger="change"> | |
| 46 | 46 | <span>{{ cf.name }}</span> | |
| 47 | 47 | <span class="count" aria-label="{{ cf.count }} projects">{{ cf.count }}</span> | |
| @@ -61,7 +61,7 @@ | |||
| 61 | 61 | hx-trigger="change" | |
| 62 | 62 | hx-target="#results-container" hx-swap="outerHTML" | |
| 63 | 63 | hx-indicator="#search-spinner" | |
| 64 | - | hx-include=".discover-filter"> | |
| 64 | + | hx-include=".discover-filter, #q"> | |
| 65 | 65 | Has source code | |
| 66 | 66 | </label> | |
| 67 | 67 | </div> | |
| @@ -132,7 +132,7 @@ | |||
| 132 | 132 | hx-get="/discover/results" | |
| 133 | 133 | hx-target="#results-container" hx-swap="outerHTML" | |
| 134 | 134 | hx-indicator="#search-spinner" | |
| 135 | - | hx-include=".discover-filter" | |
| 135 | + | hx-include=".discover-filter, #q" | |
| 136 | 136 | hx-trigger="change"> | |
| 137 | 137 | <span>{{ row.label }}</span> | |
| 138 | 138 | <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span> | |
| @@ -199,7 +199,7 @@ | |||
| 199 | 199 | hx-get="/discover/results" | |
| 200 | 200 | hx-target="#results-container" hx-swap="outerHTML" | |
| 201 | 201 | hx-indicator="#search-spinner" | |
| 202 | - | hx-include=".discover-filter" | |
| 202 | + | hx-include=".discover-filter, #q" | |
| 203 | 203 | hx-trigger="change"> | |
| 204 | 204 | <span>{{ tf.name }}</span> | |
| 205 | 205 | <span class="count" aria-label="{{ tf.count }} items">{{ tf.count }}</span> | |
| @@ -230,7 +230,7 @@ | |||
| 230 | 230 | hx-trigger="change delay:500ms" | |
| 231 | 231 | hx-target="#results-container" hx-swap="outerHTML" | |
| 232 | 232 | hx-indicator="#search-spinner" | |
| 233 | - | hx-include=".discover-filter"> | |
| 233 | + | hx-include=".discover-filter, #q"> | |
| 234 | 234 | <span aria-hidden="true">to</span> | |
| 235 | 235 | <label for="max-price" class="sr-only">Maximum price in dollars</label> | |
| 236 | 236 | <input type="number" id="max-price" name="max_price" placeholder="Any" min="0" step="0.01" | |
| @@ -241,7 +241,7 @@ | |||
| 241 | 241 | hx-trigger="change delay:500ms" | |
| 242 | 242 | hx-target="#results-container" hx-swap="outerHTML" | |
| 243 | 243 | hx-indicator="#search-spinner" | |
| 244 | - | hx-include=".discover-filter"> | |
| 244 | + | hx-include=".discover-filter, #q"> | |
| 245 | 245 | </div> | |
| 246 | 246 | {# Buckets are links, not form state, so they cannot disagree | |
| 247 | 247 | with the number inputs above and they work without JS. #} | |
| @@ -275,7 +275,7 @@ | |||
| 275 | 275 | hx-get="/discover/results" | |
| 276 | 276 | hx-target="#results-container" hx-swap="outerHTML" | |
| 277 | 277 | hx-indicator="#search-spinner" | |
| 278 | - | hx-include=".discover-filter" | |
| 278 | + | hx-include=".discover-filter, #q" | |
| 279 | 279 | hx-trigger="change"> | |
| 280 | 280 | <span>{{ af.name }}</span> | |
| 281 | 281 | <span class="count" aria-label="{{ af.count }} items">{{ af.count }}</span> |
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | //! Covers the four discover endpoints: | |
| 4 | 4 | //! - GET /discover (full page, faceted) | |
| 5 | 5 | //! - GET /discover/results (HTMX results partial) | |
| 6 | - | //! - GET /discover/suggestions (JSON search suggestions) | |
| 6 | + | //! - GET /discover/suggestions (the search box's described candidate list) | |
| 7 | 7 | //! - GET /discover/tags (tag tree partial) | |
| 8 | 8 | //! | |
| 9 | 9 | //! Privacy invariants that must hold across all of these (verified per-test): | |
| @@ -371,23 +371,80 @@ | |||
| 371 | 371 | ); | |
| 372 | 372 | } | |
| 373 | 373 | ||
| 374 | + | /// The route answers about the value it was given. It answered a JSON array | |
| 375 | + | /// until `e3615a0d`; what it answers now is the described suggestion list, and | |
| 376 | + | /// what is being asserted either way is that a typed value comes back as | |
| 377 | + | /// something to offer. | |
| 374 | 378 | #[tokio::test] | |
| 375 | - | async fn suggestions_endpoint_returns_json() { | |
| 379 | + | async fn the_suggestions_endpoint_answers_about_what_was_typed() { | |
| 376 | 380 | let mut h = TestHarness::new().await; | |
| 377 | 381 | make_discoverable_item(&mut h, "suggcreator", "Suggestion Probe", "digital").await; | |
| 378 | 382 | ||
| 379 | - | let resp = h.client.get("/discover/suggestions?q=Suggestion").await; | |
| 383 | + | // By the creator's name: the route offers tags, projects and creators, and | |
| 384 | + | // the harness names the creator but not the project it makes. | |
| 385 | + | let resp = h.client.get("/discover/suggestions?q=suggcreator").await; | |
| 380 | 386 | assert_eq!( | |
| 381 | 387 | resp.status, 200, | |
| 382 | 388 | "GET suggestions: {} {}", | |
| 383 | 389 | resp.status, resp.text | |
| 384 | 390 | ); | |
| 385 | - | // Response is `Vec<SearchSuggestion>`, must parse as JSON array. | |
| 386 | - | let parsed: serde_json::Value = resp.json(); | |
| 387 | 391 | assert!( | |
| 388 | - | parsed.is_array(), | |
| 389 | - | "Suggestions response must be a JSON array" | |
| 392 | + | resp.text.contains(r#"role="option""#), | |
| 393 | + | "rows are options in the list the field owns: {}", | |
| 394 | + | resp.text | |
| 390 | 395 | ); | |
| 396 | + | assert!( | |
| 397 | + | resp.text.contains("suggcreator"), | |
| 398 | + | "the creator it was asked about is offered: {}", | |
| 399 | + | resp.text | |
| 400 | + | ); | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | /// N8's second site. Each candidate is a page, so picking one replaces the | |
| 404 | + | /// document: the row is a link the browser follows and htmx is not involved. | |
| 405 | + | /// An `hx-get` here would land a whole page inside the suggestion row, which | |
| 406 | + | /// is the failure `00ee7af5` was ruled on and the reason this waited. | |
| 407 | + | #[tokio::test] | |
| 408 | + | async fn a_suggested_page_is_a_link_the_browser_follows() { | |
| 409 | + | let mut h = TestHarness::new().await; | |
| 410 | + | make_discoverable_item(&mut h, "navcreator", "Navigating Probe", "digital").await; | |
| 411 | + | ||
| 412 | + | let resp = h.client.get("/discover/suggestions?q=navcreator").await; | |
| 413 | + | assert_eq!(resp.status, 200, "{}", resp.text); | |
| 414 | + | assert!( | |
| 415 | + | resp.text.contains("<a class=\"form-suggestion\""), | |
| 416 | + | "a pick that navigates is an anchor: {}", | |
| 417 | + | resp.text | |
| 418 | + | ); | |
| 419 | + | assert!( | |
| 420 | + | resp.text.contains("href=\"/u/navcreator\""), | |
| 421 | + | "and it carries the page it goes to: {}", | |
| 422 | + | resp.text | |
| 423 | + | ); | |
| 424 | + | assert!( | |
| 425 | + | !resp.text.contains("hx-get"), | |
| 426 | + | "no htmx on a navigation: {}", | |
| 427 | + | resp.text | |
| 428 | + | ); | |
| 429 | + | // What kind of page it is, which is what tells two rows reading alike | |
| 430 | + | // apart, and it is its own element rather than part of the label. | |
| 431 | + | assert!( | |
| 432 | + | resp.text.contains("form-suggestion-detail"), | |
| 433 | + | "{}", | |
| 434 | + | resp.text | |
| 435 | + | ); | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | /// Below the floor the box does not ask at all, and asked with nothing the | |
| 439 | + | /// route answers nothing rather than an empty document: the answer replaces | |
| 440 | + | /// the list the field owns. | |
| 441 | + | #[tokio::test] | |
| 442 | + | async fn an_empty_question_offers_nothing() { | |
| 443 | + | let mut h = TestHarness::new().await; | |
| 444 | + | ||
| 445 | + | let resp = h.client.get("/discover/suggestions?q=%20").await; | |
| 446 | + | assert_eq!(resp.status, 200, "{}", resp.text); | |
| 447 | + | assert!(resp.text.trim().is_empty(), "{}", resp.text); | |
| 391 | 448 | } | |
| 392 | 449 | ||
| 393 | 450 | #[tokio::test] |
| @@ -203,13 +203,17 @@ | |||
| 203 | 203 | let mut h = TestHarness::new().await; | |
| 204 | 204 | ||
| 205 | 205 | // The search box is debounced, so pushing would leave an entry per keystroke pause. | |
| 206 | + | // | |
| 207 | + | // `div` and not `input#search-input`: the box is a described Field now, and | |
| 208 | + | // a consult hangs its route on a wrapper around the control, which carries | |
| 209 | + | // no id of its own. See `discover_results`. | |
| 206 | 210 | let resp = h | |
| 207 | 211 | .client | |
| 208 | 212 | .request_with_headers( | |
| 209 | 213 | "GET", | |
| 210 | 214 | "/discover/results?mode=items&q=ambient", | |
| 211 | 215 | None, | |
| 212 | - | &[("HX-Request", "true"), ("HX-Source", "input#search-input")], | |
| 216 | + | &[("HX-Request", "true"), ("HX-Source", "div")], | |
| 213 | 217 | ) | |
| 214 | 218 | .await; | |
| 215 | 219 | assert_eq!(resp.status, 200); | |
| @@ -265,14 +269,16 @@ | |||
| 265 | 269 | "AI tier should be radios: its three tiers are nested ranges, not independent values" | |
| 266 | 270 | ); | |
| 267 | 271 | ||
| 268 | - | // The one listbox on the page is the tag typeahead, and it must declare the | |
| 269 | - | // wiring it actually implements. This assertion previously banned listbox | |
| 270 | - | // roles outright, because the only one on the page was a fake. | |
| 271 | - | assert!( | |
| 272 | - | resp.text.contains(r#"role="combobox""#), | |
| 273 | - | "the tag typeahead input should declare combobox" | |
| 272 | + | // Two comboboxes on the page and a listbox each: the sidebar's tag | |
| 273 | + | // typeahead and the search box beside the results, both described (N8). | |
| 274 | + | // These assertions previously banned listbox roles outright, because the | |
| 275 | + | // only one on the page was a fake. | |
| 276 | + | assert_eq!( | |
| 277 | + | resp.text.matches(r#"role="combobox""#).count(), | |
| 278 | + | 2, | |
| 279 | + | "the tag typeahead and the search box both declare combobox" | |
| 274 | 280 | ); | |
| 275 | - | // Derived from the field's name rather than authored, which is what the | |
| 281 | + | // Derived from each field's name rather than authored, which is what the | |
| 276 | 282 | // field owning its list buys: the box's id is the name and the list's is | |
| 277 | 283 | // that plus a suffix, so the two cannot come apart. (N8.) | |
| 278 | 284 | assert!( | |
| @@ -280,10 +286,14 @@ | |||
| 280 | 286 | .contains(r#"aria-controls="tag-search-suggestions""#), | |
| 281 | 287 | "the combobox must point at the listbox it controls" | |
| 282 | 288 | ); | |
| 289 | + | assert!( | |
| 290 | + | resp.text.contains(r#"aria-controls="q-suggestions""#), | |
| 291 | + | "and so must the search box" | |
| 292 | + | ); | |
| 283 | 293 | assert_eq!( | |
| 284 | 294 | resp.text.matches(r#"role="listbox""#).count(), | |
| 285 | - | 1, | |
| 286 | - | "the typeahead should be the only listbox; filter lists must not claim the role" | |
| 295 | + | 2, | |
| 296 | + | "one listbox per described box; filter lists must not claim the role" | |
| 287 | 297 | ); | |
| 288 | 298 | assert!( | |
| 289 | 299 | !resp.text.contains(r#"role="option""#), |