search: make the navigation form work from every page (AUD-151)

The nav form carried hx-get="/search" hx-target="#search-results", but
#search-results exists only on /search. On every other page htmx found no
target and silently discarded the response: a search box that looks like it
works and does nothing.

- SearchForm is now a plain GET with no htmx attributes (navigation and the
  landing page), so it navigates to /search?q=… from any page and with
  JavaScript disabled.
- SearchFormLive keeps the in-place swap on the search page, where the target
  does exist. For HX-Request the server now returns the block's *contents*
  (SearchResultsBody), so the fragment no longer nests a second element with
  id="search-results" inside the live region.
- Empty query shows a prompt instead of an empty list. A zero-match query says
  "Nothing found" and offers the six areas plus the evidence index.
- The result count is role="status" inside the existing aria-live container, so
  a screen reader reads "4 results for …" rather than a run of titles.

Tests (internal/server, internal/site):
- the nav form carries no htmx attribute on nine pages, and no built page
  carries an hx-target whose element is missing (walks all of dist/);
- search submitted from /, /edge/ and /platform/ lands on a page with results
  with no HX-Request header, i.e. the JavaScript-disabled path;
- empty query, zero match and the announced count;
- a content change followed by a build is visible in dist/search.db. That is
  the deploy path: deploy/deploy.sh runs `make dist` before it stages a
  release, and search.Build removes and rewrites the index every build.

DoD: search from home, /edge and /platform lands on results; works with
JavaScript disabled; zero-match query shows a message; content change followed
by a deploy is reflected in search; no htmx request targets a missing element.

(cherry picked from commit a56c795768e1e9b10204aae04c3612d7a96775a7)
This commit is contained in:
2026-10-28 15:45:54 +11:00
parent 286aa12bb0
commit d793ab0871
8 changed files with 400 additions and 36 deletions

View File

@@ -135,40 +135,67 @@ templ SearchPage(v SearchView) {
<h1>Search { strconv.Itoa(v.Indexed) } pages</h1>
<p class="doc-claim">Full-text search over page titles and body text. Served from a local index — no model, no network call.</p>
</header>
@SearchForm(v.Query, "page-search")
@SearchFormLive(v.Query, "page-search")
@SearchResults(v)
</article>
}
}
// SearchResults is swapped into #search-results by HTMX.
// SearchResults is the results block as a page element: the live region wrapper
// plus its contents, which is what the whole page renders. Without JavaScript the
// form reloads the page and this markup arrives in the document.
templ SearchResults(v SearchView) {
<div id="search-results" aria-live="polite">
if v.Query == "" {
<p class="section-note">Try “zigbee”, “signage”, “routing”, “backups” or “guardrails”.</p>
} else if v.Failed {
<p class="search-empty">That query could not be run. Try fewer or simpler words.</p>
} else if len(v.Results) == 0 {
<p class="search-empty">No page matches <q>{ v.Query }</q>. The <a href="/evidence/">evidence index</a> lists everything by area.</p>
} else {
<p class="search-count">{ strconv.Itoa(len(v.Results)) } results for <q>{ v.Query }</q></p>
<ol class="results">
for _, r := range v.Results {
<li class="result">
<h2><a href={ templ.SafeURL(r.URL) }>{ r.Title }</a></h2>
<p class="result-meta">
<span class="kind">{ r.Area }</span>
<span class="kind">L{ strconv.Itoa(r.Level) }</span>
<span class={ ConfidenceClass(r.Confidence) }>{ r.Confidence }</span>
</p>
<p class="result-snippet">@SafeSnippet(r.Snippet)</p>
</li>
}
</ol>
}
@SearchResultsBody(v)
</div>
}
// SearchResultsBody is the results block without the live region. This is what an
// htmx request returns: the search page's form swaps it into #search-results with
// innerHTML, so returning the wrapper too would nest a second element with the
// same id inside the first and make the live region announce twice.
//
// Everything here arrives inside the polite live region, and the count carries
// role="status" on top of that, so the number is read as one utterance instead of
// a run of result titles.
templ SearchResultsBody(v SearchView) {
if v.Query == "" {
<p class="search-empty">Type one or two words above and press Enter. Good starting points: “zigbee”, “signage”, “routing”, “backups”, “guardrails”.</p>
} else if v.Failed {
<p class="search-empty">That query could not be run. Try fewer or simpler words.</p>
} else if len(v.Results) == 0 {
<p class="search-empty">Nothing found for <q>{ v.Query }</q>. Try a shorter or more general word, or browse an area:</p>
@searchAreaLinks(v.Areas)
} else {
<p class="search-count" role="status">{ strconv.Itoa(len(v.Results)) } results for <q>{ v.Query }</q></p>
<ol class="results">
for _, r := range v.Results {
<li class="result">
<h2><a href={ templ.SafeURL(r.URL) }>{ r.Title }</a></h2>
<p class="result-meta">
<span class="kind">{ r.Area }</span>
<span class="kind">L{ strconv.Itoa(r.Level) }</span>
<span class={ ConfidenceClass(r.Confidence) }>{ r.Confidence }</span>
</p>
<p class="result-snippet">@SafeSnippet(r.Snippet)
</p>
</li>
}
</ol>
}
}
// searchAreaLinks is the way out of an empty result: the six areas plus the
// evidence index, so a failed search never leaves the reader at a dead end.
templ searchAreaLinks(areas []LinkView) {
<ul class="search-areas">
for _, a := range areas {
<li><a href={ templ.SafeURL(a.Href) }>{ a.Label }</a></li>
}
<li><a href="/evidence/">Evidence index</a></li>
</ul>
}
// StreamPage proves the panel can be re-pointed at a stream. The layout and the
// panel markup are the ones every other page uses; only the binding differs.
templ StreamPage(v StreamView) {